들어가기 전에..
C++은 헨따이 언어이다. 외부 활동을 하면서 다른 개발자들을 만나 주 사용 언어에 대한 주제가 나왔을 때, C++이 나의 주력 언어라고 하면 다들 급격히 말수가 줄어들며 뒷걸음질 치는 것을 느낄 수 있다. C++은 모든 프로그래밍 언어의 씨육수라고 해도 과언이 아닌 C언어를 기반으로 객체 지향과 같은 개념을 살짝 확장한, 굉장히 자유도가 높은 저수준의 언어이다.
다시 말하면 이 글을 찾아 들어온 당신은 헨따이 언어를 능수능란하게 구사하고 싶어 안달이 난 개발자라는 소리다. 헨따이 언어를 능숙하게 다루려면 당신도 상당한 수준의 헨따이가 되어야만 한다.
점진적으로 러스트가 C++을 완전히 대체하게 될거라는 의견이 늘고 있지만, 러스트를 배워보면 C++을 닮아 러스트도 굉장히 변태적인 언어임을 알 수 있다. 결국에 우리는 C++을 통해 항변태력을 미리 끌어올려야 나중에 러스트로 옮겨갈 일이 생기더라도 토컨이 가능해질 것이다. 이제 명분이 생겼으니 다이브 인 해보자!
그래서 그게 뭔데
이름에서 알 수 있듯이 L-Value는 Left-Value의 준말이고, R-Value는 Right-Value의 줄인말이다. 이야 쉽다..
int pen, apple;
int apple_pen = pen + apple;
왼쪽에 있는 apple_pen은 L-Value이고, 오른쪽에 보이는 pen + apple은 R-Value이다.
대입 연산자(=)의 왼쪽에 올 수 있는 값이라 Left라 부르고, 오른쪽에 올 수 있는 값이라서 Right라고 부른다.
L-Value (Left Value)
- 변수와 같이 메모리 주소를 가지고 있어서 참조를 통한 접근이 가능하다.
- 참조가 가능하므로, 값을 수정할 수도 있다.
- 예: 변수, 배열의 원소, 객체의 멤버 등
R-Value (Right Value)
- 임시로 사용되는 값으로, 특정 메모리 주소를 가지고 있지 않다. (사실 스택에 올라와서 처리된다. 자세한건 밑에서..)
- 읽기 전용 값으로 취급되어, 값을 수정할 수 없다.
- 예: 리터럴 값(상수, 문자), 임시 객체(연산의 결과), 함수의 리턴값 등
하지만 우리의 C++은 여기에서 끝나지 않는다. R-Value와 같이 메모리에서 영구적으로 관리되지 않는 값 마저도 손에 넣고 마음대로 더럽히고 싶은 욕망을 참지 않았다. R-Value를 옮겨야 하는 상황이 생길 때, deep copy 없이 동일한 값을 돌려쓰는 무시무시한 방식을 C++11부터 채택했다. 이와 관련하여 더 자세하게 알고 싶다면, 우선 이동 의미론(Move Sementics)이라는 개념에 대해 이해할 필요가 있다.
이동 의미론
객체의 복사가 아니라 이동을 통해 성능을 최적화한다는 이동 의미론은 C++ 11부터 도입되었다.
class Bubble
{
public:
char body[100000000000] = "안녕지현아너를처음본순간부터좋아했어방학전에고백하고싶었는데바보같이그땐용기가없더라지금은이수많은사람들앞에
서오로지너만사랑한다고말하고싶어서큰마음먹고용기내어봐매일매일버스에서너볼때마다두근댔고동아리랑과활동에서도
너만보이고너생각만나고지난3월부터계속그랬어니가남자친구랑헤어지고니맘이아파울때내마음도너무아팠지만내심좋은맘
두있었어이런내맘을어떻게말할지고민하다가정말인생에서제일크게용기내어세상에서제일멋지게많은사람들앞에서너한테
고백해주고싶었어사랑하는지현님내여자가되줄래?아니나만의태양이되어줄래?난너의달님이될게내일3시반에너수업마치고
학관앞에서기다리고있을게너만의...";
Bubble(char c) : body(c) {}
};
char* getBody(Bubble msg); // msg.body를 deep copy하여 리턴하는 함수
Bubble Notice("이제 누가 공지해주냐");
Bubble(getBody(Notice)); // getString에서 deep copy하여 리턴한 값을 클래스 복사생성자에서 다시 한 번 deep copy
Bubble 클래스는 매우 큰 크기의 문자열 필드를 지니고 있다.
getBody()에서는 Bubble 객체의 문자열을 deep copy하는 함수를 추가로 선언했다.
만약 Bubble(getBody(Notice))
과 같이 클래스의 생성자 내부에서 이 함수를 호출하게 된다면 한 줄의 코드로 대따 큰 배열을 두 번이나 복사하도록 만들 수 있다. 음.
여기에서 L-Value, R-Value의 개념이 살짝 헷갈릴 수 있다. 조금 더 자세히 파고 들어가보자.
char* getBody(Bubble msg)
{
char* copiedBody = new char[100000000]; // 동적 메모리 할당, [L-value]
std::strcpy(copiedBody, msg.body); // deep copy, [L-value]
return copiedBody; // 포인터를 반환, [L-value]
}
Bubble(getBody(Notice)); // getBody(Notice) 리턴값, [R-Value]
getBody() 내에서 힙 영역에 copiedBody를 위한 메모리를 할당받고 strcpy()를 통해 deep copy를 진행한다. 메모리 내부에 자기 자신을 위한 실질적인 공간도 부여받고 변수의 값을 담고 있는 데이터이므로 여기에서 copiedBody는 L-Value이다.
다만, 복사 생성자 내에서 getBody()를 호출한 경우, getBody()의 리턴값(copiedBody에 대한 포인터)을 생성자의 매개변수로 전달하는 것이므로 이 경우에는 R-Value로 취급한다.
🤔 저게 R이라구요?
R-Value 맞아요. 뭐요.
스코프를 차근차근 넓혀가면서 다시 생각해보자.
1. Bubble(char c) 생성자가 getBody(Notice)를 호출한다.
2. getBody(Notice)에서는 char 타입의 포인터를 생성하고 strcpy()는 Notice.body의 값을 읽어와 copiedBody에 대입한다. (deep copy)
3. copiedBody는 힙 영역에 동적으로 할당되었고 Notice.body에서 읽어온 내용을 담고 있으므로 이 값은 L-Value이다.
> 이제 스코프가 Bubble 생성자로 넓어졌다.
4. return이 일어나면서 스택이 정리된다.
> copiedBody는 함수의 로컬 변수(L-Value)였지만, 리턴이 일어나고 함수 외부로 반환되는 순간 임시 값(R-Value)로 취급된다.
5. getBody()의 리턴값은 생성자에 전달되든 안되든 유지되지 않고 곧 사라질 임시 객체이다.-> R-Value // 그건 제 잔상입니다만?
+) 만약 copiedBody를 동적할당하면 어떻게 될까?
getBody()에서 정의된 것처럼, 동적할당된 메모리는 힙 영역에 저장되며, 이는 함수가 종료되더라도 사라지지 않는다. 함수가 끝난 이후에도 여전히 포인터로 가리킬 수도 있다. 하지만, 함수에서 반환하는 포인터 자체는 해당 함수의 외부로 벗어나는 시점에서 이미 임시 객체로 취급되기 때문에 이 경우에도 r-value가 된다.
이처럼 크기가 큰 객체를 복제하는 과정에서 높은 성능 오버헤드가 발생하기 때문에, 값을 복사하는게 아니라 임시 객체를 그대로 참조해서 자원의 소유권을 이동하는 방식으로 최적화하는 기법이 등장했다. 이것이 바로 이동 의미론의 등장이다.
(헌데, std::move()가 r-value를 l-value로 옮기는 역할을 한다고 설명하면, 이건 또 틀린 설명이라고.. 이건 조금 더 복잡한 문제이므로 바로 아래 'R-Value에 대한 참조 (&&)' 파트와 함께 다시 다뤄보도록 하겠다.)
여기까지 l-value와 r-value에 대한 기본적인 내용을 다루었으니, 우리는 아래 코드가 왜 작동하지 않는지도 설명할 수 있다.
class Girlfriend
{
string name = "Takagi";
public:
string& accessName() { return name; }
string getName() { return name; }
}
Girlfriend TeasingMaster;
// Case 1.
string& myGirlfriend = TeasingMaster.accessName();
cout << myGirlfriend << endl;
// Case 2.
string& myGirlfriend = TeasingMaster.getName(); // 컴파일 에러!
cout << myGirlfriend << endl;
getName()이 반환하는 값은 r-value이기 때문에, 임시객체에 참조를 가져오는 것은 불가능하므로 컴파일 에러가 발생한다.
R-Value에 대한 참조 (&&)
이동 의미론을 구현하기 위해서는 R-Value에 대한 참조를 가져올 수 있어야 한다. 따라서 기존에 C++에서 사용하던 참조 연산자인 &
에서 따와 &&
를 사용한다.
이중포인터 **가 포인터 개념에서 응용한 것임과 달리, &&은 &과 직접적인 관련성은 없다. 그냥 디벨로퍼타치 간의 약속으로 탄생한 개념이다. r-value에 대한 참조를 사용할 때에는 &&
를 사용하면 된다.
물론 'r-value를 대상으로 참조' 하기 위해 탄생한 개념인 만큼 대부분의 용례는 r-value 임시 객체를 복사하지 않고 이동하도록 만드는 상황에서 사용하게 될 것이다. 앞서 들었던 예시 코드를 다시 한 번 가져오자면 아래와 같이 수정할 수 있을 것이다.
class Bubble
{
public:
char body[100000000000] = "안녕지현아너를처음본순간부터좋아했어방학전에고백하고싶었는데바보같이그땐용기가없더라지금은이수많은사람들앞에
서오로지너만사랑한다고말하고싶어서큰마음먹고용기내어봐매일매일버스에서너볼때마다두근댔고동아리랑과활동에서도
너만보이고너생각만나고지난3월부터계속그랬어니가남자친구랑헤어지고니맘이아파울때내마음도너무아팠지만내심좋은맘
두있었어이런내맘을어떻게말할지고민하다가정말인생에서제일크게용기내어세상에서제일멋지게많은사람들앞에서너한테
고백해주고싶었어사랑하는지현님내여자가되줄래?아니나만의태양이되어줄래?난너의달님이될게내일3시반에너수업마치고
학관앞에서기다리고있을게너만의...";
Bubble(char&& c) noexcept : body(c) {} // 임시객체를 참조의 형태로 입력받는다.
};
const char* getBody(Bubble msg); // msg.body를 참조하여 리턴하는 함수
Bubble Notice("이제 누가 공지해주냐");
Bubble(getBody(Notice)); // 더 이상 deep copy가 발생하지 않는다.
이제 보다 메모리 효율적인 방식으로 그녀에게 고백할 수 있다.
r-value에 대한 참조는 어떻게 일어날까?
메모리에서의 불필요한 복사를 줄이기 위해 참조가 일어나는 것이니만큼, 당연하게도 실제 메모리에 r-value를 옮기지는 않을 것이다. 임시 메모리에 존재하는 값을 그대로 참조하되, 해당 임시 객체의 수명을 연장시켜 최적화된 방식으로 연산을 처리하도록 한다.
생명 주기가 늘어난 사이에, 이동 생성자나 이동 연산자를 통해 원본 객체의 포인터를 새로운 객체로 옮기고, 원복 객체의 포인터를 nullptr로 설정하는 방식으로 이동시키면 되겠다.
이 과정을 <utility> 헤더 내에서 구현해놓은 것이 바로 move()인데, 앞서 std::move()를 통해 r-value를 l-value로 이동시켜 불필요한 복사를 생략하는 방식으로 최적화를 이룰 수 있다고 설명했다.
하지만, 앞 장에서 언급한 것처럼 move() 자체는 소유권의 이전 로직을 수행하지 않는다. 함수의 정의를 먼저 살펴보자.
// MSVC 14.41.34120에서 가져왔다.
_EXPORT_STD template <class _Ty>
_NODISCARD _MSVC_INTRINSIC constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) noexcept {
return static_cast<remove_reference_t<_Ty>&&>(_Arg);
}
💡Note: 위 코드의 리턴값은 r-value일까, l-value일까?
위 코드를 제대로 이해하려면 r-value도 l-value도 아닌, x-value(NEW!)에 대해서도 알아야 한다.
딥--다크한 심연으로 빠질 준비가 되었다면.. '사실은..' 파트까지 읽어보길 권한다.
1. _Ty
: _Ty
는 템플릿 매개변수로서, 함수 호출 시점에 컴파일러에 의해 함수 매개변수의 타입을 추론하여 적용된다.
2. _Ty&& _Arg
: move()에 입력된 매개변수는 _Ty 타입의 r-value 참조로 입력된다.
3. remove_reference_t<_Ty>
: _Ty
에서 참조를 제거한 타입을 나타낸다.
가령, _Ty가 int&라면 remove_reference_t<_Ty>
는 int 형이 되겠다.
그렇다면, remove_reference_t<_Ty>&&
는 참조를 벗겨낸 타입에 대한 r-value 참조가 된다. (int&&
라는 뜻)
4. static_cast<remove_reference_t<_Ty>&&>(_Arg)
: _Arg에 대하여 remove_reference_t<_Ty>&&
로 static cast를 수행한다.
사람 말로 풀어내자면, _Arg에 대하여 타입추론을 하는데, 참조를 먼저 벗겨낸 다음, r-value에 대한 참조를 붙인 타입으로 static cast를 수행한다.
결론: move() 내부로 넘긴 값을 강제로 r-value로 변환하고, 그에 대한 참조를 반환한다.
지금까지 배운 내용을 접목시켜보면, 비로소 아래의 문장을 이해할 수 있을 것이다.
move()에 l-value를 넘길 경우, 단순히 타입 캐스팅으로 r-value로 변환할 뿐 실제로 객체의 상태를 변경하지는 않는다. 왜냐하면, move()가 반환하는 리턴값 역시 r-value이기 때문이다.
{
int integer = 32;
integer++;
++integer;
int& a = integer;
int& b = 32;
int&& c = integer;
int&& d = integer++;
int&& e = ++integer;
}
전위 연산자는 변수를 참조하여 increment한 다음, l-value를 반환한다.
후위 연산자는 연산을 모두 진행한 다음, 반환이 끝난 이후에 해당 스코프에서만 일시적으로 값을 증가시키므로 r-value이다.
int i = 5;
++i = 10; // 가능
i++ = 10; // 컴파일 에러!
러스트와 비교해보면?
정리하자면, 원조 헨따이 언어인 C++에도 (러스트처럼 명시적이진 않지만) 소유권 비슷한 개념이 존재한다.
물론 C++의 단점과 한계를 커버하기 위해 나온 Rust에서 그 개념이 훨씬 직관적이고 명확하지만, C++에서 l-value/r-value에 대해 얉게나마 학습하고 나면 Rust의 소유권 개념에 대해서도 크게 거부감이 들지 않을 것이라 생각한다.
C++에서는 로컬 변수의 경우 scope를 벗어날 떄 자동으로 소멸하고, 동적 할당의 경우에는 scope를 벗어난 이후에도 메모리 공간을 차지하고 있기 때문에 프로그래머가 개입하여 delete 명령을 수행해야한다. (스마트 포인터 개념을 활용하거나)
Rust에서는 scope를 벗어날 때에도 자동으로 소유권이 소멸되지만, 소유권 시스템과 소멸자(drop trait)를 통해 자동으로 관리해준다는 점에서 차이가 있다.
특히, Rust에서는 소유권 개념과 관련하여 소유권의 이전이나 대여 개념이 존재하는데, C++에서도 Rust의 소유권 이전 개념을 move()를 통해 구현하고 있다.
사실은..
자격증 준비해야돼서 다음주에 작성.. ㅎㅎ.. ㅋㅋ.. ㅈㅅ;;
혹시라도 그 사이에 누군가가 이 글을 보시게 된다면 아래 내용을 참고해주시길 바란다.
Reference: https://modoocode.com/294
다음장) 응용 - Copy On Write