C++ 참조는 원본이 사라진 뒤에도 사용할 수 있을까?
지역 변수의 수명과 값·참조 반환의 차이를 비교하고, Chromium 코드에서 참조의 수명 관계를 읽어봅니다.
이전 글에서는 참조를 기존 변수에 붙인 다른 이름으로 이해했다. 함수가 원본을 참조하면 복사본 대신 원본에 접근할 수 있고, const 참조를 사용하면 그 경로를 통한 변경을 제한할 수 있었다.
그런데 이 설명에는 전제가 하나 있다. 참조를 사용할 때 대상이 아직 살아 있어야 한다.
이번에는 『열혈 C++ 프로그래밍』 2장의 참조자와 함수를 바탕으로, 함수가 끝난 뒤 반환받은 참조를 사용할 수 있는지 살펴본다. C의 기본 문법과 함수, C++의 참조를 알고 있는 독자를 대상으로 한다. 실행 명령은 macOS의 Clang과 C++17을 기준으로 한다.
먼저, 정수도 수명이 있는 객체다
C++의 객체(object)는 클래스의 인스턴스만 뜻하지 않는다. 다음 코드의 정수 변수도 객체다.
int number = 10;
여기서 수명(lifetime)은 해당 객체가 존재하며 정상적으로 사용할 수 있는 기간을 뜻한다. 이번 글에서는 static을 붙이지 않은 일반적인 지역 변수부터 생각하자.
{
int number = 10;
// 이 안에서는 number를 사용할 수 있다.
}
// 블록을 벗어나면 이 지역 정수 객체의 수명도 끝난다.
중괄호로 묶인 영역을 블록이라고 한다. 이런 지역 변수는 실행이 그 선언에 도달해 초기화되면서 사용할 수 있게 되고, 블록을 벗어나면 수명이 끝난다.
이때 **범위(scope)**와 **수명(lifetime)**을 구분해 두면 좋다. 범위는 코드에서 어떤 이름을 사용할 수 있는 영역이고, 수명은 실행 중 객체가 존재하는 기간이다. 지금 예제에서는 둘의 끝이 함께 나타나지만, 다른 함수에서 만든 객체를 참조하는 경우처럼 항상 같은 개념은 아니다. C++ 표준 초안: 객체의 수명
값을 반환하면 함수 안의 변수가 사라져도 괜찮다
먼저 정상적인 값 반환을 보자. 다음 코드를 return_value.cc에 저장한다.
#include <iostream>
int GetNumber() {
int number = 10;
return number;
}
int main() {
int result = GetNumber();
std::cout << result << "\n";
}
clang++ -std=c++17 -Wall -Wextra return_value.cc -o return_value
./return_value
출력은 다음과 같다.
10
함수가 끝나면 지역 변수 number의 수명은 끝난다. 그런데 호출자는 어떻게 10을 읽을 수 있을까?
반환 타입이 int이므로 호출자에게 전달하는 것은 지역 변수에 대한 참조가 아니라 정수 값이다. 반환할 값을 준비하는 일이 지역 변수의 수명이 끝나기 전에 이루어지고, main의 result는 그 값으로 초기화된다.
GetNumber 안의 number: 10
↓ 정수 값을 반환
main 안의 result: 10
함수가 끝나 number의 수명이 끝나도 result는 별도로 존재한다.
이 그림은 변수의 관계를 설명한 것이며 실제 기계어의 복사 횟수를 나타내지는 않는다. 객체를 반환할 때의 최적화 등 세부 규칙은 이후에 살펴봐도 된다. C++ 표준 초안: return의 처리 순서
지역 변수의 참조를 반환하면 무엇이 달라질까?
반환 타입을 int에서 int&로 바꿔 보자.
int& GetNumberReference() {
int number = 10;
return number;
}
이번 함수는 10이라는 값을 반환하려는 것이 아니라, 함수 안의 number를 참조해서 반환하려고 한다. 문제는 함수가 끝나면서 바로 그 대상의 수명이 끝난다는 점이다.
함수 실행 중:
반환할 참조 ──→ number: 10
함수 종료 후:
반환받은 참조 ──→ 수명이 끝난 대상
대상이 이미 사라져 유효하게 사용할 수 없는 이런 참조를 **댕글링 참조(dangling reference)**라고 부른다.
위 함수를 return_reference_bad.cc에 저장하고, 실행 파일을 만들지 않고 진단만 확인해 보자.
clang++ -std=c++17 -Wall -Wextra -fsyntax-only return_reference_bad.cc
-Wall -Wextra는 경고 옵션이고, -fsyntax-only는 코드를 검사하되 실행 파일을 만들지 않는 옵션이다. 확인한 Clang에서는 다음 경고가 발생했다.
warning: reference to stack memory associated with local variable 'number' returned
경고 문구의 stack memory는 이 컴파일러가 지역 변수의 저장 공간을 설명하는 표현이다. 핵심은 저장 위치의 이름보다 반환한 참조의 대상이 함수 종료 후에도 존재하는가다.
이 참조를 받아 정수 값을 읽거나 쓰면 정의되지 않은 동작(undefined behavior)이 된다. C++이 그 동작의 결과를 보장하지 않는다는 뜻이다. 예전에 저장한 10이 보이거나, 다른 값이 나오거나, 실행이 중단될 수 있으므로 특정 출력을 정답으로 제시할 수 없다.
본문의 잘못된 예제는 경고를 확인하는 용도로만 사용했다. 경고가 발생해도 오류로 처리하지 않는 설정에서는 검사가 성공할 수 있고, 진단하지 못한 코드라고 해서 안전한 것도 아니다.
const를 붙여도 사라진 대상이 살아나지는 않는다
이전 글에서는 const 참조를 사용하면 해당 경로로 원본을 바꿀 수 없다고 설명했다. 그렇다면 아래처럼 바꾸면 안전할까?
const int& GetConstNumberReference() {
int number = 10;
return number;
}
이 함수도 잘못됐다. const는 반환받은 참조를 통한 변경을 제한하지만, 지역 변수 number의 수명을 늘려 주지 않는다.
다음 두 질문은 별도로 확인해야 한다.
- 이 참조를 통해 값을 변경할 수 있는가?
- 이 참조의 대상이 아직 존재하는가?
읽기 전용으로 접근하더라도 읽을 대상 자체가 사라졌다면 문제가 된다.
참조 반환 자체가 잘못된 것은 아니다
참조를 반환하려면 호출자가 사용할 때 대상이 살아 있어야 한다. 아래 예제에서는 함수 안에서 새로 만든 지역 변수가 아니라, 호출자가 전달한 기존 변수를 참조해서 반환한다.
return_caller_reference.cc에 저장한다.
#include <iostream>
int& SameNumber(int& value) {
return value;
}
int main() {
int number = 10;
int& result = SameNumber(number);
result = 20;
std::cout << number << "\n";
}
clang++ -std=c++17 -Wall -Wextra return_caller_reference.cc -o return_caller_reference
./return_caller_reference
출력은 다음과 같다.
20
SameNumber의 참조 매개변수는 함수가 끝나면 더 이상 사용할 수 없지만, 그 대상인 main의 number는 아직 살아 있다. 반환받은 result도 같은 number를 참조한다.
main의 number ← SameNumber의 value
↑
main의 result
함수의 참조 매개변수가 사라지는 것과 그 매개변수가 가리키던 원본이 사라지는 것은 다른 일이다. 이 예제는 number가 살아 있는 동안 result를 사용하므로 정상적으로 동작한다.
다만 “인자로 받은 참조를 반환하면 언제나 안전하다”로 일반화하면 안 된다. 대상의 수명을 확인해야 한다는 조건은 그대로다.
Chromium 코드에서 참조의 대상을 찾아보자
Chromium의 FilePath::value()는 다음처럼 정의되어 있다.
const StringType& value() const LIFETIME_BOUND { return path_; }
아직 클래스 문법을 배우지 않았다면 세부 문법보다 아래 관계에 집중하자.
StringType은 이 클래스에서 사용하는 문자열 타입의 이름이다.- 반환 타입
const StringType&는 문자열의const참조다. return path_;는 해당FilePath객체 안의 문자열을 참조해서 반환한다.
앞의 GetNumberReference와 다른 점은 path_가 함수 안에서 잠깐 만든 지역 변수가 아니라 그 함수를 호출한 객체에 속한 데이터라는 것이다. 따라서 반환된 참조를 사용할 때 그 FilePath 객체와 내부 문자열이 살아 있어야 한다.
여기에 붙은 LIFETIME_BOUND도 수명 관계에 관한 표시다. Chromium의 매크로 설명은 이런 표시를 일부 수명 오류의 진단을 돕는 용도로 설명한다. 지원하는 Clang에서는 [[clang::lifetimebound]]로 확장된다.
이 표시는 객체를 더 오래 살려 두는 기능이 아니다. 반환값이 호출한 객체의 수명에 의존한다는 정보를 컴파일러에 전달하는 것으로 읽으면 된다. Clang의 lifetimebound 설명
이 사례가 보여주는 것은 참조 반환이 위험하니 모두 없애야 한다는 주장이 아니다. 반환 타입에서 &를 발견하면 그 참조는 어느 객체를 가리키고, 그 객체는 언제까지 살아 있는가를 함께 찾아야 한다는 점이다.
가이드에서는 어떤 점을 주의하라고 할까?
Chromium은 자체 예외를 제외하고 Google C++ 스타일 가이드를 따른다. Google 가이드의 함수 입력·출력 항목은 반환값으로 결과를 전달하는 방식을 권장하며, 가능하면 값 반환을 우선하도록 설명한다.
또한 함수가 받은 참조가 호출 이후에도 계속 유효해야 하는 형태는 피하라고 권고한다. 참조 매개변수에는 임시 객체가 연결될 수도 있어, 함수가 그 참조를 나중에 사용하려고 보관하면 수명 문제가 생길 수 있기 때문이다. Google C++ 스타일 가이드
이 글의 예제에 적용하면, 단지 계산한 정수를 돌려주려는 함수는 먼저 int 반환으로 목적을 달성할 수 있는지 검토하면 된다. 기존 객체를 참조해서 반환해야 한다면 그 대상과 호출자에게 필요한 수명 조건을 분명히 해야 한다.
비동기 작업이나 콜백은 참조를 보관하는 상황이 더 복잡해지는 후속 주제다. 이번 글에서는 함수의 호출과 반환을 기준으로 수명을 읽는 데 집중한다.
핵심 요약
- 값 반환은 정수 값을 전달하므로, 함수 안의 지역 정수 변수가 사라져도 호출자가 받은 값을 사용할 수 있다.
- 지역 변수의 참조를 반환하면, 함수가 끝난 뒤 대상이 사라진 참조가 남는다.
- 참조 반환이 필요하다면 사용 시점까지 대상이 살아 있는지 확인해야 한다.
const는 변경 가능성과 관련된 제한이며, 대상의 수명을 자동으로 보장하지 않는다.
주의할 점
- 컴파일 성공은 수명 안전성의 증명이 아니다. 경고를 확인하고 참조 대상의 수명을 따로 생각해야 한다.
- 우연히 예상한 값이 나와도 안전한 코드는 아니다. 수명이 끝난 정수 객체를 참조해 읽는 결과는 보장되지 않는다.
- 참조를 다시 받아도 원본의 수명이 자동으로 연장되지는 않는다. 임시 객체의 수명 연장에도 적용 조건이 있다.
- 실제 코드의 참조 반환을 보면 먼저 대상을 찾는다. 지역 변수인지, 호출자가 제공한 객체인지, 다른 객체 안의 데이터인지 구분한다.
더 알아보기: 임시 객체와 const 참조
아래는 지역 const 참조에 임시 정수를 직접 연결하는 경우다.
const int& value = 10;
이 경우에는 임시 객체의 수명이 그 참조의 수명에 맞춰 연장된다. 하지만 이 규칙을 모든 참조 반환에 적용할 수는 없다.
const int& SameConstNumber(const int& value) {
return value;
}
int main() {
const int& result = SameConstNumber(10);
// 이 초기화 문장이 끝나면 인자로 만든 임시 객체의 수명이 끝난다.
// 다음 문장에서 result로 값을 읽으면 안 된다.
}
참조 매개변수에 연결된 임시 객체는 이 예제에서 함수 호출을 포함하는 전체 식이 끝날 때까지 존재한다. return value;로 그 참조를 전달하고 다른 참조에 연결했다고 해서 다시 수명이 연장되지는 않는다. 읽지 않고 참조를 선언하는 예제와, 수명이 끝난 뒤 그 참조를 사용해 값을 읽는 동작도 구분해야 한다. C++ 표준 초안: 임시 객체의 수명
처음에는 “const 참조를 사용하면 객체가 무조건 오래 살아 있다”라고 외우지 않는 것이 중요하다. 다음 글에서는 포인터 변수와 new로 만든 객체의 수명이 어떻게 다른지 살펴본다.