Abseil Tip 116 함수 인자에서 참조 사용 시 주의사항

Tip of the Week #116: 함수 인자에서 참조 사용 시 주의사항

작성자: Alex Pilkiewicz
최초 작성일: 2016년 5월 26일
업데이트: 2020년 6월 1일
원문 링크: abseil.io/tips/116


그림에서 이미지로, 이미지에서 텍스트로, 텍스트에서 음성으로, 상상의 포인터가 하나의 고유한 공간을 안정화하려고 시도하며 참조 체계를 가리키고 고정합니다. — 미셸 푸코, 《이것은 파이프가 아니다》


상수 참조 vs. 상수 포인터

함수의 인자로 사용될 때, 상수 참조(const reference)는 상수 포인터(pointer to const)에 비해 몇 가지 이점이 있습니다. 예를 들어, 참조는 null이 될 수 없고, 함수가 객체의 소유권을 가져가지 않는다는 점이 명확합니다. 그러나 상수 참조는 호출하는 쪽에서 참조를 사용하고 있다는 표시가 없고, 임시 객체에 바인딩될 수 있다는 점에서 문제가 발생할 가능성이 있습니다.


클래스 내에서 생길 수 있는 댕글링 참조(dangling reference)의 위험

다음 클래스를 예로 들어 보겠습니다:

class Foo {
 public:
  explicit Foo(const std::string& content) : content_(content) {}
  const std::string& content() const { return content_; }

 private:
  const std::string& content_;
};

언뜻 보기에는 문제가 없어 보입니다. 하지만 문자열 리터럴을 사용해 Foo 객체를 생성하면 어떻게 될까요?

void Func() {
  Foo foo("something");
  LOG(INFO) << foo.content();  // BOOM!
}

위 코드에서 foo 객체를 생성할 때, 멤버 변수 content_는 문자열 리터럴에서 생성된 임시 std::string 객체에 바인딩됩니다. 이 임시 객체는 생성된 라인의 끝에서 소멸되므로, 이후에 foo.content_는 존재하지 않는 객체를 참조하게 됩니다. 이를 접근하면 정의되지 않은 동작(undefined behavior)이 발생하며, 테스트에서는 정상적으로 동작할 수 있지만, 실제 실행 환경에서는 치명적인 오류를 유발할 수 있습니다.


해결책: 포인터 사용하기

이 경우 가장 간단한 해결책은 문자열을 값으로 전달하고 저장하는 것입니다. 하지만 원래 인자를 참조해야 한다면(예: 문자열이 아닌 다른 복잡한 타입인 경우), 포인터를 사용하는 방법이 있습니다:

class Foo {
 public:
  // 이 주석을 반드시 추가하세요:
  // content는 이 객체의 생명주기를 넘어 유효해야 하며, 소유권을 가져가지 않습니다.
  explicit Foo(const std::string* content) : content_(content) {}
  const std::string& content() const { return *content_; }

 private:
  const std::string* const content_;  // 소유권 없음, null 불가
};

이제 아래와 같은 코드는 컴파일 오류를 발생시킵니다:

std::string GetString();
void Func() {
  Foo foo1(&GetString());  // 오류: 'std::string' 임시 객체의 주소를 가져옴
  Foo foo2(&"something");  // 오류: 'Foo'의 생성자와 일치하는 초기화자가 없음
}

그리고 호출 시 객체가 인자의 주소를 보유할 가능성이 있음을 명확히 알 수 있습니다:

void Func2() {
  std::string content = GetString();
  Foo foo(&content);
}

한 단계 더 나아가기: 참조를 멤버로 저장하기

포인터가 null일 수 없고 소유권을 가지지 않는다는 점을 설명하는 주석이 반복적으로 나타나는 경우가 있습니다. 이를 줄이기 위해 참조를 멤버로 저장할 수도 있습니다:

class Baz {
 public:
  // 소유권을 가지지 않으며, 모든 포인터는 생성된 객체의 생명주기를 넘어 유효해야 합니다.
  explicit Baz(const Arg1* arg1, Arg2* arg2) : arg1_(*arg1), arg2_(*arg2) {}

 private:
  // 참조 타입 멤버는 소유권이 없고 null이 될 수 없음을 명확히 보여줍니다.
  const Arg1& arg1_;
  Arg2& arg2_;  // 네, 비상수 참조도 스타일 가이드에 부합합니다!
};

참조 타입 멤버의 단점은 재할당이 불가능하다는 점입니다. 따라서 이러한 클래스는 복사 할당 연산자(copy assignment operator)를 가질 수 없습니다(복사 생성자는 여전히 허용됩니다). 그러나 이를 명시적으로 삭제해 Rule of 3를 준수하도록 하는 것이 좋습니다. 클래스가 할당 가능해야 하는 경우, 상수가 아닌 포인터를 사용해야 할 수도 있습니다. 이와 관련된 내용은 Tip #177에서 더 자세히 다룹니다.

추가로, 방어적 프로그래밍을 위해 호출자가 null 포인터를 전달할 가능성을 차단하려면 *ABSL_DIE_IF_NULL(arg1)을 사용해 강제로 크래시를 발생시킬 수 있습니다. null 포인터를 단순히 역참조하는 것은 일반적으로 생각하는 것처럼 크래시를 보장하지 않습니다. 이는 정의되지 않은 동작이기 때문이며, 의존해서는 안 됩니다.


결론

생성자가 인자를 복사하거나, 생성자 내부에서만 사용하고 해당 인자의 참조를 저장하지 않을 경우에는 여전히 상수 참조를 인자로 사용하는 것이 괜찮습니다. 그러나 인자의 참조를 유지해야 하는 경우, 포인터(상수 또는 비상수)를 사용하는 것을 고려하세요. 또한, 객체의 소유권을 실제로 이전하는 경우라면 std::unique_ptr을 사용해야 합니다.

마지막으로, 여기서 논의된 내용은 생성자에만 국한되지 않습니다. 인자의 포인터를 캐시하거나, 분리된 함수에 인자를 바인딩하는 등 어떤 방식으로든 인자의 별칭을 유지하는 함수라면 동일한 원칙이 적용됩니다.


관련 읽을거리