Devin.KR

C++ · 기본

C++ 객체와 자원 관리

C++ 참조와 const - 값 전달과 참조 전달, const 멤버 함수, 매달린 참조 (C++ 객체와 자원 관리 1장)

복사 횟수를 직접 세어 값·참조·const 참조 전달의 차이를 확인하고, const 멤버 함수와 지역 변수 참조를 돌려주는 첫 수명 버그를 다룬다.

개발자 · 원고 갱신

이 장에서 배우는 것

이 책은 C++ 문법을 처음부터 전부 훑는 책이 아니다. 게임 엔진, 임베디드 펌웨어, 로봇 제어처럼 메모리와 자원을 직접 책임지는 코드를 짜야 하는 사람이 "누가 이 자원의 주인인가, 언제 사라지는가"를 판단할 수 있게 만드는 것이 목표다. 포인터와 malloc/free, 스택과 힙의 기초는 같은 서재의 『메모리 그림으로 배우는 C 기본』에서 다룬다. 이 책은 그 위에서 C++가 무엇을 더 해 주는지에 집중한다.

모든 장은 같은 순서로 진행한다.

  1. 문제 상황 — 실제로 어떤 코드에서 어떤 사고가 나는지
  2. 완성 코드 — 그대로 복사해 컴파일할 수 있는 전체 코드
  3. 줄별 해설 — 왜 이렇게 짰는지
  4. 실제 실행 결과 — 검증용 Mac(Apple clang 17, C++20)에서 돌린 출력 그대로
  5. 실무에서 자주 틀리는 것연습 문제 3개(정답·해설 포함)

컴파일은 항상 clang++ -std=c++20 -Wall -Wextra로 한다. g++를 쓴다면 clang++g++로 바꾸면 되고, 경고 문구만 조금 다르다. 첫 장의 주제는 참조와 const다.

  • 값 전달, 참조 전달, const 참조 전달이 각각 무엇을 복사하고 무엇을 공유하는지 숫자로 확인한다.
  • const 멤버 함수가 "이 함수는 객체를 바꾸지 않는다"는 약속을 컴파일러가 검사하게 만드는 방법을 본다.
  • 지역 변수의 참조를 돌려주는, 가장 흔한 첫 번째 수명 버그를 컴파일러 경고로 잡는다.

문제 상황

로봇 카메라에서 받은 한 프레임(픽셀 1,000개라고 하자)을 밝게 보정하고 합계를 구하는 코드를 짠다고 하자. C에서 넘어온 사람은 보통 두 가지 중 하나를 한다. 구조체를 그냥 값으로 넘기거나, 포인터로 넘기고 매번 NULL 검사를 한다. C++에서 값으로 넘기면 std::vector 안의 픽셀 전체가 통째로 복사된다. 한 번은 괜찮지만 초당 60번 호출되는 루프 안이라면 이야기가 다르다. 더 나쁜 경우는 "밝게 만들었는데 원본은 그대로"인 버그다. 함수 안에서 복사본을 고쳐 놓고 원본을 고쳤다고 착각한다.

원하는 것은 이렇다.

  • 읽기만 하는 함수는 복사 없이 원본을 보되, 실수로 바꾸지 못하게 한다.
  • 바꾸는 함수는 원본을 확실히 바꾼다.
  • 어느 함수가 무엇을 하는지 선언만 보고 알 수 있다.

완성 코드

refs.cpp로 저장한다. Frame은 복사될 때마다 copies를 1씩 올려서, 복사가 몇 번 일어났는지 눈으로 볼 수 있게 만든 연습용 타입이다.

#include <iostream>
#include <vector>

struct Frame {
    std::vector<int> pixels;
    static inline int copies = 0;

    explicit Frame(int count) : pixels(count, 10) {}
    Frame(const Frame& other) : pixels(other.pixels) { ++copies; }
};

int total_by_value(Frame f) {
    int sum = 0;
    for (int p : f.pixels) sum += p;
    return sum;
}

int total_by_ref(const Frame& f) {
    int sum = 0;
    for (int p : f.pixels) sum += p;
    return sum;
}

void brighten(Frame& f, int amount) {
    for (int& p : f.pixels) p += amount;
}

void brighten_wrong(Frame f, int amount) {
    for (int& p : f.pixels) p += amount;
}

