주간 팁 #120: 반환 값은 건드리지 마세요
원래 게시일: 2012년 8월 16일
작성자: Samuel Benzaquen, (sbenza@google.com)
다음과 같은 코드 조각이 있다고 가정해 봅시다. 이 코드는 RAII 클린업 함수에 의존하고 있으며, 예상대로 작동하는 것처럼 보입니다:
MyStatus DoSomething() {
MyStatus status;
auto log_on_error = RunWhenOutOfScope([&status] {
if (!status.ok()) LOG(ERROR) << status;
});
status = DoA();
if (!status.ok()) return status;
status = DoB();
if (!status.ok()) return status;
status = DoC();
if (!status.ok()) return status;
return status;
}
그러던 중 마지막 줄을 return status;
에서 return MyStatus();
로 리팩토링하면 갑자기 코드가 에러를 로깅하지 않게 됩니다.
무슨 일이 일어난 걸까요?
요약
반환 문이 실행된 후에는 반환 변수에 접근(읽기 또는 쓰기)하지 마세요. 이를 올바르게 처리하려면 매우 주의해야 하며, 그렇지 않으면 동작이 정의되지 않습니다.
반환 변수는 복사되거나 이동된 후에 암묵적으로 소멸자가 호출됩니다(이는 C++11 표준 6.6.3절 [stmt.return] 참조), 이로 인해 예기치 않게 접근이 발생할 수 있습니다. 그러나 복사/이동이 생략될 수 있기 때문에 동작이 정의되지 않습니다.
이 팁은 지역 변수를 참조하지 않고 반환하는 경우에만 적용됩니다. 다른 표현식을 반환하는 경우에는 이런 문제가 발생하지 않습니다.
문제
반환 문에 적용되는 두 가지 최적화가 원본 코드의 동작을 변경할 수 있습니다: NRVO (See TotW #11)와 암묵적 이동입니다.
이전 코드가 작동한 이유는 복사 생략이 발생하고, return
문이 실제로는 아무 작업도 하지 않기 때문입니다. 반환 주소에 status
변수가 이미 생성되어 있고, 클린업 객체는 반환 문 후에 이 고유한 MyStatus
객체를 보고 있습니다.
변경 후 코드에서는 복사 생략이 적용되지 않고, 반환된 변수가 반환 값으로 이동됩니다. RunWhenOutOfScope()
는 이동 작업이 끝난 후 실행되므로 이동된 MyStatus
객체를 보고 있습니다.
이전 코드도 정확하지 않았습니다. 왜냐하면 복사 생략 최적화에 의존하여 정확성을 유지했기 때문입니다. 성능을 위해 복사 생략을 의존하는 것은 괜찮지만(See TotW #24), 정확성을 위해 의존해서는 안 됩니다. 결국 복사 생략은 선택적 최적화이며, 컴파일러의 옵션이나 구현 품질에 따라 발생하지 않을 수도 있습니다.
해결책
반환 문 이후에는 반환 변수를 건드리지 마세요. 지역 변수의 소멸자가 암묵적으로 이를 건드릴 수 있기 때문에 주의해야 합니다.
가장 간단한 해결책은 함수를 두 개로 나누는 것입니다. 하나는 모든 처리를 하고, 다른 하나는 첫 번째 함수를 호출하여 후처리(예: 에러 로깅)를 담당하게 합니다. 예를 들어:
MyStatus DoSomething() {
MyStatus status;
status = DoA();
if (!status.ok()) return status;
status = DoB();
if (!status.ok()) return status;
status = DoC();
if (!status.ok()) return status;
return status;
}
MyStatus DoSomethingAndLog() {
MyStatus status = DoSomething();
if (!status.ok()) LOG(ERROR) << status;
return status;
}
값을 읽기만 한다면, 최적화를 비활성화하는 방법도 있습니다. 그렇게 하면 복사가 항상 이루어져서 후처리에서 이동된 값이 보이지 않게 됩니다. 예를 들면:
MyStatus DoSomething() {
MyStatus status_no_nrvo;
// 'status'는 참조형으로 NRVO와 모든 관련 최적화가 비활성화됩니다.
// 'return status;' 문은 항상 객체를 복사하고, 로거는 항상 올바른 값을 볼 수 있습니다.
MyStatus& status = status_no_nrvo;
auto log_on_error = RunWhenOutOfScope([&status] {
if (!status.ok()) LOG(ERROR) << status;
});
status = DoA();
if (!status.ok()) return status;
status = DoB();
if (!status.ok()) return status;
status = DoC();
if (!status.ok()) return status;
return status;
}
또 다른 예시:
std::string EncodeVarInt(int i) {
std::string out;
StringOutputStream string_output(&out);
CodedOutputStream coded_output(&string_output);
coded_output.WriteVarint32(i);
return out;
}
CodedOutputStream
은 소멸자에서 사용되지 않는 후행 바이트를 잘라내는 작업을 합니다. 만약 NRVO가 발생하지 않으면, 이 함수는 문자열에 쓰레기 바이트를 남길 수 있습니다.
이 경우 NRVO가 발생하도록 강제할 수 없고, 비활성화하는 트릭도 효과가 없습니다. 반환 값은 반환 문이 실행되기 전에 수정되어야 합니다.
좋은 해결책은 블록을 추가하여 함수가 블록 내에서 모든 작업을 완료하고 반환 문이 실행되도록 제한하는 것입니다. 예를 들어:
std::string EncodeVarInt(int i) {
std::string out;
{
StringOutputStream string_output(&out);
CodedOutputStream coded_output(&string_output);
coded_output.WriteVarint32(i);
}
// 이 시점에서 스트림은 파괴되고 이미 플러시되었습니다.
// 이제 안전하게 'out'을 반환할 수 있습니다.
return out;
}
결론
반환되는 변수에 대한 참조를 보유하지 마세요.
NRVO가 발생할지 여부는 제어할 수 없습니다. 컴파일러 버전과 옵션에 따라 이 최적화가 다를 수 있습니다. 정확성을 위해 NRVO에 의존해서는 안 됩니다.
지역 변수를 반환할 때 암묵적 이동이 발생하는지 여부도 제어할 수 없습니다. 사용되는 타입이 나중에 이동 연산을 지원하도록 업데이트될 수도 있습니다. 또한 미래의 언어 표준에서는 더 많은 상황에서 암묵적인 이동을 적용할 것이므로, 현재 이동이 발생하지 않는다고 해서 미래에도 발생하지 않을 것이라고 가정해서는 안 됩니다.