C++ Function Overloading: Rules, Ambiguity, and Name

Key takeaways

Learn C++ function overloading: same name, different parameters. Resolution rules, common ambiguities, default arguments, and how it ties to name mangling.

What is function overloading?

Same name, different parameters—multiple functions share an identifier. The compiler tells them apart entirely by their signature — the parameter list, not the return type or the function name alone — and this resolution happens at compile time, before the program ever runs. Under the hood, each overload gets encoded into a distinct mangled symbol name in the compiled object file (covered in more depth in the name mangling guide linked below), which is how the linker keeps add(int, int) and add(double, double) from colliding despite sharing a source-level name.

int add(int a, int b) {
    return a + b;
}
double add(double a, double b) {
    return a + b;
}
int add(int a, int b, int c) {
    return a + b + c;
}
int main() {
    std::cout << add(1, 2) << std::endl;        // 3 (int)
    std::cout << add(1.5, 2.5) << std::endl;    // 4.0 (double)
    std::cout << add(1, 2, 3) << std::endl;     // 6 (three args)
}

Overloading rules

The rule that matters most here is the last one: return type alone is never part of the signature, so int func(int x) and double func(int x) are considered the exact same declaration as far as overload resolution is concerned, and the second one is a redeclaration error, not a new overload. This is a deliberate design choice — at a call site like func(x), the compiler has no way to know what return type the caller expects without additional context (unlike return-type-based overloading in some functional languages), so C++ requires the parameter list alone to disambiguate. Top-level const on a parameter passed by value also doesn’t count toward the signature for this same reason (a caller can’t observe whether the callee’s local copy is const), but const on a pointer or reference parameter does count, since it changes what the caller is allowed to pass and what the function can do with it.

// ✅ Different arity
void func(int x);
void func(int x, int y);
// ✅ Different parameter types
void func(int x);
void func(double x);
// ✅ const differences (pointer/reference)
void func(int* ptr);
void func(const int* ptr);
// ❌ Return type only (not allowed)
int func(int x);
// double func(int x);  // error

A family of print overloads

This pattern — a family of print functions for different types — is the classic motivating case for overloading: it lets calling code stay uniform (print(x) regardless of what x is) while the implementation adapts per type, instead of forcing callers to remember type-specific function names like printInt, printDouble, printString. Note that the vector<int> and string overloads take their arguments by const& rather than by value — passing large or dynamically-allocated types by value here would copy the entire container on every call, which is wasted work when the function only needs to read the contents.

#include <iostream>
#include <vector>
void print(int x) {
    std::cout << "int: " << x << std::endl;
}
void print(double x) {
    std::cout << "double: " << x << std::endl;
}
void print(const std::string& s) {
    std::cout << "string: " << s << std::endl;
}
void print(const std::vector<int>& vec) {
    std::cout << "vector: ";
    for (int x : vec) {
        std::cout << x << " ";
    }
    std::cout << std::endl;
}
int main() {
    print(42);
    print(3.14);
    print(std::string("Hello"));
    print(std::vector<int>{1, 2, 3});
}

max overloads mixed with a template

This example mixes ordinary overloads with a function template, which raises the question of how the compiler chooses between them when both could apply. The rule is: template argument deduction happens first to see if the template is even a candidate, and then, among all viable candidates (template and non-template alike), a non-template function that’s an equally good match is preferred over a template instantiation — the idea being that a hand-written, type-specific overload was presumably written more deliberately than a generic one. Here, though, they don’t actually compete: the two/three-argument overloads take scalar int/double arguments while the template takes a vector<T>, so the compiler picks based on argument shape rather than needing this tie-breaking rule.

int max(int a, int b) {
    return a > b ? a : b;
}
double max(double a, double b) {
    return a > b ? a : b;
}
int max(int a, int b, int c) {
    return max(max(a, b), c);
}
template<typename T>
T max(const std::vector<T>& vec) {
    if (vec.empty()) {
        throw std::invalid_argument("empty vector");
    }
    
    T maxVal = vec[0];
    for (const auto& val : vec) {
        if (val > maxVal) {
            maxVal = val;
        }
    }
    return maxVal;
}
int main() {
    std::cout << max(10, 20) << std::endl;
    std::cout << max(1.5, 2.5) << std::endl;
    std::cout << max(1, 2, 3) << std::endl;
    
    std::vector<int> nums = {5, 2, 8, 1, 9};
    std::cout << max(nums) << std::endl;
}

