Nobody chooses a language by its build times, and then everybody complains about them. The single largest lever on how long a large codebase takes to compile is usually not the optimizer settings. It is the decision, made once at language design time, about what happens to a generic definition when a concrete type shows up.
The idea
The cost of generics is proportional to instantiation count, not to source size, and only some languages let that count grow. A monomorphizing compiler does real codegen work per distinct type argument, so a thousand-line generic library used at thirty types compiles like thirty thousand lines. An erasing compiler does the work once regardless. Go deliberately built a third option that bounds the count by memory shape rather than by type. The non-obvious part is where the cost surfaces: not as a slow compiler, but as a slow incremental build, because the same instantiation is generated repeatedly across translation units and only reconciled at the end.
Monomorphization: the compiler writes the code you did not
The rustc developer guide says the quiet part out loud. Rust monomorphizes all generic types, which means the compiler stamps out a different copy of the code of a generic function for each concrete type needed, so a program using Vec<u64> and Vec<String> gets two copies of the generated code for Vec. And then the bill, in the guide’s own words: the result is fast programs, but it comes at the cost of compile time, since creating all those copies can take a while, and binary size, since all those copies might take a lot of space.
The mechanics explain why the cost is hard to attribute. Monomorphization is the first step in the backend of the Rust compiler, and it begins with collection, the pass that figures out which concrete types are needed, run by a component called the monomorphization collector. Everything the collector finds becomes machine code. A generic function you called once with three type arguments is three functions in the backend, three sets of optimizer passes, three entries in the object file. See monomorphization and code bloat for what this does to a binary.
C++: the same cost, spread across every translation unit
C++ arrives at the same place with an extra multiplier, because the unit of compilation is the translation unit and templates do not respect it. A function template by itself is not a type or a function, and no code is generated from a source file that contains only template definitions. For any code to appear, a template must be instantiated, so the template arguments must be determined and the compiler generates an actual function or class.
Since the definition of a class template must be visible at the point of implicit instantiation, template libraries typically provide all template definitions in the headers, which is why most Boost libraries are header-only. Every translation unit that includes the header and uses the template parses the definition again and instantiates again. Only at link time are identical instantiations generated by different translation units merged.
That is redundant work by construction, and C++ eventually shipped a manual override for it. An explicit instantiation declaration, spelled extern template, prevents implicit instantiations: code that would otherwise cause an implicit instantiation has to use the explicit instantiation definition provided somewhere else in the program. Reaching for that keyword is the moment a C++ team admits that separate compilation stopped working, and it is the build-time story of the whole language in one feature.
Erasure: one copy, and one bill you pay elsewhere
Java’s answer costs almost nothing at build time, by construction. Type erasure ensures that no new classes are created for parameterized types, so generics incur no runtime overhead in the specific sense of class count. One ArrayList class file serves every parameterization in the program, and the compiler’s per-instantiation work is a cast insertion rather than a codegen pass.
The rustc guide is fair about what that buys and what it costs, describing the erasing approach as workable because almost all variables are reference values anyway, meaning pointers to a heap-allocated object, and noting that this flexibility comes at the cost of performance, since all accesses to an object must dereference a pointer. Java’s build stays fast. Its List<Integer> traversal chases a pointer per element for the rest of the program’s life.
C#: reification without the erasure tax, paid at run time
C# sits between the two and its own documentation frames the position: C# generics are similar to generics in Java or templates in C++, but with full runtime type information and no type erasure. The type arguments survive into the intermediate language, since the runtime provides new opcodes and prefixes to support generic types in CIL, and the actual specialization happens when the runtime constructs a type rather than when the compiler emits an assembly.
The build-time consequence is that a C# assembly does not grow with instantiation count the way a Rust binary does. The consequence at the other end is that first use of a new instantiation is work the process does while running. It is the same total work as monomorphization, relocated to a place where it is charged to latency instead of to the developer’s afternoon.
Go: bounding the count on purpose
Go’s design document states the tradeoff as a design goal rather than as a discovered problem. In order to avoid creating a different function instantiation for each invocation of a generic function or method with distinct type arguments, which would be pure stenciling, the implementation passes a dictionary along with every call. But it also refuses the opposite extreme: for simplicity and performance of implementation, there is not a single compilation of a generic function for all possible type arguments.
The settlement is gcshape grouping, where two concrete types share an instantiation if and only if they have the same underlying type or they are both pointer types. Every pointer type in the program collapses to one instantiation. That single rule is what keeps the instantiation count bounded by the number of distinct memory shapes rather than by the number of types a program declares, and it is the reason Go could add generics without giving up its build times, which had been a stated selling point of the language since before it had them.
Build time is a shared cost that shows up on the wrong ledger
A slow build does not appear in any benchmark or profile. It appears as developer minutes multiplied by every engineer and every commit, and again in CI as machine time on every push. A generic abstraction that saves fifty lines and adds forty instantiations to a hot header can be a net loss, and nothing in the toolchain will tell you. The languages that monomorphize give you the levers (explicit instantiation, fewer type parameters, dynamic dispatch at the boundary), but you have to know the bill exists before you go looking for them.
The practical read: if your build is slow in a monomorphizing language, look for a generic function called at many types, especially one that is large or that lives in a widely included header, and consider making its body non-generic with a thin generic wrapper. If your build is fast and your program is slow, you are probably on the erasing side of this line, and the fix is on the other side of the same tradeoff.
Related Notes
- Intermediate Representations and SSA - where monomorphization sits, at the front of the backend
- Compilation vs Interpretation - the general question of which work happens when
- The One Definition Rule - the rule that makes link-time merging of duplicate instantiations legal
- Generics and Type Erasure in Java - the cheap-build answer in full
- Generic Specialization and Code Sharing - how the CLR avoids paying per instantiation for reference types
- Five Answers to the Same Question - the strategy that fixes each language’s build-time profile
Sources
- “Monomorphization,” Rust Compiler Development Guide. https://rustc-dev-guide.rust-lang.org/backend/monomorph.html . Supports Rust monomorphizing all generic types, stamping out a copy per concrete type, the compile-time and binary-size cost, monomorphization being the first backend step, the collection pass and its collector, and the pointer-dereference cost of the erasing approach.
- “Function template,” cppreference.com. https://en.cppreference.com/w/cpp/language/function_template.html . Supports no code being generated from template definitions alone, instantiation being required for code to appear, and extern template preventing implicit instantiation.
- “Templates,” cppreference.com. https://en.cppreference.com/w/cpp/language/templates.html . Supports class template definitions needing to be visible at the point of implicit instantiation, header-only template libraries such as most of Boost, and link-time merging of identical instantiations.
- “Type Erasure,” The Java Tutorials (Oracle). https://docs.oracle.com/javase/tutorial/java/generics/erasure.html . Supports erasure creating no new classes for parameterized types.
- “Generic types and methods,” Microsoft Learn. https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/types/generics . Supports C# generics having full runtime type information and no type erasure.
- “Generics in .NET,” Microsoft Learn. https://learn.microsoft.com/en-us/dotnet/standard/generics/ . Supports the runtime providing new CIL opcodes and prefixes for generic types.
- “Go 1.18 Implementation of Generics via Dictionaries and Gcshape Stenciling,” Go design documents. https://go.googlesource.com/proposal/+/refs/heads/master/design/generics-implementation-dictionaries-go1.18.md . Supports dictionary passing chosen to avoid pure stenciling, the refusal of a single compilation for all type arguments, and the gcshape grouping rule.