I've been reading some papers on separate compilation, such as Cardelli's Program fragments, linking, and modularization, and I'm seeing a trend in using substitution to model the linking of compilation units (units for short). While substitution does a fine job of conveying the semantics of the final executable, I think it falls down in conveying the semantics of a unit, especially from the point of view of a compiler writer.
What I'm interested in here is the situation in which one or more compilers are used to compile several units, and then these units are linked together into an executable. We want to allow the compilers to perform aggressive optimizations within a unit, while still requiring the compilers to respect the application binary interface (ABI), which says how different units must talk to each other.
To take a stab at such a semantics, let's consider a variant of the lambda calculus that supports separate compilation. Each compilation unit will have a name c and a function table , which maps function names to lambdas. At run-time, each compilation unit will also need a stack to store it's part of the procedure call stack. In addition to the usual expressions of the lambda calculus, we'll allow references to functions in other compilation units: the expression c.f refers to the function named f in unit c. Now, we want the observable behavior to be function calls and returns that cross from one unit to another. One way to specify this behavior is to create an abstract machine that logs the crossings (producing a trace). It would be preferable to map directly from a program to the trace, but I'm better at thinking in terms of abstract machines. Thus, the approach I'm using here is similar to that of the paper Java Jr.: Fully abstract trace semantics for a core Java language by Jeffrey and Rathke, which addresses the same problem that we're concerned with here, but for a subset of Java. More generally, the use of transition systems to generate traces goes back at least as far as A Theory of Communicating Sequential Processes by Brookes, Hoare, and Roscoe.
The lambda calculus includes first-class functions, so we need to address the question: what happens when a lambda is passed from one compilation unit to another? Drawing inspiration from the paper Temporal higher-order contracts, we don't literally pass lambdas, but instead the originating unit must give the lambda a name and then pass a reference to the lambda. We'll let range over the exportable values.
The state of the machine is the actively executing expression e, the current compilation unit c, the previous compilation unit (that called the current one), and the unit table U (mapping unit names to units). We write for accessing the function table of unit c from inside U and we write for accessing it's stack. To allow internal computation within a unit, we define the following transition which single-steps the active expression according to reduction rules of the lambda calculus. A call to an external function looks up the function in the target unit , installs the function body as the active expression, and records the continuation (evaluation context) and the previous unit on the stack of the caller unit. The annotation on top of the is how we log that an observable action occurred. We also need to deal with the case where an internal function has been passed to another unit and then passed back. A call to such a function should not be observable. Thus, we have the following transition rule to convert from a reference to a function back to a lambda expression. When the active expression has been reduced to an exportable value, it's time to return to the caller . We pop the activation record from the top of the caller's stack and install as the active expression. The following transition rule converts a lambda into a reference that is suitable for export by registering it in the function table. where the context is defined as follows So this rule converts lambdas to references when they need to be returned to another unit or when they are to be passed as an argument to another unit.
We can now define the semantics, the eval function, for the separately-compiled lambda calculus. Let s be an initial state (all the stacks are empty, the current unit is I (for initial), which represents the operating system, and the active expression is a call to the main function in the first unit with argument 42).
Exercise: Write down the machine transitions for the following initial state. Then compute the result of . It should be (Up to your choice of name for the lambda passed to . Here we've named it .)
The take-away for the compiler writer is that they can compile a unit any way they want, so long as the log comes out as specified by eval.