Overloaded constructors

Constructors are overloaded the same way ordinary functions are, letting String s2("Hello") and String s3('*', 10) both construct a String through whichever initialization path fits the arguments given. There’s a real bug worth calling out in the class below, though: it manages a raw char* manually and defines a deep-copying copy constructor, but never defines a copy assignment operator. Per the Rule of Three, if a class needs a custom destructor or copy constructor because it owns a resource, it almost always needs a custom copy assignment operator too — otherwise the compiler generates one implicitly that just copies the raw pointer member-by-member. Writing s1 = s2; here would make both objects’ data point at the same heap allocation, and when both destructors eventually run, the second one calls delete[] on already-freed memory — undefined behavior that often manifests as a crash or heap corruption far away from the actual assignment. A real implementation needs an operator= that deep-copies (or is deleted, or uses copy-and-swap) to close this gap.

class String {
private:
    char* data;
    size_t length;
    
public:
    String() : data(nullptr), length(0) {}
    
    String(const char* str) {
        length = strlen(str);
        data = new char[length + 1];
        strcpy(data, str);
    }
    
    String(char c, size_t count) {
        length = count;
        data = new char[length + 1];
        memset(data, c, length);
        data[length] = '\0';
    }
    
    String(const String& other) {
        length = other.length;
        data = new char[length + 1];
        strcpy(data, other.data);
    }
    
    ~String() {
        delete[] data;
    }
};
int main() {
    String s1;
    String s2("Hello");
    String s3('*', 10);
    String s4(s2);
}

find overloads and a predicate template

The third overload, a function template taking a Predicate, is more general than the first two but doesn’t replace them — the vector<int> and vector<string> overloads exist specifically to support the concise find(numbers, 3) call syntax without requiring callers to write a lambda for the common “find this exact value” case. Reserving the predicate-based template for genuinely custom search conditions (like x > 3) keeps the simple, common calls simple while still allowing arbitrary logic when needed — a pattern worth recognizing since it appears throughout the standard library too (e.g., std::find vs. std::find_if).

#include <vector>
#include <string>
int find(const std::vector<int>& vec, int target) {
    for (size_t i = 0; i < vec.size(); i++) {
        if (vec[i] == target) {
            return static_cast<int>(i);
        }
    }
    return -1;
}
int find(const std::vector<std::string>& vec, const std::string& target) {
    for (size_t i = 0; i < vec.size(); i++) {
        if (vec[i] == target) {
            return static_cast<int>(i);
        }
    }
    return -1;
}
template<typename T, typename Predicate>
int find(const std::vector<T>& vec, Predicate pred) {
    for (size_t i = 0; i < vec.size(); i++) {
        if (pred(vec[i])) {
            return static_cast<int>(i);
        }
    }
    return -1;
}
int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5};
    std::cout << find(numbers, 3) << std::endl;
    
    std::vector<std::string> words = {"apple", "banana", "cherry"};
    std::cout << find(words, std::string("banana")) << std::endl;
    
    std::cout << find(numbers, [](int x) { return x > 3; }) << std::endl;
}

const overloading

const-qualifying a member function (the trailing const after the parameter list) is one of the few places where a qualifier on the function itself, rather than a parameter type, participates in overload resolution — it’s really overloading on the constness of the implicit this pointer. When you call operator[] on a non-const Array, the non-const overload is selected and returns a mutable reference you can assign through; called on a const Array&, only the const overload is viable, and it returns a const int& that prevents mutation. This pairing is the standard idiom anywhere a class provides indexed or dereferencing access and needs to support both mutable and read-only callers safely.

class Array {
private:
    int data[10];
    
public:
    int& operator[](size_t index) {
        return data[index];
    }
    
    const int& operator[](size_t index) const {
        return data[index];
    }
};
int main() {
    Array arr;
    arr[0] = 10;
    
    const Array& constArr = arr;
    int x = constArr[0];
}

Ambiguity, default arguments, and other overload traps

Calls that look ambiguous (and ones that really are)