int main() {
    Frame frame(1000);

    brighten(frame, 5);
    brighten_wrong(frame, 100);
    std::cout << "첫 픽셀: " << frame.pixels[0] << '\n';

    std::cout << "값 전달 합계 " << total_by_value(frame)
              << ", 누적 복사 " << Frame::copies << "회\n";
    std::cout << "참조 전달 합계 " << total_by_ref(frame)
              << ", 누적 복사 " << Frame::copies << "회\n";

    int& alias = frame.pixels[0];
    alias = 0;
    std::cout << "별칭으로 바꾼 뒤: " << frame.pixels[0] << '\n';

    const Frame& view = frame;
    std::cout << "const 참조로 읽기: " << view.pixels[1] << '\n';
}

줄별 해설

static inline int copies — 복사 횟수 세기

클래스 안의 static 변수는 객체마다 따로 있지 않고 타입 전체에 하나만 있다. C++17부터는 inline을 붙이면 클래스 안에서 바로 초기화할 수 있다. 복사 생성자 Frame(const Frame& other)는 "다른 Frame으로부터 새 Frame을 만들 때" 불린다. 여기서 ++copies를 해 두었으니 값 전달이 일어날 때마다 숫자가 올라간다. 복사 생성자 자체는 4장에서 자세히 다룬다.

int total_by_value(Frame f) — 값 전달

매개변수 f는 호출하는 쪽 객체의 복사본이다. 함수가 시작할 때 픽셀 1,000개를 새 메모리에 복사하고, 함수가 끝나면 그 복사본을 버린다. 읽기만 하는 함수인데 쓸데없이 비싸다.

int total_by_ref(const Frame& f)const 참조 전달

&가 붙은 매개변수는 참조다. 참조는 "이미 있는 객체의 다른 이름"이다. 새 객체를 만들지 않으므로 복사가 없다. 앞에 붙은 const는 이 이름으로는 객체를 바꿀 수 없다는 뜻이다. 함수 안에서 f.pixels[0] = 1;이라고 쓰면 컴파일 오류가 난다. 읽기 전용 매개변수의 기본형은 이것이다.

void brighten(Frame& f, int amount) — 바꾸기 위한 참조

const가 없는 참조는 원본을 바꾸겠다는 선언이다. 함수 안의 for (int& p : f.pixels)도 같은 원리다. int& p는 각 픽셀의 참조라서 p += amount가 원본 픽셀을 바꾼다. 여기를 int p로 쓰면 각 픽셀의 복사본을 바꾸고 버리므로 아무 일도 일어나지 않는다.

void brighten_wrong(Frame f, int amount) — 의도한 실수

이름만 보면 brighten과 같은 일을 할 것 같지만 매개변수가 값이다. 복사본의 픽셀을 100씩 올리고 함수가 끝나면서 복사본이 사라진다. 컴파일러는 아무 경고도 내지 않는다. 문법적으로 완벽히 올바른 코드이기 때문이다.

int& alias = frame.pixels[0]; — 지역 참조

참조는 매개변수에만 쓰는 것이 아니다. 긴 표현식에 짧은 이름을 붙일 때도 쓴다. 참조는 만들 때 반드시 대상을 정해야 하고(int& r;은 오류), 한번 정한 대상을 바꿀 수 없다. alias = 0;은 "alias가 다른 것을 가리키게 하라"가 아니라 "alias가 가리키는 픽셀에 0을 넣어라"다. 포인터와 가장 크게 다른 점이 이것이다. 참조에는 "아무것도 안 가리키는 상태"(널)가 없다.

const Frame& view = frame;

같은 객체에 읽기 전용 이름을 하나 더 붙였다. view.pixels[1]은 읽을 수 있지만 view.pixels[1] = 0;은 컴파일되지 않는다. 객체 자체가 const가 된 것이 아니라 이 이름을 통한 접근만 막힌 것이다. frame으로는 여전히 바꿀 수 있다.

실제 실행 결과

$ clang++ -std=c++20 -Wall -Wextra refs.cpp -o refs && ./refs
첫 픽셀: 15
값 전달 합계 15000, 누적 복사 2회
참조 전달 합계 15000, 누적 복사 2회
별칭으로 바꾼 뒤: 0
const 참조로 읽기: 15

