C++ 고급 문법 (1) — 함수 오버로딩(Function Overloading)

C에서는 정수를 출력하는 함수와 실수를 출력하는 함수를 print_int, print_double처럼 이름을 나눠 만들어야 한다. C++은 이름이 같고 매개변수 목록만 다른 함수를 여러 개 둘 수 있다. 이것이 함수 오버로딩(function overloading)이다. 편리하지만 호출할 때마다 컴파일러가 후보 중 하나를 고르는 오버로드 해석(overload resolution)이 일어나고, 이 규칙을 모르면 의도하지 않은 함수가 호출되거나 모호성(ambiguity) 에러를 만난다. 이 글에서는 g++ 13.3으로 실제 컴파일한 예제를 따라가며 오버로드가 성립하는 조건, 후보 선택 순위, 자주 빠지는 함정을 정리한다.

$ g++ --version | head -1
g++ (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0

이 시리즈의 다른 글

오버로딩 기본

매개변수의 개수나 타입이 다르면 같은 이름을 쓸 수 있다.

#include <iostream>
#include <string>

void print(int v)                { std::cout << "int: " << v << '\n'; }
void print(double v)             { std::cout << "double: " << v << '\n'; }
void print(const char* v)        { std::cout << "const char*: " << v << '\n'; }
void print(const std::string& v) { std::cout << "string: " << v << '\n'; }
void print(int a, int b)         { std::cout << "int, int: " << a << ", " << b << '\n'; }

int main()
{
    using namespace std::string_literals;
    print(42);
    print(3.14);
    print("hello");
    print("hello"s);
    print(1, 2);
}
$ g++ -std=c++20 -Wall -o basic basic.cpp
$ ./basic
int: 42
double: 3.14
const char*: hello
string: hello
int, int: 1, 2

"hello"는 const char*로 정확히 일치하므로 std::string 버전이 아니라 포인터 버전이 호출된다.

오버로드가 성립하지 않는 경우

함수를 구분하는 기준은 시그니처(이름 + 매개변수 타입 목록)이고 반환 타입은 포함되지 않는다.

int    parse(const char* s);
double parse(const char* s);
$ g++ -std=c++20 -Wall -c ret.cpp
ret.cpp:2:8: error: ambiguating new declaration of ‘double parse(const char*)’
    2 | double parse(const char* s);
      |        ^~~~~
ret.cpp:1:8: note: old declaration ‘int parse(const char*)’
    1 | int    parse(const char* s);
      |        ^~~~~

값으로 받는 매개변수의 최상위 const(top-level const)도 시그니처에서 제거되므로 같은 함수의 재정의가 된다.

void f(int x)       {}
void f(const int x) {}
$ g++ -std=c++20 -Wall -c topconst.cpp
topconst.cpp:2:6: error: redefinition of ‘void f(int)’
    2 | void f(const int x) {}
      |      ^
topconst.cpp:1:6: note: ‘void f(int)’ previously defined here
    1 | void f(int x)       {}
      |      ^
차이오버로드 가능 여부
매개변수 개수가능
매개변수 타입 (int / double)가능
참조·포인터 대상의 const (int& / const int&)가능
lvalue/rvalue 참조 (T& / T&&)가능
멤버 함수의 const·ref 한정자가능
반환 타입만 다름불가
값 매개변수의 최상위 const만 다름불가
기본 인수(default argument) 유무만 다름불가 (재정의)

오버로드 해석 순위

컴파일러는 인수마다 필요한 변환 비용을 매겨 가장 싼 후보를 고른다.

순위변환 종류예
1정확 일치(exact match)int → int, 배열 → 포인터, T → const T&
2승격(promotion)char/short → int, float → double
3표준 변환(standard conversion)int → long, int → double, 포인터 → bool
4사용자 정의 변환(user-defined conversion)변환 생성자, 변환 연산자 (const char* → std::string)
5생략 부호(ellipsis)f(...)
#include <iostream>

void f(int)    { std::cout << "f(int)\n"; }
void f(double) { std::cout << "f(double)\n"; }
void f(long long) { std::cout << "f(long long)\n"; }

int main()
{
    char  c = 'a';
    short s = 1;
    float x = 1.0f;
    f(c);   // 승격(promotion) char -> int
    f(s);   // 승격 short -> int
    f(x);   // 승격 float -> double
}
$ g++ -std=c++20 -Wall -o rank rank.cpp
$ ./rank
f(int)
f(int)
f(double)

같은 순위의 후보가 둘 이상이면 컴파일 에러다. int → long과 int → double은 둘 다 3순위 표준 변환이다.

void f(long)   {}
void f(double) {}

int main()
{
    f(1);    // int -> long, int -> double 둘 다 '변환(conversion)'
}
$ g++ -std=c++20 -Wall -c ambig.cpp
ambig.cpp: In function ‘int main()’:
ambig.cpp:6:6: error: call of overloaded ‘f(int)’ is ambiguous
    6 |     f(1);    // int -> long, int -> double 둘 다 '변환(conversion)'
      |     ~^~~
ambig.cpp:1:6: note: candidate: ‘void f(long int)’
    1 | void f(long)   {}
      |      ^
ambig.cpp:2:6: note: candidate: ‘void f(double)’
    2 | void f(double) {}
      |      ^

인수 타입을 후보와 정확히 맞추면 모호성이 사라진다.

#include <iostream>
void f(long)   { std::cout << "f(long)\n"; }
void f(double) { std::cout << "f(double)\n"; }

int main()
{
    f(1L);
    f(1.0);
    f(static_cast<long>(1));
}
$ g++ -std=c++20 -Wall -o ambig2 ambig2.cpp
$ ./ambig2
f(long)
f(double)
f(long)

bool과 std::string 오버로드 함정

순위 표의 3순위와 4순위 차이 때문에 문자열 리터럴이 bool로 가는 유명한 함정이 생긴다.

#include <iostream>
#include <string>

void set(bool v)               { std::cout << "set(bool): " << v << '\n'; }
void set(const std::string& v) { std::cout << "set(string): " << v << '\n'; }

int main()
{
    set("verbose");          // const char* -> bool : 표준 변환
                             // const char* -> std::string : 사용자 정의 변환
    set(std::string("verbose"));
}
$ g++ -std=c++20 -Wall -Wextra -o booltrap booltrap.cpp
$ ./booltrap
set(bool): 1
set(string): verbose

-Wall -Wextra로도 경고가 없다. const char* 오버로드를 하나 더 두면 정확 일치가 이긴다.

#include <iostream>
#include <string>

void set(bool v)               { std::cout << "set(bool): " << v << '\n'; }
void set(const std::string& v) { std::cout << "set(string): " << v << '\n'; }
void set(const char* v)        { set(std::string(v)); }   // 리터럴 전용 오버로드

int main()
{
    set("verbose");
    set(true);
}
$ g++ -std=c++20 -Wall -Wextra -o booltrap2 booltrap2.cpp
$ ./booltrap2
set(string): verbose
set(bool): 1

0, NULL, nullptr

#include <cstddef>
#include <iostream>
void f(int)   { std::cout << "f(int)\n"; }
void f(char*) { std::cout << "f(char*)\n"; }

int main()
{
    f(0);
    f(nullptr);
    f(NULL);
}
$ g++ -std=c++20 -Wall -c null.cpp
null.cpp: In function ‘int main()’:
null.cpp:10:6: error: call of overloaded ‘f(NULL)’ is ambiguous
   10 |     f(NULL);
      |     ~^~~~~~
null.cpp:3:6: note: candidate: ‘void f(int)’
    3 | void f(int)   { std::cout << "f(int)\n"; }
      |      ^
null.cpp:4:6: note: candidate: ‘void f(char*)’
    4 | void f(char*) { std::cout << "f(char*)\n"; }
      |      ^

g++의 NULL은 정수 계열 상수(__null)라 양쪽 다 후보가 된다. NULL 줄을 빼면 0은 int로, nullptr는 포인터로 간다.

$ g++ -std=c++20 -Wall -o null_ok null_ok.cpp
$ ./null_ok
f(int)
f(char*)

참조와 const 오버로딩

참조 매개변수는 인수의 값 범주(value category)와 const 여부로 후보가 갈린다.

#include <iostream>
#include <string>

void take(std::string&)        { std::cout << "lvalue ref\n"; }
void take(const std::string&)  { std::cout << "const lvalue ref\n"; }
void take(std::string&&)       { std::cout << "rvalue ref\n"; }

int main()
{
    std::string s = "a";
    const std::string cs = "b";
    take(s);
    take(cs);
    take(std::string("c"));
    take(std::move(s));
    take("literal");
}
$ g++ -std=c++20 -Wall -o ref ref.cpp
$ ./ref
lvalue ref
const lvalue ref
rvalue ref
rvalue ref
rvalue ref

마지막 take("literal")은 std::string 임시 객체가 만들어진 뒤 그 임시 객체가 rvalue 참조에 묶인 결과다.

멤버 함수의 const·ref 한정자

멤버 함수는 this가 가리키는 객체의 상태로도 오버로드할 수 있다. const 한정자는 C++98부터, &/&& ref 한정자(ref-qualifier)는 C++11부터 쓸 수 있다.

#include <iostream>
#include <string>
#include <utility>

class Buffer {
    std::string data_ = "payload";
public:
    char&       at(std::size_t i)       { std::cout << "[non-const] "; return data_[i]; }
    const char& at(std::size_t i) const { std::cout << "[const] ";     return data_[i]; }

    const std::string& str() const &  { std::cout << "[&]  copy-free view\n"; return data_; }
    std::string        str() &&       { std::cout << "[&&] move out\n";       return std::move(data_); }
};

int main()
{
    Buffer b;
    const Buffer& cb = b;
    b.at(0) = 'P';
    std::cout << cb.at(0) << '\n';

    std::string s1 = b.str();
    std::string s2 = Buffer{}.str();
    std::cout << s1 << ' ' << s2 << '\n';
}
$ g++ -std=c++20 -Wall -o member member.cpp
$ ./member
[non-const] [const] P
[&]  copy-free view
[&&] move out
Payload payload

임시 객체에서 str()을 부르면 && 버전이 선택되어 내부 문자열을 복사하지 않고 이동(move)으로 꺼낸다.

기본 인수와의 충돌

void log(const char* msg)                {}
void log(const char* msg, int level = 0) {}

int main()
{
    log("boot", 1);  // OK
    log("boot");     // ?
}
$ g++ -std=c++20 -Wall -c defarg.cpp
defarg.cpp: In function ‘int main()’:
defarg.cpp:7:8: error: call of overloaded ‘log(const char [5])’ is ambiguous
    7 |     log("boot");     // ?
      |     ~~~^~~~~~~~
defarg.cpp:1:6: note: candidate: ‘void log(const char*)’
    1 | void log(const char* msg)                {}
      |      ^~~
defarg.cpp:2:6: note: candidate: ‘void log(const char*, int)’
    2 | void log(const char* msg, int level = 0) {}
      |      ^~~

선언 자체는 통과하고 호출 지점에서 에러가 나므로, 라이브러리 헤더에서는 사용자가 호출하기 전까지 문제가 드러나지 않는다.

상속과 이름 숨김

파생 클래스에 같은 이름의 함수를 하나라도 선언하면 기반 클래스의 오버로드 전체가 가려진다(name hiding).

#include <iostream>

struct Base {
    void draw(int)    { std::cout << "Base::draw(int)\n"; }
    void draw(double) { std::cout << "Base::draw(double)\n"; }
};

struct Derived : Base {
    void draw(const char*) { std::cout << "Derived::draw(const char*)\n"; }
};

int main()
{
    Derived d;
    d.draw("x");
    d.draw(1);
}
$ g++ -std=c++20 -Wall -c hide.cpp
hide.cpp: In function ‘int main()’:
hide.cpp:16:12: error: invalid conversion from ‘int’ to ‘const char*’ [-fpermissive]
   16 |     d.draw(1);
      |            ^
      |            |
      |            int
hide.cpp:9:15: note:   initializing argument 1 of ‘void Derived::draw(const char*)’
    9 |     void draw(const char*) { std::cout << "Derived::draw(const char*)\n"; }
      |               ^~~~~~~~~~~

using Base::draw;로 기반 클래스의 오버로드 집합을 파생 클래스 범위로 끌어오면 해결된다.

#include <iostream>

struct Base {
    void draw(int)    { std::cout << "Base::draw(int)\n"; }
    void draw(double) { std::cout << "Base::draw(double)\n"; }
};

struct Derived : Base {
    using Base::draw;   // 기반 클래스 오버로드 집합을 끌어온다
    void draw(const char*) { std::cout << "Derived::draw(const char*)\n"; }
};

int main()
{
    Derived d;
    d.draw("x");
    d.draw(1);
    d.draw(2.5);
}
$ g++ -std=c++20 -Wall -o hide2 hide2.cpp
$ ./hide2
Derived::draw(const char*)
Base::draw(int)
Base::draw(double)

템플릿과 섞일 때

함수 템플릿도 오버로드 후보에 들어간다. 변환 비용이 같으면 비템플릿이 우선이고, 템플릿이 정확 일치를 만들 수 있으면 템플릿이 이긴다.

#include <iostream>

template <typename T>
void show(T)       { std::cout << "template show(T)\n"; }
void show(int)     { std::cout << "show(int)\n"; }

int main()
{
    show(1);      // 비템플릿과 템플릿이 동률 -> 비템플릿 우선
    show(1.5);    // 템플릿은 T=double로 정확 일치, 비템플릿은 변환 필요
    show('a');    // 템플릿 T=char 정확 일치 vs int 승격
    show<>(1);    // 템플릿을 명시적으로 지정
}
$ g++ -std=c++20 -Wall -o tmpl tmpl.cpp
$ ./tmpl
show(int)
template show(T)
template show(T)
template show(T)

show('a')는 int로 승격하는 것보다 T=char 정확 일치가 싸서 템플릿으로 간다. 템플릿 인수 추론은 다음 편에서 자세히 다룬다.

이름 맹글링

링커는 이름만으로 심볼을 구분하므로 컴파일러는 매개변수 타입을 심볼 이름에 인코딩한다(name mangling).

#include <string>
void print(int) {}
void print(double) {}
void print(const std::string&) {}
void print(int, int) {}
extern "C" void c_api(int) {}
$ g++ -std=c++20 -Wall -c mangle.cpp
$ nm mangle.o | grep ' T '
000000000000001e T _Z5printRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE
000000000000000e T _Z5printd
0000000000000000 T _Z5printi
000000000000002d T _Z5printii
000000000000003e T c_api
$ nm -C mangle.o | grep ' T '
000000000000001e T print(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)
000000000000000e T print(double)
0000000000000000 T print(int)
000000000000002d T print(int, int)
000000000000003e T c_api

extern "C" 함수는 맹글링 없이 c_api 그대로 남는다. 그래서 C 링크 함수끼리는 오버로드할 수 없다.

extern "C" void c_api(int)    {}
extern "C" void c_api(double) {}
$ g++ -std=c++20 -Wall -c externc.cpp
externc.cpp:2:17: error: conflicting declaration of C function ‘void c_api(double)’
    2 | extern "C" void c_api(double) {}
      |                 ^~~~~
externc.cpp:1:17: note: previous declaration ‘void c_api(int)’
    1 | extern "C" void c_api(int)    {}
      |                 ^~~~~

주의사항

상황증상대응
같은 순위 변환이 둘 이상call of overloaded ... is ambiguous인수를 static_cast로 맞추거나 정확 일치 오버로드 추가
bool과 클래스 타입 오버로드 공존리터럴·포인터가 조용히 bool로 감포인터 전용 오버로드 추가, 또는 bool 대신 enum 사용
NULL/0을 포인터 인수로 전달정수 오버로드 호출 또는 모호성항상 nullptr 사용
기본 인수 + 오버로드호출 지점에서만 모호성 에러둘 중 하나만 사용
파생 클래스에서 같은 이름 선언기반 클래스 오버로드가 사라짐using Base::name;
오버로드마다 의미가 다름호출 코드만 보고 동작을 예측하기 어려움의미가 다르면 이름을 나눈다
C에서 호출할 APIextern "C"는 오버로드 불가C 인터페이스는 이름을 나누고 내부에서 C++ 오버로드 호출

마무리

  • 오버로드는 매개변수 목록으로만 구분되며 반환 타입과 최상위 const는 기준이 아니다.
  • 호출 시 정확 일치 → 승격 → 표준 변환 → 사용자 정의 변환 순으로 후보를 고르고, 같은 순위가 겹치면 에러다.
  • 참조의 const, lvalue/rvalue, 멤버 함수 한정자로도 오버로드할 수 있다.
  • 이름 숨김과 bool 변환은 컴파일러 경고 없이 동작을 바꾸므로 설계 단계에서 피한다.

참고

답글 남기기