Generic programming in different languages
Generic programming is a paradigm that allows describing algorithms and data structures in abstraction from concrete types. Despite the single concept, each language implements it in its own way: through templates, parametric polymorphism, type erasure or duck typing. This article provides a detailed analysis of the mechanisms of generic programming in eight languages - C++, Java, C#, Python, Rust, Haskell, TypeScript, Julia - with an assessment of the strengths and weaknesses of each approach and a comparative summary.
1. Introduction
The idea of generic programming goes back to the works of Alexander Stepanov and David Musser in the late 1980s. Its essence is the separation of algorithms from concrete data types while preserving type safety and controlled performance. In practice, different languages solve this task through different mechanisms:
| Approach | Representatives |
|---|---|
| Templates (compile-time code generation) | C++, Rust |
| Type erasure | Java, TypeScript |
| Reification (reified generics) | C# |
| Parametric polymorphism + typeclasses | Haskell |
| Annotations + structural typing | Python |
| Multiple dispatch + JIT | Julia |
Each mechanism is considered according to the following criteria: the implementation model, the system of constraints on types, type safety, performance, expressiveness, limitations and known problems.
2. C++ - Templates and concepts
2.1 Implementation model
C++ implements generic programming through templates - a mechanism of compile-time metaprogramming. A template is an instruction to the compiler to generate code for each concrete set of arguments. This mechanism is called monomorphization: for each instantiation of the template, a separate copy of the code is generated.
// Function template
template<typename T>
T max_value(T a, T b) {
return (a > b) ? a : b;
}
// Class template with a default parameter
template<typename T, typename Allocator = std::allocator<T>>
class Stack {
std::vector<T, Allocator> data_;
public:
void push(T value) { data_.push_back(std::move(value)); }
T pop() {
T val = std::move(data_.back());
data_.pop_back();
return val;
}
bool empty() const { return data_.empty(); }
};
// Full specialization of the template for bool
template<>
class Stack<bool> {
std::vector<uint8_t> data_; // bitwise storage
// ...
};
2.2 Concepts (C++20)
Before C++20, constraints on types were expressed through SFINAE (Substitution Failure Is Not An Error) and std::enable_if. C++20 introduced concepts - named predicates over types, checked at compile time.
#include <concepts>
#include <numeric>
// Definition of a concept
template<typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;
template<typename T>
concept Sortable = requires(T container) {
{ container.begin() } -> std::input_iterator;
{ container.end() } -> std::input_iterator;
requires std::totally_ordered<typename T::value_type>;
};
// Using the concept
template<Numeric T>
T sum(const std::vector<T>& v) {
return std::accumulate(v.begin(), v.end(), T{});
}
// Compound constraints
template<typename T>
requires Sortable<T> && (!std::same_as<typename T::value_type, bool>)
void sort_container(T& container) {
std::sort(container.begin(), container.end());
}
// Abbreviated syntax (abbreviated function template)
void print_numeric(Numeric auto value) {
std::cout << value << '\n';
}
2.3 Template metaprogramming
C++ provides a mechanism for computations at compile time:
// Computing the Fibonacci number at compile time
template<int N>
struct Fibonacci {
static constexpr int value =
Fibonacci<N-1>::value + Fibonacci<N-2>::value;
};
template<> struct Fibonacci<0> { static constexpr int value = 0; };
template<> struct Fibonacci<1> { static constexpr int value = 1; };
// Variadic templates + fold expressions (C++17)
template<typename... Args>
auto sum_all(Args&&... args) {
return (... + args);
}
// Branching at the type level (if constexpr, C++17)
template<typename T>
void process(T value) {
if constexpr (std::is_integral_v<T>) {
// branch for integers
} else if constexpr (std::is_floating_point_v<T>) {
// branch for floating-point numbers
}
}
2.4 Advantages and disadvantages
Advantages:
- Zero runtime overhead - all polymorphism is resolved at compile time.
- Templates are Turing-complete at compile time, which opens up broad possibilities for metaprogramming.
- Partial and full specializations make it possible to provide optimized implementations for concrete types.
- Concepts (C++20) make compilation errors readable and turn constraints into part of the documentation.
- The standard library (STL) is built on templates and is a reference implementation of generic programming.
Disadvantages:
- Binary file bloat (code bloat): each instantiation generates separate machine code.
- Slow compilation: template code is compiled anew in each translation unit.
- The implementation of templates must be located in header files, which violates encapsulation.
- Before C++20, error diagnostics were extremely unreadable; with concepts the situation improved, but did not become trivial.
- SFINAE and partial specializations remain difficult even for experienced developers.
3. Java - Generics and type erasure
3.1 Implementation model
Java added generics in version 1.5 (2004). The key design decision is type erasure: type parameters exist only at compile time and are completely removed from the bytecode. All instantiations of List<String> and List<Integer> in the compiled code turn into List (raw type), and the type parameters are replaced by their upper bounds (usually Object).
// Generic class
public class Pair<A, B> {
private final A first;
private final B second;
public Pair(A first, B second) {
this.first = first;
this.second = second;
}
public A getFirst() { return first; }
public B getSecond() { return second; }
public static <X, Y> Pair<X, Y> of(X x, Y y) {
return new Pair<>(x, y);
}
}
// Bounded type parameter
public class SortedList<T extends Comparable<T>> {
private final List<T> items = new ArrayList<>();
public void add(T item) { items.add(item); }
public T min() {
return items.stream()
.min(Comparator.naturalOrder())
.orElseThrow();
}
}
3.2 Wildcards and variance
Java uses use-site variance through wildcards:
// ? extends T - covariance (reading)
public double sumList(List<? extends Number> list) {
return list.stream()
.mapToDouble(Number::doubleValue)
.sum();
}
// ? super T - contravariance (writing)
public <T extends Comparable<T>> void sortInto(
List<T> source,
List<? super T> destination
) {
List<T> sorted = new ArrayList<>(source);
Collections.sort(sorted);
destination.addAll(sorted);
}
// PECS: Producer Extends, Consumer Super
public <T> void copy(List<? extends T> src, List<? super T> dst) {
for (T item : src) dst.add(item);
}
3.3 Limitations of type erasure
public class TypeErasureProblems<T> {
// Compilation error: cannot create an array of a parameterized type
// T[] array = new T[10];
// Compilation error: cannot check membership in a parameterized type
// if (obj instanceof List<String>) { }
// Compilation error: cannot create an instance of a type parameter
// T instance = new T();
// Workaround: pass Class<T> explicitly
public T createInstance(Class<T> clazz) throws Exception {
return clazz.getDeclaredConstructor().newInstance();
}
public boolean isInstanceOf(Object obj, Class<T> clazz) {
return clazz.isInstance(obj);
}
}
3.4 Advantages and disadvantages
Advantages:
- Backward compatibility: raw types make it possible to use new generic classes in code without parameters.
- A single bytecode instance serves all instantiations - no binary file bloat.
- Wildcards provide expressive use-site variance.
- Integration with reflection allows tooling to work with generic code.
Disadvantages:
- Type erasure excludes the possibility of using type parameters at runtime.
- Primitive types cannot be type arguments:
List<int>is impossible, onlyList<Integer>with boxing/unboxing overhead. ? extendsand? superare a source of constant errors for developers.- No reified generics: information about the real type parameter at runtime is unavailable.
- Overloading methods by type parameters is impossible:
void f(List<String>)andvoid f(List<Integer>)conflict after erasure. - No specializations: it is impossible to provide an optimized implementation for a concrete type.
4. C# - Generics with reification
4.1 Implementation model
C# implements reified generics: type parameters are preserved both in the compiled code and at runtime. The CLR generates separate native code for each instantiation with a value type and one shared implementation for all instantiations with reference types.
// Generic class with several constraints
public class Repository<TEntity, TKey>
where TEntity : class, IEntity<TKey>, new()
where TKey : struct, IComparable<TKey>
{
private readonly Dictionary<TKey, TEntity> _store = new();
public void Add(TEntity entity) =>
_store[entity.Id] = entity;
public TEntity? Find(TKey id) =>
_store.TryGetValue(id, out var entity) ? entity : null;
// Creating an instance is possible thanks to the new() constraint
public TEntity CreateAndAdd(TKey id) {
var entity = new TEntity { Id = id };
_store[id] = entity;
return entity;
}
}
// Generic extension method
public static class Extensions {
public static TResult Pipe<TInput, TResult>(
this TInput value,
Func<TInput, TResult> transform
) => transform(value);
}
4.2 Constraint system
// Full list of C# constraints
public class Constraints<T>
where T : class // reference type
// where T : struct // value type (incompatible with class)
where T : new() // public parameterless constructor
where T : SomeBaseClass // inheritance from a class
where T : ISomeInterface // interface implementation
where T : unmanaged // unmanaged type (C# 7.3)
where T : notnull // non-nullable (C# 8.0)
{ }
// Static abstract members in interfaces (C# 11)
// make it possible to generalize operators and static factories
public interface IAddable<T> where T : IAddable<T> {
static abstract T operator+(T left, T right);
static abstract T Zero { get; }
}
public static T Sum<T>(IEnumerable<T> source)
where T : IAddable<T>
{
return source.Aggregate(T.Zero, (acc, x) => acc + x);
}
4.3 Reification at runtime
using System.Reflection;
public class RuntimeGenericDemo {
public void ShowReification<T>() {
// The real type is available at runtime
Type type = typeof(T);
Console.WriteLine(type.Name);
// Check at runtime
if (typeof(T) == typeof(string)) {
// a specific branch for strings
}
// Creating a closed generic type dynamically
Type listType = typeof(List<>).MakeGenericType(type);
var list = Activator.CreateInstance(listType)!;
// Getting generic parameters through reflection
if (type.IsGenericType) {
Type[] args = type.GetGenericArguments();
foreach (var arg in args)
Console.WriteLine(arg.Name);
}
}
}
4.4 Variance in C#
// Declaration-site variance on interfaces and delegates
// Covariance (out): IEnumerable<Derived> is compatible with IEnumerable<Base>
public interface IProducer<out T> {
T Produce();
}
// Contravariance (in): Action<Base> is compatible with Action<Derived>
public interface IConsumer<in T> {
void Consume(T item);
}
// Application: safe assignment
IEnumerable<string> strings = new List<string>();
IEnumerable<object> objects = strings; // works, because out
4.5 Advantages and disadvantages
Advantages:
- Reification provides complete type information at runtime.
- Value types (int, double, struct) are used without boxing:
List<int>storesint, notobject. - The constraint system includes
new(),unmanaged,notnull- capabilities unavailable in Java. - Static abstract members of interfaces (C# 11) make it possible to generalize operators and other static contracts.
- Declaration-site variance through
in/outon interfaces and delegates.
Disadvantages:
- Variance is available only for interfaces and delegates, but not for classes.
- The constraint system is less expressive than the concepts of C++ or the typeclasses of Haskell: arbitrary predicates over a type cannot be expressed.
- No specializations: it is impossible to provide a different implementation for a concrete type argument.
- For value types, separate code is generated for each instantiation, which increases the assembly size.
- The constraint syntax becomes cumbersome with several type parameters.
5. Python - Generics through annotations and structural typing
5.1 Implementation model
Python is a dynamically typed language. Type parameters exist only for static analyzers (mypy, pyright, pytype) and are completely ignored by the interpreter. Real flexibility is provided by duck typing: an object fits anywhere it supports the required operations.
from typing import TypeVar, Generic
from collections.abc import Iterator, Sequence
T = TypeVar('T')
# Generic class
class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
if not self._items:
raise IndexError("Stack is empty")
return self._items.pop()
def peek(self) -> T:
return self._items[-1]
def __len__(self) -> int:
return len(self._items)
def __iter__(self) -> Iterator[T]:
return reversed(self._items) # type: ignore[arg-type]
# Generic function
def first(sequence: Sequence[T]) -> T:
if not sequence:
raise ValueError("Empty sequence")
return sequence[0]
5.2 Protocol - structural typing
from typing import Protocol, runtime_checkable
# Structural interface: a type is compatible if it has the needed attributes
@runtime_checkable
class Comparable(Protocol):
def __lt__(self, other: 'Comparable') -> bool: ...
def __le__(self, other: 'Comparable') -> bool: ...
# TypeVar with a constraint through Protocol
T_Comparable = TypeVar('T_Comparable', bound=Comparable)
def max_element(items: Sequence[T_Comparable]) -> T_Comparable:
if not items:
raise ValueError("Empty sequence")
result = items[0]
for item in items[1:]:
if item > result:
result = item
return result
# TypeVar with an explicit enumeration of allowed types
Numeric = TypeVar('Numeric', int, float, complex)
def add(a: Numeric, b: Numeric) -> Numeric:
return a + b # type: ignore
5.3 Python 3.12 syntax (PEP 695)
# New built-in type parameter syntax
def first[T](lst: list[T]) -> T:
return lst[0]
class Pair[T, U]:
def __init__(self, first: T, second: U) -> None:
self.first = first
self.second = second
# Type alias
type Vector[T] = list[T]
# Constraint in the new syntax
def max_val[T: (int, float)](a: T, b: T) -> T:
return a if a > b else b
5.4 Advantages and disadvantages
Advantages:
- Duck typing provides real flexibility without compatibility declarations.
- Protocol implements structural typing: a type is compatible with a protocol without explicit inheritance.
- Gradual typing: annotations are optional and can be added incrementally.
- The Python 3.12 syntax makes type parameters part of the language syntax, and not a library construct.
Disadvantages:
- No type safety at runtime:
Stack[int]is identical toStack[str]from the interpreter’s point of view. - Static analyzers (mypy, pyright) interpret the same constructs differently.
- No type-based optimizations: the type of an argument does not affect the generated bytecode.
- All objects are stored as references; there is no equivalent of unboxed value types.
- The difference between
bound=and an enumeration of types inTypeVaris non-obvious and leads to errors.
6. Rust - Traits and monomorphization
6.1 Implementation model
Rust implements generic programming through traits and monomorphization. Traits describe behavior (a set of methods and associated types), and generic functions are parameterized by trait bounds. During compilation, a separate implementation is generated for each combination of concrete types.
use std::fmt::Display;
// Generic structure
#[derive(Debug, Clone)]
struct Pair<T> {
first: T,
second: T,
}
// Implementing methods with constraints
impl<T: Display + PartialOrd> Pair<T> {
fn new(first: T, second: T) -> Self {
Self { first, second }
}
fn cmp_display(&self) {
if self.first >= self.second {
println!("{}", self.first);
} else {
println!("{}", self.second);
}
}
}
// Generic function with a trait bound
fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
let mut result = list[0];
for &item in list.iter() {
if item > result {
result = item;
}
}
result
}
6.2 Traits, where-clauses, static and dynamic dispatch
use std::fmt::{Debug, Display};
// where-clause for readability with several constraints
fn print_pair<T, U>(pair: (T, U))
where
T: Display + Debug,
U: Display + Clone,
{
println!("({}, {})", pair.0, pair.1);
}
// Static dispatch: the compiler generates separate code for each T
fn static_dispatch<T: Display>(value: T) {
println!("{}", value);
}
// Dynamic dispatch: one code, method selection through a vtable at runtime
fn dynamic_dispatch(value: &dyn Display) {
println!("{}", value);
}
// impl Trait as an opaque return type
fn makeadder(x: i32) -> impl Fn(i32) -> i32 {
move |y| x + y
}
// Associated types in traits
trait Container {
type Item;
fn first(&self) -> Option<&Self::Item>;
fn last(&self) -> Option<&Self::Item>;
fn len(&self) -> usize;
fn is_empty(&self) -> bool { self.len() == 0 }
}
6.3 Const Generics and Generic Associated Types
// Const generics (stable since Rust 1.51)
struct Matrix<T, const ROWS: usize, const COLS: usize> {
data: [[T; COLS]; ROWS],
}
impl<T, const N: usize> Matrix<T, N, N>
where
T: Default + Copy + std::ops::Add<Output = T>,
{
fn trace(&self) -> T {
let mut sum = T::default();
for i in 0..N {
sum = sum + self.data[i][i];
}
sum
}
}
// Generic Associated Types (GAT, stable since Rust 1.65)
trait StreamingIterator {
type Item<'a> where Self: 'a;
fn next<'a>(&'a mut self) -> Option<Self::Item<'a>>;
}
// PhantomData for type markers without storing values
use std::marker::PhantomData;
struct Wrapper<T, State> {
value: T,
_state: PhantomData<State>,
}
struct Locked;
struct Unlocked;
impl<T> Wrapper<T, Locked> {
fn unlock(self) -> Wrapper<T, Unlocked> {
Wrapper { value: self.value, _state: PhantomData }
}
}
6.4 Advantages and disadvantages
Advantages:
- Monomorphization provides performance comparable to C++, without the overhead of dynamic dispatch.
- Traits are an expressive constraint system with support for associated types and default methods.
- Const generics make it possible to be parameterized by constant values (array sizes, numeric parameters).
- The borrow checker works at full strength inside generic code, preserving memory safety guarantees.
- GATs make it possible to express complex dependencies between lifetimes and associated types.
- A clear distinction between static (
T: Trait) and dynamic (dyn Trait) dispatch - the choice is explicit.
Disadvantages:
- Monomorphization leads to binary file bloat when there is a large number of instantiations.
- The combination of generics with lifetime annotations quickly increases the complexity of signatures.
- Trait specialization (the
specializationfeature) is unstable and unavailable in stable Rust. - Slow compilation: borrow checking + monomorphization + LLVM optimizations.
- GATs have known limitations: a number of patterns cannot be expressed due to the limitations of the type inference system.
7. Haskell - Parametric polymorphism and typeclasses
7.1 Implementation model
Haskell implements parametric polymorphism based on the Hindley-Milner type system. The compiler automatically infers types; explicit annotations are optional. Typeclasses are a mechanism of ad-hoc polymorphism: they describe a set of operations that a type must support, and make it possible to define instances retrospectively.
-- Parametric polymorphism: works for any type a
identity :: a -> a
identity x = x
-- Typeclass with several methods
class Container f where
empty :: f a
insert :: a -> f a -> f a
toList :: f a -> [a]
-- Instance for a list
instance Container [] where
empty = []
insert = (:)
toList = id
-- Generic function with a typeclass constraint
fromList :: Container f => [a] -> f a
fromList = foldr insert empty
-- User-defined type with instances of standard typeclasses
data Tree a = Leaf | Node (Tree a) a (Tree a)
instance Functor Tree where
fmap _ Leaf = Leaf
fmap f (Node l x r) = Node (fmap f l) (f x) (fmap f r)
instance Foldable Tree where
foldr _ z Leaf = z
foldr f z (Node l x r) = foldr f (f x (foldr f z r)) l
7.2 Multi-parameter typeclasses and higher-kinded types
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE FunctionalDependencies #-}
{-# LANGUAGE FlexibleInstances #-}
-- Multi-parameter typeclass with a functional dependency
-- a -> b means: the type a uniquely determines the type b
class Convert a b | a -> b where
convert :: a -> b
instance Convert String Int where convert = read
instance Convert Int Double where convert = fromIntegral
-- Higher-kinded types (kind * -> *): generalization over type constructors
class Monad m where
return :: a -> m a
(>>=) :: m a -> (a -> m b) -> m b
-- Monad transformer: parameterization over the type constructor m
newtype StateT s m a = StateT { runStateT :: s -> m (a, s) }
instance Monad m => Monad (StateT s m) where
return a = StateT $ \s -> return (a, s)
m >>= f = StateT $ \s -> do
(a, s') <- runStateT m s
runStateT (f a) s'
7.3 Type Families
{-# LANGUAGE TypeFamilies #-}
-- Associated type families
class Collection c where
type Element c
insert' :: Element c -> c -> c
member :: Element c -> c -> Bool
toList' :: c -> [Element c]
data IntSet = IntSet [Int]
instance Collection IntSet where
type Element IntSet = Int
insert' x (IntSet xs) = IntSet (x : xs)
member x (IntSet xs) = x `elem` xs
toList' (IntSet xs) = xs
-- Closed type families for computations at the type level
type family If (b :: Bool) (t :: *) (f :: *) :: * where
If 'True t _ = t
If 'False _ f = f
7.4 Advantages and disadvantages
Advantages:
- Parametric polymorphism guarantees correctness through parametricity theorems: all possible implementations can be derived from the signature of a function.
- Type inference removes the need to explicitly specify type parameters in most cases.
- Typeclasses support retrospective instances: one can add an implementation for a third-party type.
- Higher-kinded types make it possible to generalize over type constructors (
Functor,Monad,Traversable). - Type families provide computations at the type level, expressed declaratively.
- Typeclasses allow formal laws that document the invariants of behavior.
Disadvantages:
- For one type there can be only one instance of a given typeclass in scope. Instance conflicts (orphan instances) are a source of problems when working with several libraries.
- Compiler errors when using multi-parameter typeclasses and type families are hard to read.
- There is no specialization mechanism: it is impossible to provide an optimized instance for a concrete type bypassing the general one.
- Higher-kinded type parameters practically do not occur in other widespread languages, which complicates the transfer of patterns.
- Lazy evaluation interacts with generic code unpredictably from the point of view of memory consumption.
8. TypeScript - Structural typing and generics
8.1 Implementation model
TypeScript adds static typing to JavaScript through type erasure: all type annotations are removed when compiling to JavaScript. The basis of the system is structural typing: compatibility is determined by the set of properties and methods, and not by the name of the type.
// Generic interface
interface Repository<T, ID> {
findById(id: ID): Promise<T | null>;
findAll(): Promise<T[]>;
save(entity: T): Promise<T>;
delete(id: ID): Promise<void>;
}
// Generic class
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T {
const item = this.items.pop();
if (item === undefined) throw new Error('Stack is empty');
return item;
}
peek(): T {
if (this.items.length === 0) throw new Error('Stack is empty');
return this.items[this.items.length - 1];
}
get size(): number { return this.items.length; }
}
// Generic functions
function identity<T>(arg: T): T { return arg; }
function zip<A, B>(as: A[], bs: B[]): [A, B][] {
return as.map((a, i) => [a, bs[i]]);
}
8.2 Conditional types and mapped types
// Conditional types
type IsArray<T> = T extends any[] ? true : false;
type Flatten<T> = T extends Array<infer U> ? U : T;
// Standard utility types through mapped types
type Readonly<T> = { readonly [K in keyof T]: T[K] };
type Partial<T> = { [K in keyof T]?: T[K] };
type Required<T> = { [K in keyof T]-?: T[K] };
// Recursive mapped type
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? DeepReadonly<T[K]>
: T[K];
};
// Template literal types
type EventName<T extends string> = `on${Capitalize<T>}`;
type ClickHandler = EventName<'click'>; // 'onClick'
// infer in conditional types
type ReturnType<T extends (...args: any[]) => any> =
T extends (...args: any[]) => infer R ? R : never;
type Parameters<T extends (...args: any[]) => any> =
T extends (...args: infer P) => any ? P : never;
// Recursive type
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
8.3 Constraints and variance
// Constraint through extends
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
// Structural typing: a structural match is enough
interface Printable {
toString(): string;
}
function print<T extends Printable>(value: T): void {
console.log(value.toString());
}
// Variance is structural, inferred by the compiler automatically
// Discriminated unions in combination with generics
type Result<T, E> =
| { success: true; value: T }
| { success: false; error: E };
function map<T, U, E>(
result: Result<T, E>,
f: (value: T) => U
): Result<U, E> {
return result.success
? { success: true, value: f(result.value) }
: result;
}
8.4 Advantages and disadvantages
Advantages:
- Structural typing removes the need to explicitly declare the compatibility of types.
- Conditional types and mapped types make it possible to express complex transformations at the type level.
- Template literal types give the ability to work with string patterns as types.
- Inferring type parameters works in most contexts without explicit annotations.
- Gradual typing: generics are introduced into existing JavaScript code without rewriting it.
Disadvantages:
- Type erasure: no information about generic parameters at runtime.
- Structural typing allows accidental compatibility of unrelated types.
- Conditional types become unreadable with several levels of nesting.
- The
anytype completely disables checking for the whole call chain. - Variance is inferred by the compiler automatically and does not always match the developer’s expectations.
- All computations are limited by the capabilities of the JavaScript runtime.
9. Julia - Multiple Dispatch and parametric types
9.1 Implementation model
Julia uses the mechanism of multiple dispatch in combination with JIT compilation (LLVM) and parametric types. Functions are not generic templates in the traditional sense: the compiler automatically specializes each method for concrete argument types on the first call. The choice of method is determined by the types of all arguments simultaneously.
# Parametric composite type
struct Stack{T}
items::Vector{T}
Stack{T}() where T = new(T[])
end
function push!(s::Stack{T}, item::T) where T
push!(s.items, item)
return s
end
function pop!(s::Stack{T}) where T
isempty(s.items) && throw(ArgumentError("Stack is empty"))
return pop!(s.items)
end
# Multiple dispatch: the method is selected by the types of all arguments
combine(a::Int, b::Int) = a + b
combine(a::String, b::String) = a * b
combine(a::Vector, b::Vector) = vcat(a, b)
# Parametric function with a constraint
function dot_product(a::Vector{T}, b::Vector{T}) where T <: Number
length(a) == length(b) || throw(DimensionMismatch())
return sum(a .* b)
end
9.2 Parametric types and variance
# Parametric types in Julia are invariant:
# Vector{Float64} is not a subtype of Vector{Number}
# For covariant behavior the <: syntax is used
function sum_numbers(v::AbstractVector{<:Number})
return sum(v)
end
# Union of types
function process(x::Union{Int, Float64})
return x * 2
end
# Parametric abstract types
abstract type Shape{N} end
struct Circle{T<:Real} <: Shape{2}
center::NTuple{2, T}
radius::T
end
struct Sphere{T<:Real} <: Shape{3}
center::NTuple{3, T}
radius::T
end
volume(c::Circle{T}) where T = π * c.radius^2
volume(s::Sphere{T}) where T = (4/3) * π * s.radius^3
9.3 Generated functions and macros
# @generated - a function whose code is generated at compile time
# depending on the types of the arguments
@generated function sum_fields(x::T) where T
fields = fieldnames(T)
isempty(fields) && return :(zero(eltype(T)))
expr = :(x.$(fields[1]))
for field in fields[2:end]
expr = :($expr + x.$(field))
end
return expr
end
struct Point3D
x::Float64
y::Float64
z::Float64
end
# sum_fields(Point3D(1.0, 2.0, 3.0)) will return 6.0
# without iterating over the fields at runtime
# Macro for generating methods
macro define_ops(T)
quote
Base.:+(a::$T, b::$T) = $T(a.value + b.value)
Base.:-(a::$T, b::$T) = $T(a.value - b.value)
Base.:*(a::$T, b::Number) = $T(a.value * b)
end
end
9.4 Advantages and disadvantages
Advantages:
- Multiple dispatch is the most flexible mechanism for choosing an implementation: the method is determined by the types of all arguments simultaneously.
- JIT specialization automatically generates optimized code for each combination of types.
- The performance of numeric computations is comparable to C when concrete types are used.
- Parametric types and
@generatedfunctions make it possible to express complex dependencies between types. - Complete type information is available at runtime.
Disadvantages:
- A long delay on the first run (time-to-first-execution) due to JIT compilation.
- Parametric types are invariant, which requires the explicit use of
<:for covariant behavior. - The absence of formal interfaces: “protocols” are defined by conventions and documentation, but are not checked by the compiler.
- Method mismatch errors (MethodError) are detected only at runtime.
- The language is oriented toward numerical computations; its application in other domains is limited by the ecosystem.
10. Comparative analysis
10.1 Summary table of characteristics
| Criterion | C++ | Java | C# | Python | Rust | Haskell | TypeScript | Julia |
|---|---|---|---|---|---|---|---|---|
| Model | Templates / monomorphization | Type erasure | Reification | Annotations | Traits / monomorphization | Typeclasses / parametric polymorphism | Type erasure | Multiple dispatch / JIT |
| Type safety at runtime | No | Partial | Full | No | Partial | No | No | Full |
| Performance with value types | No loss | Boxing overhead | No loss | Always boxing | No loss | Depends on the implementation | JS runtime | No loss (JIT) |
| Variance | Through concepts | Use-site (wildcards) | Declaration-site (in/out) | Through TypeVar | Through traits | Parametric | Structural (automatic) | Invariant |
| Metaprogramming | TMP, if constexpr | Reflection | Reflection, source generators | Metaclasses, decorators | Procedural macros | Type families, Template Haskell | Conditional types | @generated, macros |
| Specialization for concrete types | Full | No | No | No | Partial (unstable) | No | No | Through multiple dispatch |
| Constraints on type parameters | Concepts | Bounds, wildcards | Constraints | Protocol, TypeVar bounds | Trait bounds | Typeclass constraints | extends | where T <: |
| Constraint checking | Compilation | Compilation | Compilation | Static analyzer | Compilation | Compilation | Compilation | Compilation / runtime |
10.2 Monomorphization versus type erasure
The two dominant approaches to implementing generics have opposite trade-offs:
Monomorphization (C++, Rust):
- For each combination of concrete types, separate machine code is generated.
- The compiler applies full optimization taking into account the concrete type: inlining, branch elimination, vectorization.
- There are no runtime overheads.
- The size of the binary file grows proportionally to the number of instantiations.
- Compilation slows down with a large number of template instantiations.
Type erasure (Java, TypeScript):
- A single bytecode instance serves all instantiations.
- The size of the binary file does not depend on the number of type parameters.
- In Java, working with primitive types requires boxing; value types cannot be arguments.
- Information about the real type parameter is unavailable at runtime.
- A number of operations on type parameters are forbidden by the compiler (creating an instance, instanceof).
Reification (C#):
- The CLR generates separate native code for each instantiation with a value type.
- All instantiations with reference types share one implementation.
- Information about the real type parameters is available through reflection.
- Trade-off: code grows only for value types, which is practically more advantageous than full monomorphization.
10.3 Nominal versus structural typing in the context of generics
Nominal typing (Java, C#, Rust, Haskell): A type is compatible with a constraint only if it is explicitly declared as implementing the corresponding interface, trait or typeclass. This excludes accidental compatibility and makes the author’s intentions explicit. Retrospective addition of compatibility (for a third-party type) is possible in Rust and Haskell, but limited in Java and C#.
Structural typing (TypeScript, Python): A type is compatible with a constraint if it has the required structure (a set of fields and methods). An explicit declaration is not required. This maximizes flexibility, but allows accidental compatibility of unrelated types with the same structure.
10.4 Expressiveness of constraint systems
The constraint systems of languages differ in what exactly can be required of a type parameter:
| What can be required | C++ | Java | C# | Rust | Haskell |
|---|---|---|---|---|---|
| Implementation of an interface / trait | Concept | Bound | Constraint | Trait bound | Typeclass |
| Presence of a concrete constructor | Through a concept | No | new() |
Through a trait | No |
| Belonging to a value / reference type | No | No | struct / class |
Through traits | No |
| Absence of pointers (unmanaged) | No | No | unmanaged |
Copy |
No |
| An arbitrary predicate over a type | Through a concept | No | No | No | Partially through type families |
| A constraint on an associated type | Through a concept | No | Through an interface | where T::Item: Trait |
Through type families |
10.5 Performance: a qualitative comparison
The performance of generic code is determined by two factors: the possibility of applying optimizations taking into account the concrete type and the overhead of working with values.
Languages with monomorphization (C++, Rust) and Julia (JIT) give the best performance for numeric algorithms, since the compiler sees the concrete type and can apply vectorization, branch elimination and inlining. C# occupies an intermediate position: value types are optimized, reference types are not. Java loses because of the inevitable boxing when working with primitives through generic containers. TypeScript and Python are limited by the capabilities of their runtimes (V8 and CPython respectively).
10.6 Comparison by tasks of implementing typical patterns
A collection with a type-safe iterator:
- C++:
template<typename T> class Containerwithiterator- full control, high complexity. - Java:
class Container<T> implements Iterable<T>- type erasure, boxing for primitives. - C#:
class Container<T> : IEnumerable<T>- reification, no boxing for value types. - Rust:
struct Container<T>with an implementation of theIteratortrait - monomorphization, borrow checker. - Haskell: through
FoldableandTraversable- the most generalized, through typeclasses.
A function that works with any numeric type:
- C++: the
Numericconcept, monomorphization - zero overhead. - Java:
<T extends Number>- works only with boxed types. - C#: static abstract members (C# 11) - the
+operator is generalized through an interface. - Rust: the
Add,Sub,Multraits fromstd::ops- monomorphization. - Haskell:
Num a => a -> a -> a- inferred automatically. - Python:
TypeVar('T', int, float)- static checking only.
11. Conclusion
Generic programming is implemented through fundamentally different mechanisms depending on the goals of the language:
C++ provides the most powerful mechanism: templates are Turing-complete at compile time, concepts formalize constraints, specializations make it possible to optimize for concrete types. The price is the complexity of the language, slow compilation and binary file bloat.
Java chose type erasure for the sake of backward compatibility. This decision, made in 2004, imposed fundamental limitations: the impossibility of working with primitives without boxing and the unavailability of the real type at runtime. The Valhalla project (value types) partially removes these limitations.
C# implemented reified generics from the moment CLR 2.0 appeared, which made it possible to avoid boxing for value types and to ensure the availability of type information at runtime. The addition of static abstract members in C# 11 brings the constraint system closer to typeclasses in expressiveness.
Python uses generics exclusively as a tool of static analysis. The interpreter ignores all type parameters; real flexibility is provided by duck typing. The Protocol mechanism provides structural typing without explicit inheritance.
Rust combines monomorphization (the performance of C++) with a system of traits (expressiveness close to the typeclasses of Haskell) and memory safety guarantees. Limitations: unstable specialization and complexity when combined with lifetime annotations.
Haskell provides the most theoretically rigorous implementation: parametric polymorphism with automatic type inference, typeclasses with retrospective instances and higher-kinded types. The application of Haskell patterns in other languages is limited by fundamental differences in type systems.
TypeScript builds a system of generics on top of JavaScript, using structural typing and type erasure. Conditional types and mapped types make the type system of TypeScript one of the most expressive among languages with type erasure. The fundamental limitation is the impossibility of going beyond what JavaScript can do at runtime.
Julia replaces traditional generics with the mechanism of multiple dispatch with JIT specialization. This provides performance comparable to statically compiled languages, with dynamic typing. The weak side is the absence of formal interfaces and the delay of the first run.
None of the considered approaches is dominant by all criteria at the same time. Each language represents a concrete point in the space of trade-offs between performance, type safety, the expressiveness of the constraint system and the complexity of the implementation.
Versions of the languages at the time of writing: C++23, Java 21, C# 12, Python 3.12, Rust 1.75, GHC 9.8, TypeScript 5.3, Julia 1.10