숫자를 하나씩 읽어 보자.

  • 첫 픽셀 15 — 처음 값 10에 brighten이 5를 더했다. brighten_wrong의 +100은 복사본에만 적용되어 원본에 흔적이 없다.
  • 값 전달 뒤 누적 복사 2회 — 한 번은 brighten_wrong, 한 번은 total_by_value가 만든 복사다.
  • 참조 전달 뒤에도 2회total_by_ref는 복사를 전혀 하지 않았다.
  • 별칭으로 바꾼 뒤 0alias를 통해 원본 픽셀이 바뀌었다.

값 전달은 픽셀 전체를 복사한 새 객체를 만들고, 참조와 const 참조는 원본을 가리키기만 한다. const 참조는 원본을 바꾸려는 코드를 컴파일 단계에서 막는다.

표 1-1. 매개변수 전달 방식 비교

선언복사원본 수정받을 수 있는 인자
void f(Frame x)있음(전체)복사본만무엇이든
void f(Frame& x)없음가능이름 있는 수정 가능 객체
void f(const Frame& x)없음불가(컴파일 오류)무엇이든(임시 객체 포함)
void f(Frame* x)없음(주소만)가능주소 또는 nullptr

const 멤버 함수: 약속을 컴파일러가 검사한다

멤버 함수 뒤에 붙는 const는 "이 함수는 객체를 바꾸지 않는다"는 표시다. const 참조로 받은 객체에서는 이런 함수만 부를 수 있다. 다음 코드는 읽기 전용으로 받은 센서를 몰래 보정하려고 한다.

#include <iostream>

class Sensor {
public:
    explicit Sensor(double offset) : offset_(offset) {}
    double read(double raw) const { return raw + offset_; }
    void calibrate(double offset) { offset_ = offset; }

private:
    double offset_;
};

void report(const Sensor& s) {
    std::cout << s.read(20.0) << '\n';
    s.calibrate(0.0);
}

int main() {
    Sensor s(0.5);
    report(s);
}
$ clang++ -std=c++20 -Wall -Wextra const_member.cpp -o const_member
const_member.cpp:15:5: error: 'this' argument to member function 'calibrate' has type 'const Sensor', but function is not marked const
   15 |     s.calibrate(0.0);
      |     ^
const_member.cpp:7:10: note: 'calibrate' declared here
    7 |     void calibrate(double offset) { offset_ = offset; }
      |          ^
1 error generated.

오류 문구가 정확히 이유를 말한다. calibrateconst가 아니므로 const Sensor에서 부를 수 없다. read에는 const를 붙였으니 문제없이 불린다. 멤버를 바꾸지 않는 함수에는 처음부터 const를 붙이는 습관이 중요하다. 나중에 붙이려고 하면, 그 함수를 부르는 다른 함수들도 줄줄이 고쳐야 한다.

지역 변수의 참조를 돌려주면

#include <iostream>
#include <string>

const std::string& default_name() {
    std::string name = "left_wheel_motor";
    return name;
}

int main() {
    const std::string& name = default_name();
    std::cout << "이름 길이: " << name.size() << std::endl;
}

name은 함수가 끝나면 사라지는 지역 변수다. 사라진 객체의 참조를 돌려받으면 그 참조는 매달린 참조(dangling reference)가 된다. 다행히 이 경우는 clang이 컴파일할 때 알려 준다.

$ clang++ -std=c++20 -Wall -Wextra -c dangling_ref.cpp
dangling_ref.cpp:6:12: warning: reference to stack memory associated with local variable 'name' returned [-Wreturn-stack-address]
    6 |     return name;
      |            ^~~~
1 warning generated.

default_name 이 끝나면 그 스택 프레임과 지역 문자열 name 이 사라진다. main 이 받은 참조는 다른 호출이 덮어쓸 빈자리를 계속 가리킨다.

경고를 무시하고 실행하면 이렇게 나왔다.

$ clang++ -std=c++20 -Wall -Wextra dangling_ref.cpp -o dangling 2>/dev/null && ./dangling
이름 길이: 0

문자열 길이는 16인데 0이 나왔다. 이 값에는 아무 의미가 없다. 이미 없어진 스택 영역을 읽은 결과라서 컴파일러, 최적화 옵션, 앞뒤 코드에 따라 16이 나올 수도, 이상한 큰 수가 나올 수도, 프로그램이 죽을 수도 있다. C++ 표준은 이것을 정의되지 않은 동작(undefined behavior)이라고 부른다. "우연히 맞는 값이 나왔다"도 정의되지 않은 동작의 한 형태라는 점이 가장 위험하다. 이런 버그를 실행 중에 잡아내는 도구(sanitizer)는 12장에서 다룬다.

