C++에는 int같은 data type 말고도 복잡한 계산과 코드를 우아하게 해결해주는 여러가지 복합 유형 compound type이 있다. 일단 그 중 하나인 l-value와 r-value에 대해 먼저 알아보자.
lvalue는 식별 가능한 개체나 함수로 평가되는 표현식이다. (내 신원을 확인할 수 있는 Identify한 걸로 자신이 ID를 가졌다고 이해함)
int main()
{
int x{};
const double d{};
int y { x }; // x is a modifiable lvalue expression
const double e { d }; // d is a non-modifiable lvalue expression
return 0;
}
lvalue는 수정가능한 lvalue, const 이기 때문에 수정 불가능한 lvalue 두 종류로 구분할 수 있다.
rvalue는 간단하게 말하면 lvalue가 아닌 표현식이지만, 좀 더 자세하게 말하자면 Rvalue 표현식은 값으로 평가된다.
Lvalue와 달리 식별할 수 없으며(즉시 사용해야 함을 의미) 사용되는 표현식의 범위 내에서만 존재한다.
(나 자신의 고유한 식별가능 방법이 없음. 나만의 ID가 없는데, 그냥 숫자 5는 ID가 없다.. 할당 유무로 R Lvalue 판단하면 안될듯)
int return5()
{
return 5;
}
int main()
{
int x{ 5 }; // 5 is an rvalue expression
const double d{ 1.2 }; // 1.2 is an rvalue expression
int y { x }; // x is a modifiable lvalue expression
const double e { d }; // d is a non-modifiable lvalue expression
int z { return5() }; // return5() is an rvalue expression (since the result is returned by value)
int w { x + 1 }; // x + 1 is an rvalue expression
int q { static_cast<int>(d) }; // the result of static casting d to an int is an rvalue expression
return 0;
}
밑에 return5()나 x+1, static_cast<int>(d) 는 왜 rvalue인지 헷갈릴 수 있다. 그냥 간단하게 이러한 표현식들은 식별 가능한 객체가 아닌 임시 값을 생성하기 때문이다.
우선 짧게 정리하자면, Lvalue는 식별 가능한 객체로 평가되고 Rvalue는 값으로 평가된다.
그럼 우리가 지금껏 당연하게 생각해왔던 할당문에 대해 살펴보자.
// Assignment requires the left operand to be a modifiable lvalue expression and the right operand to be an rvalue expression
x = 5; // valid: x is a modifiable lvalue expression and 5 is an rvalue expression
5 = x; // error: 5 is an rvalue expression and x is a modifiable lvalue expression
이제 우리는 x = 5는 유효하지만 5 = x는 유효하지 않은 이유에 대해 대답할 수 있다.
할당 작업을 수행하려면 할당의 왼쪽 피연산자가 수정 가능한 lvalue 식이어야 하고 오른쪽 피연산자가 rvalue 식이어야 한다. 왼쪽 피연산자 식 5가 lvalue가 아니기 때문에 후자의 할당(5 = x)은 틀렸다.
이걸로 왜 Lvalue, Rvalue로 이름붙혔는지 알게 됐다.
추가예시)
int foo()
{
return 5;
}
int main()
{
int x { 5 };
&x; // compiles: x is an lvalue expression
&5; // doesn't compile: 5 is an rvalue expression
&foo(); // doesn't compile: foo() is an rvalue expression
}
이번엔 lvalue가 rvalue로 변환되는 경우를 보자.
(아까 이해한 대로면 원래 할당 받았던 값이 이젠 할당 하는 값으로?)
int main()
{
int x { 5 };
int y { x }; // x is an lvalue expression
return 0;
}
위 코드에서 x값은 일단 당연히 Lvalue로 평가된다.
하지만 y에서 int 변수의 초기화 프로그램은 x값이 Rvalue 표현식이 될 것으로 예상한다. 따라서 Lvalue 표현식 x는 Lvalue에서 Rvalue로 변환되고, 이는 값 5로 평가되며, y를 초기화하는 데 사용된다.
int main()
{
int x{ 1 };
int y{ 2 };
x = y; // y is a modifiable lvalue, not an rvalue, but this is legal
return 0;
}
여기에서 y는 Lvalue 표현식이다. 하지만 Lvalue에서 Rvalue로 변환하는 것처럼, y의 값 2가 x에 할당된 값으로 평가된다.
오해할 수 있는 사항이 있는데 여기서 y는 Rvalue가 아니라 수정가능한 Lvalue이다. 그냥 저 표현식에선 컴파일러가 그렇게 평가한다는 것이다.
int main()
{
int x { 2 };
x = x + 1;
return 0;
}
위의 예시에서, 변수 x는 두 가지 다른 문맥으로 사용된다. 할당 연산자의 왼쪽에서 x는 변수 x로 평가되는 lvalue 표현식이다. 그런데 할당 연산자의 오른쪽에서 x + 1은 값이 3으로 평가되는 rvalue 표현식이다.
Lvalue 참조 (여기서는 주소의미 아님. 말그대로 참조)
참조는 우리가 전에 배웠던 별명 aliases와 비슷한 역할을 한다. 참조 타입은 타입 선언에 &를 추가하면 된다.
참조가 객체(또는 함수)로 초기화되면 해당 객체(또는 함수)에 바인딩되어 있다고 말한다. 이러한 참조가 바인딩되는 프로세스를 참조 바인딩 이라고 한다 .
조금 주의해야 할 점이 있는데, Lvalue 참조는 항상 수정 가능한 Lvalue에 바인딩되어야 한다는 점이다.
왜냐면 참조를 통해 해당 값을 변경할 수 있어햐 하기 때문인데 이런 이유로 const값을 참조하지 못한다.
그리고 C++에선 일단 초기화되고 나서 참조를 다시 배치할 수 없다. 쉽게말하면 다른 개체를 참조하도록 변경할 수 없다.
그럼 다음 값은 어떤 결과를 출력할까?
#include <iostream>
int main()
{
int x { 5 };
int y { 6 };
int& ref { x }; // ref is now an alias for x
ref = y; // assigns 6 (the value of y) to x (the object being referenced by ref)
// The above line does NOT change ref into a reference to variable y!
std::cout << x << '\n'; // user is expecting this to print 5
return 0;
}
위에 말한대로라면 5를 출력할것 같은데, 이상하게도 6을 출력한다..
아까 잘못 말한것은 아니고, 참조하는 개체가 ref = y로 변경된 것이 아니라 여전히 ref = x 그대로 가리키고 있는데, 우리가 가리키는 ref값에 표현식 값 6을 할당해준 것으로 평가한다. 무슨 차이인지 알지...? 난 미래의 나 믿어
이러한 참조 변수는 일반적인 변수와 같은 범위 및 기간을 갖는다.
여기서도 조금 주의해야 할 점은, 참조변수 ref의 수명과 참조 대상 y의 수명은 서로 독립적이다. 그러므로 참조는 참조하는 객체보다 먼저 소멸될 수 있고, 참조되는 객체가 참조 전에 삭제될 수 있다.
참조ref가 참조 대상보다 먼저 파괴되면, 참조는 아무런 영향을 받지 않는다.
좀 신기한 사실은 C++에서 참조가 객체가 아니라는 것이다. 일반적인 경우 참조에는 저장소가 따로 필요하지 않고, 가능하다면 컴파일러는 참조의 모든 발생을 참조 대상으로 대체하는 것으로 최적화시킨다. 물론 항상 이런 것은 아니고, 아닌 경우에는 저장 공간이 필요할 수도 있다.
그리고 우리는 "참조 변수"라는 이름으로부터, 일반적인 변수가 객체임을 생각해서 참조도 객체라고 착각하지 말자. 객체가 아니므로 객체가 필요한곳에 사용될 수 없다.
앞에서 Lvalue 참조는 수정가능한 Lvalue에만 바인딩될 수 있다고 말했었다. 그래서 참조변수에는 const로 선언된 변수는 할당될 수 없었다.
하지만 Lvalue 참조를 선언할 때 const 키워드를 사용하면, Lvalue 참조가 참조하는 개체를 const로 처리하도록 할 수 있다.
이렇게 하면 Lvalue 참조는 수정 불가능한 Lvalue에 바인딩될 수 있다.
int main()
{
const int x { 5 }; // x is a non-modifiable lvalue
const int& ref { x }; // okay: ref is a an lvalue reference to a const value
std::cout << ref << '\n'; // okay: we can access the const object
ref = 6; // error: we can not modify an object through a const reference
return 0;
}
하지만 const에 대한 Lvalue 참조는 참조하는 객체를 const로 처리하므로, 참조되는 값에 액세스는 할 수 있지만 수정할 수는 없다.
const Lvalue 참조는 수정 가능한 Lvalue에 바인딩될 수도 있다. 이러한 경우 참조를 통해 액세스할 때 참조되는 객체는 const로 처리된다(기본 객체가 const가 아니더라도)
조금 잘 상상이 안됐는데, 그냥 int x {5}; 에서 const int& ref {x}; 가 가능하지만 여기서 ref에 다른값 할당은 안된다는 뜻
정리하자면 Lvalue 참조는 수정 가능한 Lvalue에만 바인딩할 수 있고,
const Lvalue 참조는 수정 가능한 Lvalue, 수정 불가능한 Lvalue 및 Rvalue에 바인딩될 수 있다.
몰랐을 때 이름만 들어보면 Lvalue참조가 더 유연할 것 같은데 사실은 더 제한적이고, const Lvalue가 오히려 더 유연하다. 이 점을 잊지 말자.
원래 Rvalue같이(그냥 숫자) 임시 객체를 생성하는 경우는 표현식의 끝에서 삭제되는데, 이러면 남아있는 ref는 dangling 상태가 되어 정의되지 않은 동작을 발생시킨다.
그래서 C++에선 이렇게 참조가 매달리는 경우를 방지하기 위해, const Lvalue가 임시 객체에 직접 바인딩되는 경우는 임시 객체의 수명이 참조의 수명과 일치하도록 확장되어 오류를 방지한다.
constexpr Lvalue참조 간략하게
이번엔 const말고 constexpr이다. constexpr을 사용한 참조는 정적 기간(전역)이 있는 객체에만 바인딩될 수 있다. 왜냐하면 컴파일러가 정적 개체가 메모리에서 인스턴스화되는 위치를 알고 있으므로 해당 주소를 컴파일 타임 상수로 처리할 수 있기 때문다.
반대로 constexpr 참조는 (비정적) 지역 변수에 바인딩할 수 없다. 지역 변수가 정의된 함수가 실제로 호출될 때까지 지역 변수의 주소를 알 수 없기 때문이다.
이런 사항을 고려해봤을 때 constexpr 참조는 거의 사용되지 않는다.
그래서 우리는 지금 왜 참조를 배우고 있을까.
옛날에 함수에 전달되는 인수는 함수의 매개변수에 값이 복사된다고 배웠었다. (pass by value)
이러면 복사본을 잠시 만들어서 사용하고 호출이 끝나면 값을 파기한다.
지금까진 일반적인 타입을 복사해서 괜찮았지만, 앞으로 배울 class type을 복사할 때면 잠깐 사용하고 파기하는 것은 비용을 너무 잡아먹는다. 이런 불필요한 복사본을 만드는 경우는 되도록 피하고싶다...
이러한 문제를 해결하기 위해 우리는 값 대신 참조를 전달한다. (pass by reference) 이러면 인수의 복사본이 만들어지지 않는다. 심지어 참조 바인딩은 항상 저렴하다.
대신 주의할 점은, 참조되는 개체에 대한 별명 역할을 하기 때문에 reference를 사용하면 복사본이 아닌 실제 인수에 접근하게 되므로 인수 값을 변경할 수 있다.
하지만 여기서 오해하면 안된다.
void addOne(const int& ref)
{
++ref; // not allowed: ref is const
}
const int&는 상수이기 때문에 참조된 값을 변경할 수 없다.
그래도 어차피 대부분의 경우 우리는 함수가 인수 값을 수정하는 것을 원하지 않기 때문에 별 상관은 없다.
만약 함수가 인수 값을 변경해야 하는 경우라면 non-const ref를 사용하자.
나는 지금까지 참조를 함수 내에서 인수값을 바꾸기 위해 존재하는 줄 알았는데, 사실 비용을 줄이기 위한 목적이 더 크다는 것을 처음 알았다..
그냥 일반 타입을 복사하는것이 가장 빠르고, 다음으로 참조 비용, 마지막으로 무거운 class type을 복사하는 것이 제일 느리다. 이러한 이유 때문에 우리는 앞으로 복사 비용이 많이 드는 객체의 경우, 복사 비용이 너무 크니까 const 참조 전달을 사용할 것이다.. (T 타입의 객체는 일반적으로 복사하는 것이 더 저렴하다.)
지금까지 Lvalue 참조에 대해 알아봤었다... 이것은 포인터와 아주 유사하지만 조금 다르다는데, 더 자세히.. 알아보자.
일단 변수에 메모리 주소가 할당되고, 표현식이나 명령문에서 그 변수를 사용할 때마다 프로그램은 메모리 주소로 이동하여 거기에 저장된 값에 엑세스한다.
이러한 변수의 좋은 점은 어떤 특정 메모리 주소가 할당되는지 또는 객체의 값을 저장하는 데 필요한 바이트 수에 대해 걱정할 필요가 없다는 것이다. 주어진 식별자로 변수를 참조하면 컴파일러는 이 이름을 적절하게 할당된 메모리 주소로 변환한다. 컴파일러는 모든 주소 지정을 처리한다.
이것은 참조 변수도 마찬가지이다. 아까 변수의 참조를 사용할 때마다 참조는 위와 같은 메모리 주소로 이동한다. 이것도 컴파일러가 주소 지정을 처리하기 때문에 우리가 신경쓸 필요가 없다.
앞에서 말했던 변수가 사용하는 메모리 주소는 기본적으로 우리에게 노출되지 않지만 우리는 주소 연산자&를 사용하여 피연산자의 메모리 주소를 반환할 수 있다.
타입 뒤에 &가 있으면 Lvalue 참조를 의미한다. int& ref
여기서 말하는 주소 연산자는 표현식의 단항 컨텍스트에서 사용된다. &x
이렇게 얻은 변수의 주소를 얻는 것 자체는 별로 사용할 일이 없다.
주소로 할 수 있는 일은 해당 주소에 저장된 값에 직접 엑세스하는 것이다. 역참조 연산자 *는 주어진 메모리 주소의 값을 Lvalue로 반환한다.
정리하자면, 참조 연산자는 객체의 주소를 가져오고
역참조는 그 주소에 있는 객체를 가져온다.
이 두 가지 연산자를 알아봤으니 이제 포인터를 알아볼 준비가 됐다.
포인터는 (주로 다른 변수의) 메모리 주소를 값으로 보유하는 객체이다. 이것을 통해 나중에 사용할 다른 객체의 주소를 저장할 수 있다. 참조타입이 &를 사용하는것과 같이 그대로 타입 뒤에 *를 붙이면 된다. int*
이제 포인터에 주소를 할당해보자.
주소를 할당하는 방법은 두 가지로 볼 수 있다.
- 포인터가 가리키는 것을 변경(포인터에 새 주소를 할당하여)
- 가리키는 값을 변경(역참조된 포인터에 새 값을 할당하여)
우선 포인터가 가리키는 것을 변경해보자.
int main()
{
int x{ 5 };
int* ptr{ &x }; // ptr initialized to point at x
std::cout << *ptr << '\n'; // print the value at the address being pointed to (x's address)
int y{ 6 };
ptr = &y; // // change ptr to point at y
std::cout << *ptr << '\n'; // print the value at the address being pointed to (y's address)
return 0;
}
이제 포인터가 가리키는 값을 변경시켜보자.
int main()
{
int x{ 5 };
int* ptr{ &x }; // initialize ptr with address of variable x
std::cout << x << '\n'; // print x's value
std::cout << *ptr << '\n'; // print the value at the address that ptr is holding (x's address)
*ptr = 6; // The object at the address held by ptr (x) assigned value 6 (note that ptr is dereferenced here)
std::cout << x << '\n';
std::cout << *ptr << '\n'; // print the value at the address that ptr is holding (x's address)
return 0;
}
이렇게 역참조 *ptr을 통하여 주소에 저장된 값을 변수라는 별칭 외에도 포인터라는 별칭으로 호출할 수 있다.
그런데 이쯤 되니 포인터랑 참조랑 조금 헷갈리는데, 다음 코드를 보고 생각을 정리하자..
int main()
{
int x{ 5 };
int& ref { x }; // get a reference to x
int* ptr { &x }; // get a pointer to x
std::cout << x;
std::cout << ref; // use the reference to print x's value (5)
std::cout << *ptr << '\n'; // use the pointer to print x's value (5)
ref = 6; // use the reference to change the value of x
std::cout << x;
std::cout << ref; // use the reference to print x's value (6)
std::cout << *ptr << '\n'; // use the pointer to print x's value (6)
*ptr = 7; // use the pointer to change the value of x
std::cout << x;
std::cout << ref; // use the reference to print x's value (7)
std::cout << *ptr << '\n'; // use the pointer to print x's value (7)
return 0;
}
우리는 값이 5인 정규 변수 x를 만든 다음 l값 참조와 x에 대한 포인터를 만든다. 다음으로 l값 참조를 사용하여 값을 5에서 6으로 변경하고 세 가지 방법을 모두 사용하여 업데이트된 값에 액세스할 수 있음을 보여준다. 마지막으로 참조 해제된 포인터를 사용하여 값을 6에서 7로 변경하고 다시 세 가지 방법을 모두 사용하여 업데이트된 값에 액세스할 수 있음을 보여준다.
따라서 포인터와 참조는 모두 다른 개체에 간접적으로 접근할 수 있는 방법을 제공한다. 주요 차이점은 포인터의 경우 주소를 명시적으로 가져와야 하고 값을 얻으려면 포인터를 명시적으로 역참조해야 한다는 것이다. 참조의 경우 주소및 역참조가 암묵적으로 발생한다.
주소 연산자(&)는 피연산자의 주소를 리터럴로 반환하지 않고, 피연산자의 주소가 포함된 포인터를 반환한다.
앞에서 Lvalue 참조에서 말했듯이, 포인터의 크기는 항상 동일하다. 포인터는 단지 메모리 주소일 뿐이고 메모리 주소에 액세스하는 데 필요한 비트 수가 일정하기 때문이다. (실행파일에 따라 다름. 32비트 실행파일이면 32비트 크기를 갖는다.)
다시 한번 말하지만 포인터는 메모리 주소를 보유하는 변수다. 역참조 연산자(*)를 사용하여 주소의 값을 검색할 수 있다. 포인터는 참조보다 더 유연하고 그만큼 위험하다..
왜 위험하냐면 메모리 주소 외에 아무것도 가리키지 않는 null 포인터가 있기 때문이다.
int* ptr {}; // 참고로 int* ptr; 은 garbage 주소 값을 준다.
//아니면
int* ptr { nullptr }; // nullptr 키워드 사용. 의미상 정수 0도 null값을 의미하므로 {0}가능
할당을 사용하여 포인터가 가리키는 대상을 변경할 수 있으므로 처음에 null로 설정된 포인터는 나중에 유효한 개체를 가리키도록 변경될 수 있다.
이렇게 nullptr 키워드를 사용하여 명시적으로 포인터를 초기화하거나 null값을 할당할 수 있다.
당연하겠지만 null 포인터를 역참조하면 프로그램이 종료되거나 정의되지 않은 동작이 발생한다.
여기서 프로그래머들이 가장 많은 오류를 발생시키는데, 객체가 소멸되면 파괴된 객체에 대한 포인터는 그대로 유지된다(자동으로 nullptr 로 설정되지 않음 ). 우리는 항상 이러한 경우를 감지하고 해당 포인터가 이후에 nullptr로 설정되었는지 확인해야 한다...
포인터와 참조는 모두 다른 개체에 간접적으로 액세스할 수 있다.
포인터에는 가리키는 내용을 변경하고 null을 가리킬 수 있는 추가 기능이 있다. 그러나 이러한 포인터 기능은 본질적으로 위험하다. 널 포인터는 역참조될 위험이 있으며 포인터가 가리키는 대상을 변경하는 기능을 사용하면 dangling 포인터를 만들 가능성이 더 올라갈 수 있습니다. 그러므로 가능하면 포인터보다 참조를 선호하는게 좋다.
지금까지 포인터에 새 주소를 할당하여 포인터가 가리키는 값을 변경하거나 보유중인 주소의 값을 변경했었다.
그런데 우리가 가리키고 싶은 값이 const라면 어떻게 될까?
int main()
{
const int x { 5 }; // x is now const
int* ptr { &x }; // compile error: cannot convert from const int* to int*
return 0;
}
const변수를 가리키도록 일반 포인터를 설정할 수 없기 때문에 컴파일 에러가 된다.
const변수는 값을 변경할 수 없는 변수인데 const포인터가 아닌 포인터를 const값으로 설정해버리면, 포인터를 역참조하고 값을 변경하는 경우가 있기 때문에 에러가 된다..
만약 const값에 대한 포인터를 선언하려면 포인터 타입 앞에 const 키워드를 사용해야 한다. (그냥 Lvalue 참조와 같다)
가리키는 데이터 유형이 const이므로 가리키는 값을 변경할 순 없지만, const에 대한 포인터는 const 자체가 아니기 때문에(단지 const 값을 가리킬 뿐이므로) 포인터에 새 주소를 할당하여 포인터가 가리키는 대상을 변경할 수 있다.
int main()
{
const int x{ 5 };
const int* ptr { &x }; // ptr points to const int x
const int y{ 6 };
ptr = &y; // okay: ptr now points at const int y
return 0;
}
const에 대한 참조와 마찬가지로 const에 대한 포인터도 const가 아닌 변수를 가리킬 수 있다. const에 대한 포인터는 해당 주소의 객체가 처음에 const로 정의되었는지 여부에 관계없이 가리키는 값을 상수로 처리한다.
int main()
{
int x{ 5 };
int* const ptr { &x }; // ptr will always point to x
*ptr = 6; // okay: the value being pointed to is non-const
return 0;
}
const int* const ptr {&value};
여기서 포인터와 const에 대해 잠깐 정리하고 가자면
- non-const 포인터는 가리키는 것을 변경하기 위해 다른 주소를 할당받을 수 있다.
- const 포인터는 항상 동일한 주소를 가리키며 이 주소는 변경할 수 없다.
- const가 아닌 값에 대한 포인터는 가리키는 값을 변경할 수 있다. 이는 const 값을 가리킬 수 없다.
- const 값에 대한 포인터는 포인터를 통해 액세스할 때 값을 const로 취급하므로 가리키는 값을 변경할 수 없다.
pass by value와 pass by reference를 통해 함수에 값을 전달하는 방법을 배웠다. 마지막으로 pass by address라는 방법을 알아보자.
pass by address를 사용하면 호출자는 객체를 인수로 제공하지 않고, 객체의 주소를 (포인터를 통해) 제공한다. 이 포인터(객체의 주소를 보유함)는 호출된 함수(이제 객체의 주소도 보유함)의 포인터 매개변수에 복사된다. 그런 다음 함수는 해당 포인터를 역참조하여 주소가 전달된 개체에 액세스할 수 있다.
뭔가 value와 reference가 섞인 느낌이다. 아래 예시 코드를 통해 비교해보자
#include <iostream>
#include <string>
void printByValue(std::string val) // The function parameter is a copy of str
{
std::cout << val << '\n'; // print the value via the copy
}
void printByReference(const std::string& ref) // The function parameter is a reference that binds to str
{
std::cout << ref << '\n'; // print the value via the reference
}
void printByAddress(const std::string* ptr) // The function parameter is a pointer that holds the address of str
{
std::cout << *ptr << '\n'; // print the value via the dereferenced pointer
}
int main()
{
std::string str{ "Hello, world!" };
printByValue(str); // pass str by value, makes a copy of str
printByReference(str); // pass str by reference, does not make a copy of str
printByAddress(&str); // pass str by address, does not make a copy of str
return 0;
}
위에서 printByAddress(&str); 호출이 실행되면 str의 주소를 보유하는 포인터가 생성된다. 이 주소는 함수 호출의 일부로서 함수 매개변수에 복사된다. 이제 str의 주소를 보유하고 있으므로 함수가 ptr을 역참조할 때 str의 값을 얻게되고, 함수는 콘솔에 print한다. 이게 address 과정 끝이다.
어찌됐든 주소값만 매개변수에 보내주면 되는 것이기 때문에, 이미 str의 주소값을 갖고있는 ptr이 있다면 매개변수에 ptr을 넘겨줘도 괜찮다.
std::string* ptr { &str }; // define a pointer variable holding the address of str
printByAddress(ptr); // pass str by address, does not make a copy of str
이렇게 참조와 마찬가지로 주소값만 넘겨주게 되니, 가리키는 객체의 복사본은 만들지 않는다.
이것은 복사본이 아닌 주소값이므로, 이를 역참조하여 원본 인수값을 수정할수도 있다.
하지만 주소는 복사한 값이므로, 함수 내에서 포인터에 다른 주소를 넣어도 원본 주소엔 아무런 영향이 없다.
그럼 함수가 포인터 인수가 가리키는 것을 변경하도록 할 수 있을까? ...그럼 어떻게..?
일반 변수를 참조로 전달할 수 있는 것처럼 포인터도 참조로 전달할 수 있다. (포인터 주소를 참조로)
#include <iostream>
void nullify(int*& refptr) // refptr is now a reference to a pointer
{
refptr = nullptr; // Make the function parameter a null pointer
}
int main()
{
int x{ 5 };
int* ptr{ &x }; // ptr points to x
std::cout << "ptr is " << (ptr ? "non-null\n" : "null\n");
nullify(ptr);
std::cout << "ptr is " << (ptr ? "non-null\n" : "null\n");
return 0;
}
솔직히 int*& 을 같이 쓰는건 처음봤다.. 이렇게 하면 포인터가 참조로 전달되는거니까,
(1)주소를 전달할 수 있고, (2) 그 전달한게 참조니까.. 원본값을 변경시킨다?
포인터에 대해 아직 잘 몰랐을 때는 *&랑 만나면 상쇄되는거 아닌가? 해서 없는셈 퉁 쳤는데.. 이럴 때 쓰이는구나? 싶었다.
Const Reference
반환 타입을 참조로 둘 수도 있다. 참조로 반환하려면 함수의 반환 값을 참조 타입으로 정의하기만 하면 된다.
이렇게 하면 반환되는 객체에 바인딩된 참조를 반환하므로 반환 값의 복사본을 만들지 않는다.
그러면서 const reference는 호출자를 함수에서 정의한 값에 엑세스하는 데에 사용될 수 있다.
저번에 함수는 인수의 값을 복사하므로 함수블록이 끝나면 인수는 파괴된다고 했었다. 방금 소개한 참조로 반환되는 객체는 인수를 복사하지 않으므로, 함수가 반환된 후에도 존재한다.
#include <iostream>
#include <string>
const std::string& getProgramName() // returns a const reference
{
static const std::string s_programName { "Calculator" }; // has static duration, destroyed at end of program
return s_programName;
}
int main()
{
std::cout << "This program is named " << getProgramName();
return 0;
}
여기서 함수가 s_programName을 복사하는 대신에 s_programName에 대한 상수 참조를 반환한다. 상수 참조를 반환하면 참조를 통해 데이터에 직접적으로 접근할 수 있기 때문에 데이터를 복사하는 것보다 효율적이다.
따라서 return s_programName은 getProgramName()이 반환하는 상수 참조를 반환한다. 그리고 호출자(caller)는 이 반환된 참조를 사용하여 s_programName의 값을 직접적으로 접근할 수 있다. 여기서는 값에 접근하여 출력됐다.
즉, 함수가 값을 복사하는 대신에 상수 참조를 반환하여 호출자가 해당 값을 직접적으로 접근할 수 있도록 한다.
그런데 참조를 반환하는 경우 조심해야 하는 점이 있다. 반환된 참조(reference)가 함수를 호출한 영역보다 더 오래 존재해야 하는 경우에 대해 알아보자.
위의 예시 프로그램에서, getProgramName() 함수는 참조를 반환하고 있다. 그러나 과연 반환된 참조가 함수가 종료된 후에도 여전히 유효할까?
이 경우, getProgramName() 함수 내에서 생성된 지역 변수 s_programName의 참조를 반환하고 있다. 그리고 이 참조는 함수가 종료된 후에는 더 이상 유효하지 않다.
s_programName이 함수 내에서 지역 변수로 선언되어 있으므로 함수가 종료되면 파괴되기 때문인데, 만약 이후에 main 함수에서 사용하면 더 이상 유효하지 않은(dangling) 참조를 사용하게 된다. (당연히 오류)
따라서 우리는 프로그램을 작성할 때, 참조를 반환하는 경우 반환된 참조가 함수를 호출한 영역에서 여전히 유효한지 확인해야 한다.
#include <iostream>
const int& returnByConstReference()
{
return 5; // returns const reference to temporary object
}
int main()
{
const int& ref { returnByConstReference() };
std::cout << ref; // undefined behavior
return 0;
}
반환 값이 mian에서 다른 const 참조에 바인딩될 때 쯤이면 임시 객체가 이미 파괴되었기 때문에 임시 객체의 수명을 연장하기엔 늦다. 따라서 ref는 dangling 참조에 바인딩되며 값을 사용하면 정의되지 않은 동작이 발생한다.
아직 const 참조로 반환하는 개념이 조금 헷갈린다. 다음을 보자.
const int& getNextId()
{
static int s_x{ 0 }; // note: variable is non-const
++s_x; // generate the next id
return s_x; // and return a reference to it
}
int main()
{
const int& id1 { getNextId() }; // id1 is a reference
const int& id2 { getNextId() }; // id2 is a reference
std::cout << id1 << id2 << '\n';
return 0;
}
const int id1 { getNextId() }; // id1 is a normal variable now and receives a copy of the value returned by reference from getNextId()
const int id2 { getNextId() }; // id2 is a normal variable now and receives a copy of the value returned by reference from getNextId()
그런데 그냥 const int인 일반 변수를 사용한다면 어떻게 될까?
위의 예에서는 getNextId()참조를 반환하지만 id1과 id2는 참조가 아닌 변수이다. 이러한 경우 반환된 참조 값이 일반 변수에 복사된다. 따라서 이 프로그램은 12 를 콘솔창에 print한다.
물론 이 예시는 참조로 값을 반환하려는 목적에 부합하지 않는다...
그럼 다른 예시 하나를 보자.
#include <iostream>
#include <string>
// Takes two std::string objects, returns the one that comes first alphabetically
const std::string& firstAlphabetical(const std::string& a, const std::string& b)
{
return (a < b) ? a : b; // We can use operator< on std::string to determine which comes first alphabetically
}
int main()
{
std::string hello { "Hello" };
std::string world { "World" };
std::cout << firstAlphabetical(hello, world) << '\n';
return 0;
}
이 예시는 참조 매개변수를 참조로 반환해도 괜찮음을 보여주는 코드이다.
인수를 함수에 전달하려면 인수가 호출자의 범위에 존재해야 하고, 호출된 함수가 반환되면 해당 개체는 호출자의 범위에 계속 존재한다.
위 함수에서 호출자는 const 참조로 두 개의 std::string 객체를 전달하고, 이 문자열은 const 참조로 다시 전달된다.
만약 값으로 전달 및 값으로 반환을 사용했다면 std::string의 복사본을 최대 3개 만들었을 것이다(각 매개변수에 대해 하나씩, 반환 값에 대해 하나씩). 참조로 전달/참조로 반환을 사용하면 이러한 복사본을 피할 수 있다.
참조 매개변수 뿐만 아니라, const 참조로 전달된 rvalue를 const 참조로 반환하는 것도 괜찮다.
rvalue가 생성된 전체 표현식이 끝날 때까지 rvalue가 삭제되지 않기 때문이다.
#include <iostream>
#include <string>
std::string getHello()
{
return std::string{"Hello"};
}
int main()
{
const std::string s{ getHello() };
std::cout << s;
return 0;
}
이 경우 getHello()는 rvalue인 std::string 값을 반환한다. 그런 다음 이 rvalue는 s를 초기화하는 데 사용된다.
s를 초기화한 후에는 rvalue가 생성된 식의 평가가 완료되고 rvalue가 파괴된다.
이번엔 지금까지 다뤘던 const에 대한 참조의 반대 경우이다.
const가 아닌 참조로 인수가 함수에 전달되면 함수는 참조를 사용하여 인수 값을 수정할 수 있듯이, 함수에서 const가 아닌 참조가 반환되면 호출자는 해당 참조를 사용하여 반환되는 값을 수정할 수 있다. (당연하다면 당연한 사실..)
#include <iostream>
// takes two integers by non-const reference, and returns the greater by reference
int& max(int& x, int& y)
{
return (x > y) ? x : y;
}
int main()
{
int a{ 5 };
int b{ 6 };
max(a, b) = 7; // sets the greater of a or b to 7
std::cout << a << b << '\n';
return 0;
}
그러면 a와 b중 b가 더 크므로 b에 7이 할당되어 57이 출력된다.
진짜 굳이..? 입 출력 매개변수 필요하다면?
우리는 일반적으로 함수에 값이나 const 참조로 인수를 전달해왔다. 보통 함수의 매개변수는 호출자로부터 입력을 수신하기만 했어서 그랬지만.. 이젠 역으로 매개변수가 호출하는 경우에 대해 알아보자.
#include <cmath> // for std::sin() and std::cos()
#include <iostream>
// sinOut and cosOut are out parameters
void getSinCos(double degrees, double& sinOut, double& cosOut)
{
// sin() and cos() take radians, not degrees, so we need to convert
constexpr double pi { 3.14159265358979323846 }; // the value of pi
double radians = degrees * pi / 180.0;
sinOut = std::sin(radians);
cosOut = std::cos(radians);
}
int main()
{
double sin { 0.0 };
double cos { 0.0 };
double degrees{};
std::cout << "Enter the number of degrees: ";
std::cin >> degrees;
// getSinCos will return the sin and cos in variables sin and cos
getSinCos(degrees, sin, cos);
std::cout << "The sin is " << sin << '\n';
std::cout << "The cos is " << cos << '\n';
return 0;
}
이 코드는 하나의 매개변수(인수는 값으로 전달됨)를 입력으로 갖고 두 개의 매개변수(참조로)를 출력으로 반환한다.
우리는 이러한 out 매개변수의 이름을 out 매개변수임을 나타내기 위해 접미사 “out”으로 명명했다. 이는 호출자에게 이러한 매개변수에 전달된 초기 값은 중요하지 않으며 해당 매개변수를 덮어쓰게 될 것을 암시하는 데에 도움이 된다. 관례적으로 출력 매개변수는 일반적으로 가장 오른쪽 매개변수에 넣는다.
먼저, 메인 함수는 지역 변수 sin과 cos를 만든다. 함수에 전달된 것들은 (값이 아닌) 참조로 가져온다. 이는 함수 getSinCos()가 복사본뿐만 아니라 main()의 실제 sin과 cos 변수에 액세스할 수 있음을 의미한다. getSinCos()는 따라서 sin과 cos(각각 sinOut과 cosOut 참조를 통해)에 새 값을 할당하고, 이는 sin과 cos의 이전 값을 덮어쓴다. 그런 다음 함수 main()은 이러한 업데이트된 값을 print 한다.
만약 sin과 cos가 참조 대신 값으로 전달됐다면 getSinCos()는 sin과 cos의 복사본을 변경하여 함수의 끝에서 변경 사항이 파괴되었을 것이다. 그러나 sin과 cos가 참조로 전달되었기 때문에 sin이나 cos에 대한 변경 사항은 함수 너머로 계속 유지된다. 이러한 메커니즘을 사용하여 값을 호출자에게 반환할 수 있던 것이다.
하지만 이런 out 매개변수는 객체를 인스턴스화(및 초기화)하거나 사용하려는 의도가 없더라도 이를 인수로 전달해야 하기 때문에 할당될 수 있는 객체를 선언해야한다. 이러한 이유로 const로 만들 수 없다.
이 외에도 여러 이유들이 있는데 이런 것들 때문에 out 매개변수는 사용하지 않는 것이 좋다.