C++ 함수, 오버로딩, std::vector, 문자열
C++ 강좌 2. Apple clang 21 로 실행: 오버로딩 모호성 에러, 기본 인자, vector 값 전달 복사 비용 실측, push_back 재할당 횟수, ASan 으로 잡는 반복자 무효화.
시리즈: C++ 강좌 2/6 (초급). C++ 프로그램을 빌드할 수 있고 참조와 const 를 아는 분(1 강)을 위한 글입니다. 다 읽으면 모호성 에러 없이 함수를 만들고 오버로딩하며, 인자 전달 방식을 실측 근거로 고르고, std::vector 와 std::string 을 안전하게 쓰고, vector 안을 가리키던 참조가 죽는 버그를 알아볼 수 있습니다.
C++ 실무 첫 몇 달 동안 쓰는 코드는 대부분 문자열과 vector 를 받아 결과를 돌려주는 함수입니다. 그런 코드의 리뷰에서 되풀이되는 버그는 세 가지입니다. 누군가 오버로딩을 하나 추가하자 갑자기 모호해지는 호출, 아무도 모르게 매번 복사되는 큰 vector, 그리고 vector 가 저장 공간을 옮긴 뒤에 쓰는 반복자나 참조. 마지막 것은 매번 죽지는 않아서 살아남습니다. 모든 결과는 Apple Silicon Mac(M1 Pro), macOS 26, Apple clang 21.0.0 + libc++ 에서 clang++ -std=c++23 -Wall -Wextra -pedantic 로 직접 실행한 것입니다.
백준에서 DFS 재귀 함수에 vector<int> 를 값으로 넘겼다가 "시간 초과"를 받아 본 적이 있다면, 이 글 가운데의 표가 그 이유를 숫자로 보여 줍니다.
함수: 쓰기 전에 선언하고, 반드시 return 하기
C++ 컴파일러는 위에서 아래로 읽습니다. 선언보다 먼저 함수를 부르면 에러입니다.
decl.cpp:2:27: error: use of undeclared identifier 'square'
2 | int main() { std::cout << square(4) << '\n'; }
정의를 위로 옮기거나, 선언(함수 첫 줄에 ; 를 붙인 것. 보통 헤더 파일에 둡니다) int square(int x); 를 먼저 둡니다.
어떤 경로에서 return 을 빠뜨려도 경고뿐이고, 결과는 정의되지 않습니다.
int grade(int score) {
if (score >= 90) return 'A';
if (score >= 80) return 'B';
} // score 50: falls off the end
int main() { return grade(50); }
noret.cpp:4:1: warning: non-void function does not return a value in all control paths [-Wreturn-type]
그래도 실행해 봤습니다. -O0 에서는 종료 코드 1, -O2 에서는 66 이었습니다. 66 은 'B' 입니다. 최적화기가 "끝까지 내려올 일은 없다"고 가정하고 두 번째 검사를 지워 버린 겁니다. -fsanitize=undefined 로는 runtime error: execution reached the end of a value-returning function without returning a value. -Wreturn-type 은 에러로 취급하세요(-Werror=return-type).
오버로딩과 모호성 에러
오버로딩은 이름이 같고 매개변수 타입이 다른 함수를 여러 개 두는 것입니다. 컴파일러가 가장 잘 맞는 것을 고릅니다.
#include <iostream>
#include <string>
void show(int x) { std::cout << "int: " << x << '\n'; }
void show(double x) { std::cout << "double: " << x << '\n'; }
void show(const std::string& s) { std::cout << "string: " << s << '\n'; }
int main() {
show(42);
show(3.14);
show('A'); // char -> int (promotion)
show(2.5f); // float -> double (promotion)
show("hi"); // const char* -> std::string (user-defined conversion)
}
int: 42
double: 3.14
int: 65
double: 2.5
string: hi
'A' 가 65 로 나온 건 char 가 int 로 승격됐기 때문입니다. 이번에는 long 이나 unsigned 를 넘겨 봅니다.
void show(int) {}
void show(double) {}
int main() {
long n = 5;
show(n);
show(5u);
}
amb.cpp:5:5: error: call to 'show' is ambiguous
5 | show(n);
| ^~~~
amb.cpp:1:6: note: candidate function
1 | void show(int) {}
amb.cpp:2:6: note: candidate function
2 | void show(double) {}
amb.cpp:6:5: error: call to 'show' is ambiguous
6 | show(5u);
순위를 간단히 말하면 정확히 일치 → 승격(char→int, float→double) → 표준 변환(long→int, long→double, int→double) → 사용자 정의 변환입니다. long 에서 int 로도, double 로도 표준 변환이라 동점이고, C++ 는 추측을 거부합니다.
nullptr 가 있는 이유도 같은 규칙으로 설명됩니다. find(int) 와 find(const char*) 를 두면:
find(0) -> find(int) 0
find(nullptr) -> find(const char*) (null)
find(NULL) -> error: call to 'find' is ambiguous
이 플랫폼의 NULL 은 정수 같은 상수라 양쪽에 다 맞습니다. "포인터 없음"은 항상 nullptr 로 씁니다.
기본 인자
기본 인자(default argument) 는 호출하는 쪽이 생략한 값을 채워 줍니다.
std::string pad(const std::string& s, int width = 8, char fill = ' '); // declaration
std::string pad(const std::string& s, int width, char fill) { // definition
std::string out = s;
while ((int)out.size() < width) out.insert(out.begin(), fill);
return out;
}
// pad("42") -> "[ 42]" pad("42", 5) -> "[ 42]" pad("42", 5, '0') -> "[00042]"
함정이 두 개 있고, 둘 다 진짜 에러가 됩니다.
defarg_err.cpp:3:43: error: redefinition of default argument
defarg_err.cpp:2:43: note: previous definition is here
defarg_err.cpp:6:14: error: call to 'log' is ambiguous
첫 번째는 정의 쪽에도 = 8 을 썼기 때문입니다. 기본값은 선언에 한 번만 씁니다. 두 번째는 void log(const char*, int level = 0); 옆에 void log(const char*); 를 뒀기 때문입니다. 인자 하나짜리 호출이 둘 다에 맞습니다. 오버로딩과 기본 인자 중 하나만 고르세요.
값, 참조, const 참조 전달을 재 보기
값 전달은 인자를 복사합니다. int 라면 공짜나 다름없지만 std::vector 라면 메모리 할당과 원소 전체 복사가 일어납니다. 매개변수 타입만 다른 두 함수로 vector 를 합해 봤습니다.
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <random>
#include <vector>
[[gnu::noinline]] long long sum_by_value(std::vector<int> v) {
long long s = 0; for (int x : v) s += x; return s;
}
[[gnu::noinline]] long long sum_by_cref(const std::vector<int>& v) {
long long s = 0; for (int x : v) s += x; return s;
}
template <class F> double ms_per_call(F f, int calls, long long& sink) {
auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < calls; ++i) sink += f();
auto t1 = std::chrono::steady_clock::now();
return std::chrono::duration<double, std::milli>(t1 - t0).count() / calls;
}
int main(int argc, char** argv) {
const int N = argc > 1 ? std::atoi(argv[1]) : 1'000'000;
std::mt19937 rng(42); // fixed seed
std::vector<int> v(N);
for (int& x : v) x = static_cast<int>(rng() % 100);
long long sink = 0;
double a = ms_per_call([&] { return sum_by_value(v); }, 100, sink);
double b = ms_per_call([&] { return sum_by_cref(v); }, 100, sink);
std::printf("N=%d by value %.3f ms by const& %.3f ms (sink %lld)\n", N, a, b, sink);
}
[[gnu::noinline]] 은 최적화기가 함수를 호출부에 녹여 넣어 복사를 숨기지 못하게 합니다. 100 번 호출 평균을 7 번 잰 중앙값, -O2 입니다.
| 원소 수 | 값 전달 | const& |
|---|---|---|
| 1,000,000 (4 MB) | 0.116 ms | 0.047 ms |
| 10,000,000 (40 MB) | 1.642 ms | 0.698 ms |
원소를 전부 읽는 함수인데도 복사 때문에 호출 비용이 두 배를 넘었습니다. v[0] 과 v.size() 만 읽는 함수라면 값 전달은 호출당 124.5 µs, const& 는 사실상 0 이었습니다(원소 100 만 개, 1,000 번 호출). 원소 1,000 개 미만에서는 차이가 타이머 해상도보다 작았습니다. 재귀 DFS 에서 노드마다 값으로 넘기면 이 비용이 호출 횟수만큼 쌓이니, 시간 초과가 나는 게 당연합니다.
| 매개변수 타입 | 쓰는 경우 |
|---|---|
T (값 전달) |
작은 타입(int, double, 포인터), 어차피 함수 안에서 사본이 필요할 때 |
const T& |
읽기만 하는 큰 타입: std::string, std::vector, 직접 만든 클래스 |
T& |
호출한 쪽 객체를 바꿔야 할 때 |
std::string_view |
std::string 이든 리터럴이든 받는 읽기 전용 문자열(C++17) |
std::vector: 성장, capacity, reserve
std::vector<T> 는 늘어나는 배열이고, 원소는 힙 블록 하나에 빈틈없이 놓입니다. size() 는 들어 있는 원소 수, capacity() 는 더 큰 블록을 다시 잡지 않고 들어갈 수 있는 수입니다. push_back 을 100 만 번 하면서 capacity 가 바뀔 때마다 찍었습니다(가운데 줄은 프로그램에서 생략).
size 0: capacity 0
size 1: capacity 0 -> 1
size 2: capacity 1 -> 2
size 3: capacity 2 -> 4
size 5: capacity 4 -> 8
size 9: capacity 8 -> 16
size 17: capacity 16 -> 32
size 33: capacity 32 -> 64
size 65: capacity 64 -> 128
size 262145: capacity 262144 -> 524288
size 524289: capacity 524288 -> 1048576
reallocations for 1,000,000 push_back: 21
with reserve(1000000): 0 reallocations
libc++ 는 capacity 를 두 배씩 늘리므로 원소 100 만 개에 재할당 21 번, 그 과정에서 모두 약 1,048,575 개 원소를 복사합니다. 원소 하나당 여분의 복사가 대략 한 번인 셈입니다. reserve(n) 은 처음에 한 번만 할당합니다.
reserve 로 얼마나 빨라질까요. 이건 재는 방법에 따라 달라지고, 첫 측정은 틀렸습니다. 한 프로세스에서 두 방식을 이어서 돌리자 reserve 가 세 배 빨라 보였습니다(1,000 만 개에 14.55 ms 대 4.61 ms). 하지만 두 번째 실행은 첫 번째가 이미 건드린 메모리를 다시 쓰고 있었습니다. 프로세스를 따로 띄워 각 7 회, 중앙값을 다시 냈습니다.
push_back 1,000 만 번 |
중앙값 |
|---|---|
reserve 없음 |
31.85 ms |
먼저 reserve(N) |
24.83 ms |
새 프로세스에서는 시간 대부분이 운영체제가 40 MB 의 새 페이지를 내주는 데 쓰이고, 이건 두 방식 모두 냅니다. 크기를 안다면 reserve 할 가치는 있지만, 더 큰 이점은 다음 절에 있습니다. 재할당이 없으면 참조도 죽지 않습니다.
반복자 무효화: ASan 이 지목하는 버그
반복자(iterator) 는 컨테이너 안의 위치(v.begin(), v.end())이고, 범위 for 도 뒤에서 이것을 씁니다. vector 가 재할당하면 옛 블록을 가리키던 반복자, 포인터, 참조는 모두 무효가 됩니다.
#include <iostream>
#include <vector>
int main() {
std::vector<int> scores = {70, 85, 90};
int& best = scores[2]; // reference into the vector
std::cout << "capacity before: " << scores.capacity() << '\n';
scores.push_back(100); // needs more room -> reallocates
std::cout << "capacity after: " << scores.capacity() << '\n';
std::cout << "best = " << best << '\n'; // dangling!
}
경고는 없습니다. 빌드 방법에 따라 세 가지 결과가 나왔습니다.
-O0: capacity before: 3 / capacity after: 6 / best = 2
-O2: capacity before: 3 / capacity after: 6 / best = 5
-fsanitize=address:
==43075==ERROR: AddressSanitizer: heap-use-after-free on address 0x6020000000f8
READ of size 4 at 0x6020000000f8 thread T0
#0 0x000104c40d54 in main invalid.cpp:10
freed by thread T0 here:
...
#10 ... in std::__1::vector<int>::push_back(int&&) vector.h:466
#11 ... in main invalid.cpp:8
best 는 90 이어야 합니다. 일반 빌드는 2 와 5 를 찍고 종료 코드 0 으로 끝나며 아무 힌트도 주지 않았습니다. ASan 은 읽은 곳(10 번째 줄), 해제한 곳(8 번째 줄의 push_back), 원래 할당한 곳까지 짚었습니다. 범위 for 본문에서 vector 를 늘려도 똑같습니다.
for (int x : v) {
if (x == 2) v.push_back(20); // ASan: heap-use-after-free rangefor.cpp:5
std::cout << x << ' ';
}
ASan 도 못 잡는 것
반복문 안의 erase 도 고전인데, 여기서는 ASan 이 조용했습니다.
std::vector<int> w = {1, 2, 2, 3, 4, 4, 5};
for (auto it = w.begin(); it != w.end(); ++it)
if (*it % 2 == 0) w.erase(it); // 'it' is invalid after erase
// printed: 1 2 3 4 5 (one 2 and one 4 survived)
erase 는 재할당하지 않으니 메모리 자체는 유효하고, ASan 은 보고할 게 없습니다. 반복문은 지운 자리로 당겨진 원소를 건너뛸 뿐입니다. libc++ 의 디버그 하드닝 모드로도 여기서는 잡히지 않았습니다. erase 의 반환값을 쓰거나 C++20 의 std::erase_if 를 쓰면 됩니다.
for (auto it = w.begin(); it != w.end(); )
it = (*it % 2 == 0) ? w.erase(it) : it + 1;
auto removed = std::erase_if(v, [](int x) { return x % 2 == 0; }); // C++20: "removed 4: 1 3 5"
-std=c++17 에서는 std::erase_if 가 error: no member named 'erase_if' in namespace 'std' 입니다.
단순한 범위 밖 인덱스라면 libc++ 에 ASan 보다 가벼운 검사가 있습니다. 원소 3 개짜리 vector 에서 v[3] 은 보통 조용히 v[3] = 0 을 찍지만, -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_DEBUG 로 빌드하자 종료 코드 134 로 멈췄습니다.
.../c++/v1/__vector/vector.h:414: libc++ Hardening assertion __n < size() failed: vector[] index out of bounds
v.at(3) 은 항상 검사해서 std::out_of_range 를 던집니다(libc++ 의 what() 은 그냥 vector).
함수 속 문자열: find, npos, string_view
std::string::find 는 위치를 돌려주고, "못 찾음"은 특별한 값 std::string::npos 입니다. 이걸 조건문에 그대로 쓰면 논리가 뒤집힙니다.
bool has_at_sign(const std::string& s) {
if (s.find('@')) return true; // BUG
return false;
}
has_at_sign("user@example.com") = true
has_at_sign("@admin") = false
has_at_sign("nobody") = true
npos = 18446744073709551615
위치 0 은 거짓이고 "못 찾음"은 거대한 참 값입니다. 경고도 없습니다. s.find('@') != std::string::npos 로 쓰거나 C++23 이면 s.contains('@') 를 씁니다.
std::string_view(C++17) 는 문자열을 소유하지 않는 가벼운 "창"으로, 포인터와 길이만 가집니다. 매개변수 타입으로 훌륭하지만 보고 있는 문자열보다 오래 살면 안 됩니다. 임시 문자열의 view 를 함수에서 돌려주자 일반 빌드는 공백을 찍었고, ASan 은 출력하는 줄에서 heap-use-after-free 를 보고했습니다. clang 은 단순한 경우에만 경고합니다. std::string_view v = std::string("..."); 이면 warning: object backing the pointer will be destroyed at the end of the full-expression [-Wdangling-gsl] 가 나오지만, 함수 호출을 거치면 아무 말도 없었습니다.
흔한 실수 → 실제로 일어난 일 → 해결
| 실수 | 실제로 본 것 | 해결 |
|---|---|---|
어떤 경로에 return 이 없음 |
-O0 에서 1, -O2 에서 66 |
-Werror=return-type |
int/double 버전에 show(5L) |
call to 'show' is ambiguous |
정확히 맞는 버전이나 명시적 캐스트 |
int/포인터 버전에 NULL |
모호성 에러 | nullptr |
매개변수가 std::vector<int> v |
1,000 만 개에서 2.4 배 느림 | const std::vector<int>& |
push_back 너머로 참조 보관 |
90 대신 2 나 5, 종료 코드 0 | 인덱스로 보관하거나 먼저 reserve |
erase(it) 뒤 ++it |
결과가 틀림, ASan 도 침묵 | it = v.erase(it) 또는 std::erase_if |
if (s.find(x)) |
논리가 뒤집힘 | npos 와 비교 |
솔직한 한계와 버전 메모
- 실행한 것은 arm64 macOS 26 의 Apple clang 21.0.0 과 libc++ 22 뿐입니다. libstdc++(GCC) 도 두 배, MSVC 는 1.5 배씩 늘리는 것으로 알고 있지만 여기서 돌려 보지 않았으므로 정확한 capacity 순서는 싣지 않았습니다.
std::string_view는 C++17, vector 용std::erase_if는 C++20,std::string::contains는 C++23. 범위 for 와nullptr는 C++11._LIBCPP_HARDENING_MODE는 libc++ 기능(LLVM 18 이후)입니다. libstdc++ 에는 대신_GLIBCXX_ASSERTIONS가 있습니다(실행 안 함).- 시간은 한 대의 기계에서 잰 값입니다. 봐야 할 것은 비율이고,
reserve표처럼 재는 방법 하나로 답이 세 배 달라질 수도 있습니다.
연습 문제
1. 위의 show(int) 와 show(double) 이 있을 때, 다음 호출은 어느 쪽을 고르나요, 아니면 에러인가요? show('x'), show(2.5f), show(10L)
2. libc++ 에서 빈 vector 에 push_back 을 5 번 할 때, 매번의 capacity 는?
3. 다음 반복문은 3 2 1 을 찍으려는 코드입니다. 실제로는 무슨 일이 일어나고, 어떻게 써야 맞나요?
std::vector<int> v = {1, 2, 3};
for (std::size_t i = v.size() - 1; i >= 0; --i)
std::cout << v[i] << ' ';
정답
1. show('x') 는 show(int)(승격, 120 출력). show(2.5f) 는 show(double)(승격). show(10L) 은 모호성 에러. long 은 양쪽 다 표준 변환이 필요합니다.
2. 실제 출력은 1 2 4 4 8.
3. 부호 없는 타입에서 i >= 0 은 항상 참입니다. v[0] 다음 --i 가 18446744073709551615 로 돌아가고 v[i] 는 vector 한참 밖을 읽습니다. -Wall -Wextra 는 아무 말이 없었고, -Wtautological-unsigned-zero-compare 를 주면 result of comparison of unsigned expression >= 0 is always true, ASan 은 heap-buffer-overflow ... in main rev.cpp:6 으로 멈춥니다. 올바른 버전 세 가지, 모두 3 2 1 을 찍습니다.
for (std::size_t i = v.size(); i-- > 0; ) std::cout << v[i] << ' ';
for (auto it = v.rbegin(); it != v.rend(); ++it) std::cout << *it << ' ';
for (int x : v | std::views::reverse) std::cout << x << ' '; // C++20, <ranges>
다음 강의
3 강은 클래스입니다. 생성자와 소멸자, 파일과 잠금의 RAII, 얕은 복사가 이중 해제로 끝나는 과정, 그리고 그것을 막는 0·3·5 의 규칙을 다룹니다.
- 이전: C++ 강좌 1/6: 첫 프로그램·타입·참조
- 다음: C++ 강좌 3/6: 클래스·RAII·5 의 규칙
- 이어서: 스마트 포인터와 이동 의미론, 템플릿과 STL 알고리즘, 성능과 동시성
- 관련 글: C 에서
realloc으로 같은 확장 방식을 잰 C 강좌 3, JVM 에서 같은 질문을 던진 Java 컬렉션 성능 - C++ 의 위치: 프로그래밍 학습 로드맵
자주 묻는 질문
C++ 오버로딩 호출이 ambiguous 에러가 나는 이유는?
정확히 맞는 함수가 없고, 두 후보가 같은 순위의 변환을 필요로 하면 컴파일러가 고르기를 거부합니다. show(int) 와 show(double) 이 있을 때 show(5L) 이나 show(5u) 를 부르면 clang 21 은 "error: call to 'show' is ambiguous" 를 냈습니다. long→int 와 long→double 이 둘 다 '표준 변환'이기 때문입니다. 정확히 맞는 버전을 추가하거나 show(static_cast<int>(n)) 처럼 직접 변환하세요.
std::vector 를 값으로 넘기면 비용이 얼마나 드나요?
호출할 때마다 통째로 복사됩니다. int 100 만 개 vector 를 합하는 함수가 값 전달은 호출당 0.116 ms, const 참조는 0.047 ms 였고, 1,000 만 개에서는 1.642 ms 대 0.698 ms 였습니다(7 회 중앙값, -O2, Apple Silicon M1 Pro). 원소 하나만 읽는 함수도 복사에 약 124 마이크로초를 썼습니다. 큰 타입은 const& 로 넘기세요.
push_back 뒤에 vector 원소 참조가 깨지는 이유는?
size 가 capacity 에 닿으면 push_back 이 더 큰 블록을 잡고 원소를 옮긴 뒤 옛 블록을 해제합니다. 옛 블록을 가리키던 참조, 포인터, 반복자는 모두 허공을 가리킵니다. 실제로 90 이어야 할 값이 -O0 에서 2, -O2 에서 5 로 찍혔고, AddressSanitizer 는 그 줄을 heap-use-after-free 로 보고했습니다. 인덱스로 들고 있거나 먼저 reserve() 하세요.