Skip to content
jieqi's archive
Go back

C++ Basics: Polymorphism - Static Dispatch & Overloading

The problem overloading solves: without it, one name can only mean one thing. In C you get abs(), fabs(), labs()… the concept is identical (“absolute value”) but you’re forced to encode the type into the name. The caller has to remember which variant matches their type, and if they pick wrong, C will silently convert and maybe lose precision.

                         POLYMORPHISM
                (same interface, many behaviors)
                              |
              +---------------+---------------+
              |                               |
        COMPILE TIME                      RUNTIME
     (type known statically)      (type known only at runtime)
              |                               |
      +-------+-------+              +--------+--------+
      |       |       |              |        |        |
  function  operator  templates   virtual  variant   manual fn
 overloading overloading (incl.   dispatch  + visit   pointers /
                        CRTP)    (open set) (closed   type erasure
                                             set)

Motivation

Say we want to get the absolute value for int, long and double. We could naively do this:

int    iabs(int x);
long   labs(long x);
double dabs(double x);

Works, but the caller does the dispatch by hand. Change a variable from int to long and every call site needs editing, and if you call iabs with a double, silent conversion occurs, which might not be what you wanted.

The solution — function overloading:

int    abs(int x);
long   abs(long x);
double abs(double x);

double px = -101.25;
int qty = -3;

abs(px);   // compiler picks abs(double), exact match
abs(qty);  // compiler picks abs(int)

How Function Overloading Is Implemented

1. Overload resolution (compile time)

When the compiler sees abs(px), it gathers all visible functions named abs (the overload set), then ranks each candidate by how well the argument types match:

  1. Exact match (or trivial stuff like adding const)
  2. Promotion (charint, floatdouble)
  3. Standard conversion (intdouble, derived*base*)
  4. User-defined conversion (your converting constructors, operator double())

The best match wins. If two candidates tie at the same rank, compilation fails with an ambiguity error rather than guessing.

Key point: resolution uses the static types of the arguments, at the call site, at compile time. Return type is ignored entirely; you can’t overload on return type alone.

The linker has no idea about overloading, it just matches symbol names. So the compiler encodes the parameter types into the symbol:

int    abs(int x);     // -> _Z3absi
long   abs(long x);    // -> _Z3absl
double abs(double x);  // -> _Z3absd

double px = -101.25;
int qty = -3;

abs(px);   // compiler emits: call _Z3absd
abs(qty);  // compiler emits: call _Z3absi

Each overload becomes a distinct symbol, so to the linker they’re just different functions — it never sees the shared source-level name at all.

So the mechanism is: mangling makes overloads distinct functions, resolution decides which one the call site binds to. Zero runtime machinery, which is why it lands on the static side of the tree.

Aside: extern “C” Functions Can’t Be Overloaded

extern "C" means “emit this with the plain, unmangled symbol name” — that’s the whole point, it’s what lets C code call the function. But overloading only works because mangling gives each overload a distinct symbol. Strip the mangling and there’s nothing left to tell them apart:

extern "C" int  scale(int x)  { return 2 * x; }   // symbol: scale
extern "C" long scale(long x) { return 2 * x; }   // symbol: scale  <- collision, error!
error: conflicting types for 'scale'
note: previous definition is here

Note the error is “conflicting types”, not “ambiguous overload”: both functions would become the same symbol scale, so the second is just an illegal redeclaration.

Operator Overloading: The Same Thing

An overloaded operator is just a function with a funny name — the operator syntax is sugar. The one requirement: at least one of the parameters must be a class type or enum type. You can’t redefine operators for built-in types alone — 2 + 3 compiles to an add instruction, and there’s no overload that can intercept it.

struct Meters { double v; };

Meters operator+(Meters a, Meters b) { return {a.v + b.v}; }

Meters d = Meters{1.5} + Meters{2.0};   // sugar for operator+(Meters{1.5}, Meters{2.0})

When the compiler sees a + b with a class- or enum-typed operand, it rewrites it as a call to a function named operator+, builds the overload set for that name, and runs the exact same overload resolution from earlier. The winner gets mangled and called directly:

_Zpl6MetersS_    <- operator+(Meters, Meters)

Operator symbols can’t appear in a linker name, so each operator gets a code instead — pl for plus — and the parameter types follow, same as before.

Same pipeline as function overloading: rewrite to a named function call, resolve at compile time, emit a direct call to a mangled symbol. Zero runtime machinery — which is why both live on the same branch of the tree.

Wrapping Up

One name, many functions: overload resolution picks the winner from the static types at the call site, name mangling bakes that choice into a distinct symbol, and by link time the shared name is gone entirely. The dispatch costs nothing at runtime because it never exists at runtime.

That covers two of the three boxes on the compile-time branch of the tree. The third — templates and CRTP — is its own post, and virtual dispatch on the runtime branch is covered here.

Appendix: What Is Name Mangling?

Step 1: We start with two functions sharing the same name:

int    twice(int x)    { return 2 * x; }
double twice(double x) { return 2.0 * x; }

Step 2: The compiler mangles each definition into a unique symbol.

Running nm on the object file:

_Z5twicei    <- twice(int)
_Z5twiced    <- twice(double)

Reading the encoding: _Z = “mangled C++ name”, 5twice = 5-char identifier “twice”, then one letter per parameter type: i = int, d = double. Parameter types in, return type out (which is why you can’t overload on return type: it wouldn’t produce a distinct symbol… well, mainly because resolution ignores it, but the mangling reflects that).

Step 3: At each call site, resolution picks a candidate, and the compiler emits a call to that specific mangled symbol.

Look at the disassembly of main with relocations:

mov  $0x3,%edi          ; load int 3 into arg register
call ...                ; R_X86_64_PLT32  _Z5twicei   <- names twice(int)

movsd 0x0(%rip),%xmm0   ; load 3.5 into float register
call ...                ; R_X86_64_PLT32  _Z5twiced   <- names twice(double)

Those R_X86_64_... lines are relocation entries: the object file literally says “patch this call to point at symbol _Z5twicei”. The overload decision is fully baked in here; there is no trace of “twice” as an ambiguous name anymore.

Step 4: The linker’s view. The linker never heard of overloading. It sees two ordinary jobs: “someone needs _Z5twicei, someone provides _Z5twicei, connect them.” Exact string match on symbols, same as it would do for C’s abs and labs. This is your separate-compilation model unchanged: mangling is the trick that squeezes C++‘s rich naming through the linker’s dumb string-matching interface.

Next Post
C++ Basics: The Five Special Member Functions