The comment in the original code is actually slightly misleading — func(10.0f) is not ambiguous in standard C++, even though it’s tempting to think so. Overload resolution ranks implicit conversions, and “promotion” ranks better than “conversion”: float → double is specifically classified as a floating-point promotion, while float → int is a floating-integral conversion, a strictly worse rank. So func(10.0f) unambiguously resolves to func(double) — no error, no warning. The genuinely ambiguous cases are ones where two overloads require conversions of the same rank with no tiebreaker, such as a class with implicit conversion operators to two unrelated types, or char matching both func(int) and func(long) equally well via integral promotion vs. integral conversion in some corner cases. When you do hit real ambiguity, the fix is the same either way: make the intended conversion explicit at the call site with a cast, as shown below.

void func(int x) {
    std::cout << "int" << std::endl;
}
void func(double x) {
    std::cout << "double" << std::endl;
}
int main() {
    func(10);
    func(3.14);
    func(10.0f);  // NOT ambiguous: resolves to func(double) via promotion
    
    func(static_cast<int>(10.0f));
    func(static_cast<double>(10.0f));
}

Default arguments colliding with an overload

Default arguments effectively create an implicit second signature — func(int, int = 0) can be called with either one or two arguments — and when another overload already covers the one-argument form, the two collide the moment a caller writes func(10) and the compiler has two equally valid ways to satisfy it. The error only appears at the ambiguous call site, not at the declaration, which makes this pitfall easy to miss until someone actually calls the function that way; it’s worth being conservative about mixing default arguments into an already-overloaded name, or checking as this example does by giving each shape its own name.

// ❌ Ambiguous
void func(int x) {
    std::cout << "one arg" << std::endl;
}
void func(int x, int y = 0) {
    std::cout << "two args" << std::endl;
}
int main() {
    // func(10);  // error: ambiguous
    func(10, 20);
}
// ✅ Different names
void func1(int x);
void func2(int x, int y = 0);

int arr[] and int* are the same parameter

This isn’t really an overload at all — it just looks like one. In a function parameter declaration specifically (not in other contexts, like a local variable), int arr[] is treated by the language as exactly equivalent to int* arr, a leftover of C’s array-to-pointer decay rules that C++ inherited. Since both declarations describe the identical parameter type, this is a straightforward redefinition error, not an ambiguity — the compiler doesn’t even get as far as trying to pick between them, because it sees only one function declared twice.

void func(int* ptr) {
    std::cout << "pointer" << std::endl;
}
void func(int arr[]) {
    std::cout << "array" << std::endl;
}
// Error: same signature (array decays to pointer)

Overloading on return type alone

int getValue() {
    return 42;
}
// double getValue() { return 3.14; }  // error
int getIntValue() { return 42; }
double getDoubleValue() { return 3.14; }

Overload resolution sketch

For each call, the compiler builds the set of viable overloads (ones the argument could convert to at all), ranks the required conversion for each, and picks the strictly best one — if two or more candidates tie for best, the call is ambiguous and rejected. Walking through the calls below: func(10) matches func(int) exactly, no conversion needed. func(3.14) matches func(double) exactly. func("hello") — a const char* — converts to std::string via string’s non-explicit constructor from a C-string, a user-defined conversion, which is the only viable option since there’s no overload taking a raw pointer or C-string directly. char c = 'A'; func(c); promotes char to int via integral promotion, a better rank than the user-defined conversion to string or any conversion to double, so it picks func(int). short s = 10; func(s); similarly promotes to int via integral promotion and calls func(int) — in both cases, the compiler prefers the built-in promotion path over any of the other overloads’ more expensive conversions.

Here is the func implementation:

void func(int x) { std::cout << "int" << std::endl; }
void func(double x) { std::cout << "double" << std::endl; }
void func(const std::string& s) { std::cout << "string" << std::endl; }
int main() {
    func(10);
    func(3.14);
    func("hello");
    
    char c = 'A';
    func(c);
    
    short s = 10;
    func(s);
}

FAQ

Q1: When to use overloading?

A:

  • Same operation, different types
  • Different arity
  • Convenience overloads

Q2: Overloading vs templates?

A:

  • Overloading: type-specific implementations
  • Templates: same logic, generic

Q3: Performance impact?

A: None at runtime; resolved at compile time.

Q4: Overload by return type?

A: Not allowed; distinguish by parameters.

Q5: Ambiguous calls?

A: Use explicit casts, or give the calls unambiguous argument types in the first place.