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
막대그래프. 원소 100 만 개에서 값 전달은 호출당 0.116 밀리초, const 참조는 0.047. 1,000 만 개에서는 값 전달 1.642 밀리초, const 참조 0.698. 복사 때문에 호출 비용이 두 배 넘게 든다.
std::vector<int> 를 합하는 함수 한 번의 시간. 값 전달과 const 참조 비교. 100 번 호출 평균을 7 번 잰 중앙값, -O2, 고정 시드, Apple Silicon Mac(M1 Pro).

원소를 전부 읽는 함수인데도 복사 때문에 호출 비용이 두 배를 넘었습니다. 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) 은 처음에 한 번만 할당합니다.

흐름도. push_back(x) 는 먼저 size 가 capacity 보다 작은지 본다. 작으면 x 를 다음 빈칸에 놓을 뿐 아무것도 움직이지 않는다. 아니면 약 두 배 크기의 새 블록을 잡고, 모든 원소를 그리로 옮기거나 복사한 뒤 옛 블록을 해제한다. 그 뒤 옛 블록을 가리키던 참조, 포인터, 반복자는 모두 허공을 가리키고, AddressSanitizer 는 이를 heap-use-after-free 로 보고한다.
push_back 한 번이 하는 일. 아래 길은 size 가 capacity 에 닿았을 때만 지난다. libc++ 에서는 push_back 100 만 번에 21 번 일어났고, 그때마다 옛 블록을 가리키던 참조는 모두 죽는다.

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++ 오버로딩 호출이 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() 하세요.

  • #C++
  • #초급
  • #오버로딩
  • #std::vector
  • #반복자 무효화
  • #AddressSanitizer

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