상수 좌측값 참조 소개
이 글은 "좌측값 참조( l-value reference )의 성질"에 연결되는 글입니다.
얘기의 연속을 위해서 위의 글을 먼저 읽어보시길 추천합니다.
좌측값 참조( 간단하게는 참조 )는 실제 존재하는 객체의 별명이라고 설명했습니다.
그리고, 이 참조는 수정 가능한 객체 하고만 묶일 수 있습니다. ( reference binding )
const int x = 10;
int& rx = x; // error !
따라서, 만일 상수 객체에 대해서도, 참조 변수와 같은 별명을 만들고자 한다면, 참조 타입과는 다른 타입이 필요합니다.
그것이 기존의 참조 타입에 const 키워드를 붙인 상수 참조 타입( reference type to const )입니다.
그리고, 이 참조 타입으로 선언된 변수가 상수 좌측값 참조( l-value reference to a const value )입니다.
혹은 간략히 상수 참조( reference to const 또는 const reference )라고도 합니다.
이 상수 참조는 참조 대상을 상수로 다루는 참조 변수입니다.
즉, 반드시 참조 대상에 상수 속성이 있어야 참조할 수 있는 것이 아니라, 참조 대상을 상수 속성을 가진 객체로 생각하고 참조하겠다는 뜻입니다.
따라서, 상수 참조 변수는 당연히 상수 객체와 묶일 수 있습니다.
그리고, 일반 객체와도 묶일 수 있습니다.
게다가, 우측값과도 묶일 수 있습니다.
int main(){
const int a = 10;
int b = 20;
const int& ra = a; // ok
const int& rb = b; // ok. 상수가 아닌 객체도 참조 가능
rb += 10; // error. 상수로 취급하므로 값을 변경할 수 없음
b += 10; // ok. 변수 자체는 상수가 아님
const int& ref_rvalue = 5; // ok. 우측값도 참조
}
위에서 변수 b 는 상수 객체가 아닙니다.
그래서, b 의 값을 변경하는 것은 합법입니다.
그러나, 상수 참조인 rb 에 대하여 값을 변경하는 연산을 수행할 수 없습니다.
따라서, rb 를 통해선 b 의 값을 변경할 수 없습니다.
그런데, 위의 우측값인 5는 객체가 아닌데 어떻게 참조할 수 있는 걸까요?
const int& ref_rvalue = 5; // ok. 우측값도 참조
이 경우, 컴파일러는 우측값을 담기 위한 임시 객체를 생성합니다.
그리고, 이 임시 객체를 우측값 5로 초기화합니다.
그래서, 보이기에는 상수 참조가 5의 값을 가진 것 같지만, 실제는 상수 참조가 임시 객체를 참조하는 것입니다.
이 얘기를 듣자마자, "임시 객체는 그 문장이 끝남과 동시에 파괴되는 데, 무슨 의미가 있지"라고 의문을 가질 수 있습니다.
원래는 이 말이 맞습니다.
그런데, C++에서는 우측값도 상수 참조와 묶기 위해서, 특별한 규칙을 두었습니다.
그것은,
임시 객체가 상수 참조와 "직접적으로" 묶인다면, 임시 객체의 수명을 상수 참조의 수명과 동일하게 확장한다.
입니다.
그래서, 위의 5의 값을 가진 임시 객체는 상수 참조 ref_rvalue 가 파괴될 때, 같이 파괴됩니다.
또한, 참조 변수와 달리, 상수 참조는 다른 타입의 값과도 묶일 수 있습니다.
double d = 3.14;
int& rd = d; // error. 타입이 다름
const int& crd = d; // ok
const double& ref_rvalue = 5; // ok
char c{ 'a' };
const int& ref_char = c; // ok
참조 변수 rd 는 int 타입의 참조 변수입니다.
따라서, double 타입의 변수 d 와 묶일 수 없습니다.
그런데, 상수 참조인 crd 는 double 타입의 변수 d 와 묶는 게 문제없습니다.
const int& crd = d; // ok
어떻게 된 걸까요?
이것은 사실 약간의 트릭이 있습니다.
사실, int 타입의 참조 타입은 double 타입의 객체와 묶일 수 없습니다.
그래서, 컴파일러는 int 타입의 임시 객체를 생성하고, 변수 d 의 값으로 임시 객체를 초기화합니다.
( 물론, int 타입으로 변환하는 과정에서 소수점이하의 값은 잘려나갈 것입니다. )
그리고, 상수 참조인 crd 와 이 임시 객체가 묶이는 것이 이 문장에서 실제로 일어나는 일입니다.
그다음 문장과 마지막 문장도 마찬가지로, 값과 다른 타입의 상수 참조가 값과 묶이는 것을 볼 수 있습니다.
이제, 마지막으로 이전 글에서 해결을 남겨두었던 문제와 약간 찝찝했던 "'직접적으로" 묶인 다는 것이 무엇을 얘기하는 것인지에 알아보는 것이 남았습니다.
남겨진 문제들
이전 글에선 함수의 지역 변수를 참조 변수로 반환하면 안 된다고 얘기했었습니다.
그것은 반환되는 참조 변수가 dangling 되기 때문입니다.
// 참조 변수를 반환하는 함수
int& return_ref_func(){
int local = 10; // 지역 변수
return local;
}
int main(){
int& dangling_ref = return_ref_func(); // 참조 대상이 파괴된 참조 변수
cout << "return value: " << dangling_ref << endl; // 정의되지 않은 동작
}
위의 dangling_ref는 참조 대상인 local 변수가 파괴되었기 때문에, 이 참조 변수에 접근해선 안됩니다.
이 문제를 바로잡으려면, return_ref_func가 참조 변수를 반환하는 것이 아니라, 일반 변수를 반환하면 됩니다.
// 일반 변수를 반환하는 함수
int return_value_func(){
int local = 10; // 지역 변수
return local;
}
...
int value = return_value_func();
그리고, 참조 변수는 우측값과 묶일 수 없으므로, 위와 같이 일반 변수로 반환 값을 받아야 됩니다.
그런데, 반환 값이 int 타입 같은 원시 타입이 아니라 복합 타입인 경우( 예를 들어 std::string )는, 적어도 한 번은 무거운 복사 연산을 거쳐야 될 것입니다.
그래서, 상수 참조( reference to const )를 반환하는 것을 고려해 볼 수 있습니다.
상수 참조가 임시 변수와 묶이면, 임시 변수의 수명은 상수 참조의 수명만큼 확장되기 때문입니다.
// 상수 참조 변수를 반환하는 함수
const int& return_const_ref_func(){
// do something...
return getStrResult(); // 우측값 반환
}
int main(){
const int& const_ref = return_const_ref_func();
cout << "return value: " << const_ref << endl;
}
위에서 getStrResult 함수를 std::string 객체를 우측값으로 반환하는 함수라고 합시다.
그럼, 이 우측값을 반환하기 위해서 임시 객체가 생성되고, 이 임시 객체가 상수 참조와 묶일 때, 임시 객체의 수명이 확장됩니다.
그리고, 반환되는 상수 참조는 main 함수에서 다시 const_ref 상수 참조에 묶이게 되므로, 문제가 없을 것이라고 생각할 수 있습니다.
이것이 위에서 설명한, 임시 변수와 상수 참조가 묶일 때의 특별 규칙에서 "직접적으로"라는 단어를 따옴표까지 사용해서 강조했던 이유입니다.
const_ref 는 return_const_ref_func 함수에서 생성된 임시 객체와 직접적으로 묶이지 않았습니다.
따라서, 임시 객체는 return_const_ref_func 함수 종료 시에 파괴되고, const_ref 는 dangling 상태가 되는 것입니다.
( 물론, return_const_ref_func 가 반환한 임시 상수 참조 객체는 함수를 호출하는 문장이 끝나면 파괴됩니다. )
결론은, 함수의 지역 변수를 참조 타입( 참조 변수이든, 상수 참조 변수이든 )을 통해서 반환해선 안된다는 것입니다.
그리고, 이 결론을 강조하기 위해, 숨겼던 사실이 있습니다.
복합 타입의 지역 변수를 반환하면, 무거운 복사 과정이 반드시 필요한 것과 같은 뉘앙스를 풍긴 것입니다.
std::string return_string_func(){
std::string local = "This is a class type object"; // 지역 변수
return local;
}
int main(){
string obj = return_string_func(); // 클래스 객체를 반환하는 함수
cout << "return value: " << obj << endl;
}
위의 코드에서는, 사실 객체의 복사 과정은 일어나지 않습니다.
그것인 컴파일러가 복사 생략( copy elision )이라는 최적화 연산을 수행하기 때문입니다.
( 물론, 경우에 따라 최적화를 수행하지 못하는 경우도 있습니다. )
그래서, 지역 변수의 값으로, 바로 obj 객체를 생성하게 됩니다.
함수 경계를 넘어서기 위한 추가 복사 과정은 없게 된 것이죠.
복사 생략에 관한 내용은 여기서 볼 수 있습니다.
'C, C++ > C++ 언어' 카테고리의 다른 글
[C++] 함수 삭제( = delete )를 통한 기능 제어 (1) | 2024.10.05 |
---|---|
[C++] 사용자 정의 타입( user defined type )을 다른 타입으로 변경하기 (0) | 2024.10.01 |
[C++] 좌측값 참조( l-value reference )의 성질 (0) | 2024.09.29 |
[C++] 이름이나 표현식의 타입을 알려주는 decltype (2) | 2024.09.13 |
[C++] 컴파일러가 자동으로 작성하는 멤버 함수들 (2) | 2024.09.13 |