When the build order changes the program
TL;DR — C++ generates a class's copy assignment, or a template's code, only in the files that use it, and each of those files makes its own copy. The linker keeps one. If the copies differ, the link order decides which one the program runs, and the standard does not require the compiler to say anything. Swift and Haskell have the same shape; Rust rules it out with three rules. Vx follows those rules in most places. This post shows where it does not yet.
A compiler should give the same program for the same source, however the build is ordered, split or run in parallel. The code a C++ compiler generates only on use is where that promise breaks.
Code made where it is used
C++ does not generate code nobody calls. A class's implicit operator= is defined
only when something assigns one of its objects. A template's body is instantiated only for the
types it is used with. Both are generated in each file (translation unit) that uses them, as
inline functions, and every one of those files puts its own copy in its object file. The linker
keeps one copy and drops the rest.
That is fine while the copies agree. Nothing makes them agree. Here a header is compiled twice with different settings, which a large build does by accident more often than anyone would like:
// buffer.h
struct Buffer {
#ifdef LARGE
char bytes[4096];
#else
char bytes[64];
#endif
};
inline int capacity() { return sizeof(Buffer); }
// a.cpp, compiled with -DLARGE
#include "buffer.h"
int from_a() { return capacity(); }
// b.cpp, compiled without it
#include "buffer.h"
int from_b() { return capacity(); }
A main prints from_a() and from_b(). With GCC 15 on
Linux:
| Build | Prints |
|---|---|
-O0, linked as main.o a.o b.o | 4096 4096 |
-O0, linked as main.o b.o a.o | 64 64 |
-O2, either order | 4096 64 |
Three builds of the same files give three programs. At -O0 the first object file
the linker reads wins. At -O2 each file inlines its own copy, so there is no single
copy left to pick. An implicit operator= on Buffer is generated the same
way, so it would copy 4096 bytes in one file and 64 in the other.
The standard calls this a violation of the One Definition Rule: a program is ill-formed if two files define the same inline function differently. It also says "no diagnostic required", so neither the compiler nor the linker has to report it. The same holds for templates: if the code a template means depends on where it is instantiated, the program is ill-formed, with no diagnostic required.
Modules help, but not completely
C++20 modules remove the example above: a module is compiled once, so a macro can no longer give two files two versions of it. They do not remove the rule. A template is still instantiated in the modules that use it, and what it means there can still depend on which declarations that module can reach. When two modules bring in the same definition, the compiler has to merge them, and which one it sees first depends on the order the modules are loaded. Clang reports some mismatches as "different definitions in different modules". The standard still does not require it to.
Other languages with the same problem
The pattern is not specific to C++. It appears wherever something is chosen at the place it is used, and nothing makes every place choose the same way.
- Swift. Two modules can each make the same type conform to the same protocol
(a "retroactive conformance"). When a program contains both, the Swift runtime has to make
exactly one of them active, and which one is not specified. Since
SE-0364
the compiler warns about such conformances unless they are marked
@retroactive, which records that the author accepts the risk; it does not remove it. - Haskell. An "orphan" instance is one declared in a module that owns neither
the class nor the type. Two libraries can both declare the same orphan instance. GHC reports
overlapping instances only when it has to choose one for a use, not where they are declared or
imported, so a program that imports both compiles until something uses the instance. The GHC
developers chose this on purpose: refusing the import would stop two libraries with overlapping
instances from ever being used together. With the
IncoherentInstancesextension the choice can depend on where it is made. - Java, and C++ static variables. A static initializer runs when the class is first used, and C++ runs initializers across files in an unspecified order (the "static initialization order fiasco"). This is order at run time rather than at build time, but the cause is the same: work happens on first use, and the first use is not fixed.
Languages that rule it out
Rust generates generic code on use too, but the result cannot depend on where. Three rules make sure of that:
- One implementation per type and trait. An
implmust be in the crate that defines the trait or the type (the orphan rule), and two impls may not overlap. A whole program has at most one implementation of any trait for any type, so there is nothing to choose. - Generated code depends only on the type.
CloneandCopyare asked for with#[derive]where the type is defined, not created where they are used. - Generic code is checked once. A generic function is type-checked against its bounds, not once per type it is used with, so an error cannot appear in one use and not another.
Go and C# also check generic code once. Zig is a useful middle case: it analyses a function only if something refers to it, so an unused function with a type error compiles. That depends on which functions are reachable, not on the order anything is built, so it is still deterministic.
With these rules, generating code only on use is purely an optimization: it saves work, and it cannot change what the program does.
Where Vx stands
Vx compiles modules and functions in parallel with vxc -j, so this question is not
academic. We checked each rule against the compiler as it is today.
The parallel build is the same program. A test compiles a program two modules deep at one thread and at four, and requires the generated MLIR to be the same bytes. The checking and the lowering of modules run concurrently at four threads, so module order is part of what the test compares.
Unused library functions are not checked, the way Zig does it. A function in
the program's own file is always checked. A function in an imported module is checked only if the
program uses it, so a library's type error appears only in programs that call the function. The
error names the program's use: in `from_left`, imported from `left` and used by this
program. This depends on what is reachable, not on build order, and the test for it runs
with one thread, with four, and on both of Vx's code generators.
Two implementations of one trait for one type are refused, but too late. Here two modules implement the same trait for the same type, one returning 1 and one returning 2:
// shape.vx
struct Point { x : i32, y : i32, }
trait Describe { fn describe(self : &Self) -> i32; }
fn make() -> Point { return Point { x : 1, y : 2 }; }
// left.vx
import shape;
impl Describe for Point { fn describe(self : &Point) -> i32 { return 1; } }
fn from_left(p : &Point) -> i32 { return p.describe(); }
// right.vx: the same, but it returns 2 and the function is from_right
// main.vx
import shape;
import left;
import right;
fn main() -> i32 {
let p = make();
print(from_left(&p));
print(from_right(&p));
return 0;
}
Vx never picks one silently. The program is refused with E3035 in both import orders, at one
thread and at four, and on both code generators, with the same errors each time. (A
-j build prints them in a slightly different order from a plain build, but in the
same order at every thread count.)
error[E3035]: in `from_left`, imported from `left` and used by this program: Method 'describe' on 'Point' is defined by more than one impl (Describe, Describe); name the trait to choose one, as in 'Describe::describe(..)'
But this is GHC's behaviour, not Rust's. The two impls themselves are accepted: delete the two
calls to describe and the program compiles. The error lands on whichever function
calls the method, which may be in a third module whose author did nothing wrong. And the help is
wrong for this case: both impls are of Describe, so naming the trait gives the same
error. Both problems are filed as
#1443. The fix is to report the second
impl where it is written. Whether Vx should also adopt Rust's orphan rule, which makes the
cross-module case impossible to write, is a larger decision.
Vx is Apache 2.0 with the LLVM exception — install it and try the examples.