실무에서 자주 틀리는 것

1. 작은 타입까지 전부 const&로 받는다

int, double, 포인터처럼 레지스터 하나에 들어가는 타입은 값으로 받는 편이 빠르고 읽기도 쉽다. const int&는 오히려 간접 접근을 한 번 더 만든다. 기준은 대략 이렇다. 복사가 싼 타입(기본 타입, 작은 구조체)은 값, 복사가 비싸거나 복사하면 안 되는 타입(std::string, std::vector, 큰 클래스)은 const&. 문자열을 읽기만 한다면 8장의 std::string_view도 후보다.

2. 범위 기반 for에서 auto만 쓴다

for (auto item : items)는 원소마다 복사본을 만든다. items가 문자열 벡터라면 반복마다 문자열 복사가 일어나고, 루프 안에서 item을 고쳐도 원본은 그대로다. 읽기만 하면 const auto&, 고치려면 auto&를 쓴다.

3. auto x = 참조를_돌려주는_함수();

auto는 참조와 최상위 const를 떼고 타입을 추론한다. const Frame& get_frame();의 결과를 auto f = get_frame();로 받으면 fFrame 복사본이다. 공유하려면 const auto& f = get_frame();이라고 써야 한다.

표 1-2. 참조와 포인터

성질참조 T&포인터 T*
없음nullptr 가능
다른 대상으로 바꾸기불가가능
만들 때 초기화필수선택(안 하면 쓰레기 값)
주 용도매개변수, 별칭없을 수도 있는 대상, C API

4. 경고를 끄고 산다

이 장의 매달린 참조는 -Wall 없이도 clang이 경고했지만, 다른 많은 수명 버그는 -Wall -Wextra를 켰을 때만 보인다. 팀 빌드에서 경고가 수백 개 쌓여 있으면 새 경고가 묻힌다. 새 코드는 경고 0개를 기준으로 두고, 가능하면 -Werror로 경고를 오류로 취급하게 한다.

연습 문제

  1. for (int p : frame.pixels) p = 0;을 실행한 뒤 frame.pixels[0]은 몇인가? 모든 픽셀을 0으로 만들려면 어떻게 고쳐야 하는가?
  2. const Frame& ref = frame; 다음에 auto copy = ref;auto& same = ref;를 썼다. 각각의 타입은 무엇이고, Frame::copies는 몇 번 늘어나는가?
  3. std::size_t length(const std::string& s)length("robot")처럼 문자열 리터럴로 부를 수 있다. 그런데 매개변수를 std::string& s로 바꾸면 컴파일되지 않는다. 이유는 무엇인가?

정답

  1. 원래 값 그대로다(이 장의 예제라면 10). int p는 각 픽셀의 복사본이라 복사본만 0이 되고 사라진다. clang은 -Wextra에서 "variable 'p' set but not used" 경고로 힌트를 준다. for (int& p : frame.pixels) p = 0;으로 고친다.
  2. copyFrame(참조와 const가 떨어진 복사본)이라 복사가 1번 일어난다. sameconst Frame&로 추론되어 원본을 가리키고 복사는 없다. auto&는 참조를 붙이지만 const는 그대로 유지하기 때문에 same으로 원본을 바꿀 수는 없다.
  3. "robot"std::string이 아니라 문자 배열이다. 호출할 때 임시 std::string을 만들어야 하는데, 임시 객체는 const 참조에만 묶을 수 있다. 수정 가능한 참조에 임시 객체를 묶으면 "바꿔 봤자 곧 사라질 객체를 바꾸는" 실수가 되기 때문에 언어가 막는다. 이 규칙 덕분에 const& 매개변수는 변수, 임시 객체, 리터럴을 모두 받는 가장 너그러운 형태가 된다.

다음 장에서는 객체가 언제 태어나고 언제 죽는지를 생성자와 소멸자로 직접 찍어 본다. 이 순서를 정확히 알아야 3장의 RAII가 왜 동작하는지 이해할 수 있다.

READER FEEDBACK

질문·오탈자·의견

내용에 관한 질문이나 오탈자, 더 나은 설명을 위한 의견을 남겨 주세요. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.