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에서 호출할 API | extern "C"는 오버로드 불가 | C 인터페이스는 이름을 나누고 내부에서 C++ 오버로드 호출 |
마무리
- 오버로드는 매개변수 목록으로만 구분되며 반환 타입과 최상위
const는 기준이 아니다. - 호출 시 정확 일치 → 승격 → 표준 변환 → 사용자 정의 변환 순으로 후보를 고르고, 같은 순위가 겹치면 에러다.
- 참조의
const, lvalue/rvalue, 멤버 함수 한정자로도 오버로드할 수 있다. - 이름 숨김과
bool변환은 컴파일러 경고 없이 동작을 바꾸므로 설계 단계에서 피한다.