Understanding Source-Level Inlining
So, what exactly *is* source-level inlining? Essentially, it means replacing a function call directly in your source code with a copy of that function's body, meticulously substituting arguments for parameters. It's a durable modification to the code you write. This differs significantly from the inlining many of us are familiar with in compilers, including Go's own. Compiler-level inlining operates on an ephemeral intermediate representation to optimize the generated machine code, a temporary transformation. Source-level inlining, however, permanently alters your `.go` files. If you've ever used `gopls`' "Inline call" interactive refactoring – say, in VS Code – you've already experienced this technology in action. (You can find more on that feature here.) Consider this simple example, where a call to `sum` within the `six` function gets inlined:
Automated API Migration with `//go:fix inline`
The true power of this new inliner shines within the updated `go fix` command, primarily through a new directive: `//go:fix inline`. This simple comment tells the tool to automatically inline any call to the annotated function. Take the `ioutil.ReadFile` function as a classic example. Deprecated in Go 1.16 in favor of `os.ReadFile`, its continued presence is a testament to Go's compatibility promise, preventing the outright removal of old names. Ideally, every Go program would transition to `os.ReadFile`. By adding `//go:fix inline` to `ioutil.ReadFile`'s definition: ```go package ioutil import "os" // ReadFile reads the file named by filename… // Deprecated: As of Go 1.16, this function simply calls [os.ReadFile]. //go:fix inline func ReadFile(filename string) ([]byte, error) { return os.ReadFile(filename) } ``` Running `go fix` then yields the desired replacement, effectively changing the import and the function call: ``` $ go fix -diff ./... -import "io/ioutil" +import "os" - data, err := ioutil.ReadFile("hello.txt") + data, err := os.ReadFile("hello.txt") ``` This transformation's critical distinction is its safety. Because the inliner replaces a call with the *called function's body*, the program's behavior shouldn't change (unless, of course, your code inspects the call stack). This is a stark contrast to tools like `gofmt -r`, which allow arbitrary rewrites that are incredibly powerful but demand a far higher degree of scrutiny. This isn't some experimental concept. Our Google colleagues, working with Java, Kotlin, and C++ codebases, have been using similar source-level inliner tools for years. They've eliminated millions of calls to deprecated functions across a monorepo spanning billions of lines of code. Developers simply add directives, and automated systems prepare, test, and submit batches of changes. Go's inliner is the new kid on the block, but it's already facilitated over 18,000 changelists within Google's monorepo, a clear validation of its utility at scale.Beyond Renames: Tackling API Design Flaws
The `//go:fix inline` directive isn't limited to simple renames. With a bit of strategic implementation, it can resolve deeper API design issues. Consider a hypothetical `oldmath` package with problems like `Sub` having its parameters reversed, `Inf` being ambiguous, and `Neg` being redundant. By deprecating these functions and implementing them in terms of a `newmath` package, then adding the `//go:fix inline` directive: ```go // Package oldmath is the bad old math package. package oldmath import "newmath" // Sub returns x - y. // Deprecated: the parameter order is confusing. //go:fix inline func Sub(y, x int) int { return newmath.Sub(x, y) } // Inf returns positive infinity. // Deprecated: there are two infinite values; be explicit. //go:fix inline func Inf() float64 { return newmath.Inf(+1) } // Neg returns -x. // Deprecated: this function is unnecessary. //go:fix inline func Neg(x int) int { return newmath.Sub(0, x) } ``` Now, when users of `oldmath` run `go fix`, calls like `oldmath.Sub(1, 10)` will transform into `newmath.Sub(10, 1)`, correcting the argument order. If you're using `gopls`, you'll even get diagnostics and suggested fixes in your editor the moment you add those `//go:fix inline` directives. This functionality extends to types and constants as well, allowing for seamless migration of declarations like `type Rational = newmath.Rational` or `const Pi = newmath.Pi`. The goal, of course, is to eventually remove the deprecated `oldmath` package entirely once all call sites are gone.Under the Hood: The Inliner's Complexities
While the concept of source inlining seems straightforward – swap a call for a function body, manage parameters – the actual implementation is anything but. This isn't a simple regex replacement. The inliner contains roughly 7,000 lines of dense, compiler-like logic to correctly handle all the intricacies. Let's look at a few of the challenges that make this process so tricky.Parameter Elimination
One of the inliner's primary jobs is to replace parameter occurrences within the callee's body with the corresponding arguments from the call. Simple literals like `0` or `""` are easy. However, if a non-trivial literal, such as `404`, appears multiple times, simply copying the value could obscure its original intent and lead to inconsistencies if only one instance is later changed. In these cases, the inliner wisely inserts an explicit "parameter binding" declaration to maintain clarity and prevent scattered magic values. Consider `printPair`:
//go:fix inline
func printPair(before, x, y, after string) {
fmt.Println(before, x, after)
fmt.Println(before, y, after)
}
printPair("[", "one", "two", "]")
// a “parameter binding” declaration var before, after = "[", "]" fmt.Println(before, "one", after) fmt.Println(before, "two", after)
Side Effects
A function call can modify variables, impacting subsequent operations. The inliner has to be acutely aware of argument evaluation order, especially when functions like `f()` and `g()` might have side effects. A naive substitution in `z = add(f(), g())` that reorders `g()` before `f()` would be incorrect if their effects depend on each other. The inliner attempts to prove that arguments have no interdependencies. If it can't, it defaults to a safer explicit parameter binding, like `var x = f(); z = g() + x`. The complexity doesn't stop with arguments. The order in which parameters are used within the function body also matters. If a parameter is used inside a loop, direct substitution could alter the number of times an effect occurs. The inliner employs a novel hazard analysis to model effect ordering. That said, its ability to construct these safety proofs is understandably limited. For instance, while an optimizing compiler might know `start()` has no effect today and delete its calls, the inliner cannot, as the function's implementation might change tomorrow. This often means the inliner produces conservative results that a maintainer might want to manually clean up for better style."Fallible" Constant Expressions
You might assume replacing a parameter with a constant argument is always safe. Surprisingly, it isn't. Certain runtime checks can shift to compile-time and fail. Consider `index("", 0)`. A direct inline yields `""[0]`, which is a compile-time error in Go because it's an out-of-bounds index on a constant string. The original code would only fail if executed. The inliner must track expressions that might become constant during substitution and trigger new compile-time checks. It builds a constraint system, adding explicit bindings for parameters if constraints aren't met.Shadowing
Identifiers in argument expressions must refer to the same symbols after substitution. If a name in the caller's scope would be *shadowed* by a name in the callee's body, the inliner needs to step in. It inserts parameter bindings to ensure the original binding is preserved. For example, when inlining `f(x)` where `x` is defined in the caller, but also internally in `f`:
//go:fix inline
func f(val string) {
x := 123
fmt.Println(val, x)
}
x := "hello" f(x)
x := "hello"
{
// another “parameter binding” declaration
// to read the caller's x before shadowing it
var val string = x
x := 123
fmt.Println(val, x)
}
Unused Variables
Finally, eliminating an argument expression that has no side effects and corresponds to an unused parameter can unexpectedly break your code. If that expression contained the *last* reference to a local variable in the caller, that variable suddenly becomes unused, leading to a compile error. Consider:
//go:fix inline
func f(_ int) { print("hello") }
x := 42 f(x)
x := 42 // error: unused variable: x
print("hello")
//go:fix inline
func callee() {
defer f()
…
}
callee()
func() {
defer f()
…
}()
Next article: Type Construction and Cycle Detection
Previous article: Allocating on the Stack
Blog Index