[모던 c++] 값의 범주(Value Category), 이동 의미론 (move semantics)
C++의 값의 범주와 이동 의미를 이해하면 복사 없이 자원을 효율적으로 다루는 방법을 알 수 있다. 이 글에서는 값의 범주(lvalue, rvalue)부터 시작해 std::move, T&&, 이동 생성자와 noexcept까지, 모던 C++의 핵심 개념을 단계적으로 정리해 보았다.
값의 범주 (value category)란?
C++의 값의 범주(value category) 는 표현식(expression) 이 나타내는 값이 어떤 성질을 가지는가 를 분류하는 개념이다.
이 분류는 크게 두 가지 기준에 따라 결정된다.
- 식별성(Identity) — 이 표현식이 메모리 상의 특정 객체를 식별할 수 있는가
- 이동 가능성(Moveability) — 이 표현식이 자원을 다른 객체로 이동할 수 있는가
쉽게 말해서 다음과 같다.
- 식별성은 “이 표현식이 실제 존재하는 객체(메모리 위치)를 가리키는가?”
- 이동 가능성은 “이 표현식의 자원을 옮길 수 있는가?” 를 의미한다.
c++11 이후에는 총 5가지의 범주로 나누어지는데, 3가지의 기본 값 범주(Prrimary Categories)와 2가지의 복합 값 범주(Mixed Categories)로 분류되게 된다.
기본 값 범주 (Primary value categories)
기본 값 범주로는 lvalue, xvalue, prvalue가 있다. 각각의 의미와 특징은 다음과 같다.
- lvalue — 식별 가능한 객체: 이름이 있는 객체를 나타내며, 메모리 위치를 가리킨다. 주소를 얻을 수 있고, 대입문의 왼쪽에 올 수 있다.
int a = 10; // 'a'는 lvalue
a = 20; // OK
&a; // 주소 취득 가능lvalue 예제
- xvalue (eXpiring value) — 곧 소멸할(expiring) 객체: 식별은 가능하지만 수명이 끝나서 이동의 대상이 되는 값이다. 보통
std::move()를 통해 만들어진다.
std::string s = "hi";
std::string t = std::move(s); // std::move(s)는 xvaluexvalue 예제
위 코드에서 아직 다루지 않은 이동 의미(std::move)에 대해서는 지금 단계에서는 "이동을 가능하게"만들어주는 함수 정도로 이해하면 된다.
- prvalue (Pure rvalue) — 이름 없는 임시 값: 연산의 결과나 리터럴 처럼, 메모리에서 따로 실별되지 않는(식별될 필요도 없는) 임시 값이다.
int x = 3 + 4; // (3 + 4)는 prvalue
std::string("hi"); // 임시 객체도 prvalueprvalue 예제
| 범주 | 의미 | 식별 (Identity) | 이동 (Moveability) | 주소 취득 | 특징 | 예시 |
|---|---|---|---|---|---|---|
| lvalue | 이름이 있고, 식별 가능한 객체 | ✅ | ❌ | ✅ | 메모리 상의 실제 객체를 가리킴. 대입문의 왼쪽에 올 수 있음. | a, *ptr, arr[0] |
| xvalue | 곧 소멸(expiring)할 객체 | ✅ | ✅ | ✅ | 이동 대상이 되는, 수명이 끝나가는 식별 가능한 객체. | std::move(a) |
| prvalue | 일시적인 값 (pure rvalue) | ❌ | ✅ | ❌ | 이름 없는 임시 값. 계산 결과나 리터럴 등. | 10, a + b, "hi", MyClass() |
복합 값 범주 (Mixed value categories)
복합 값 범주는 기본 값 범주를 조합하여서 만든 범주이며, glvalue와 rvalue가 있다.
- glvalue (Generalized lvalue) — 위치를 가리킬 수 있는 값: lvalue와 xvalue를 합친 개념으로, "어디에 있는가"를 나타내는 값이다.
int a = 10;
std::cout << &a; // 주소 가능 ✅
std::move(a); // xvalue지만 glvalue에도 포함gvalue 예제
- rvalue — 이동 가능한 값: xvalue와 prvalue를 합친 개념으로, "무엇인가"를 나타내는 이동 가능한 값이다. 함수의 반환값이나 리터럴, 연산 결과가 여기에 해당된다.
int n = 3 + 5; // (3 + 5)는 prvalue → rvalue 범주에 포함됨
std::string t = std::move(s); // std::move(s)는 xvalue → rvalue 범주에도 포함됨
rvalue 예제
| 범주 | 의미 | 구성 요소 | 식별 (Identity) | 이동 (Moveability) | 주소 취득 | 특징 | 예시 |
|---|---|---|---|---|---|---|---|
| glvalue | 메모리 상의 객체를 식별할 수 있는 표현식 | lvalue+ xvalue | ✅ | ⏹️ 일부 가능(xvalue) | ✅ | “어디 있는가”를 알 수 있는 값. 즉, 메모리 위치를 참조함. | a, *ptr, arr[0], std::move(a) |
| rvalue | 이동 가능한 값. 더 이상 특정 객체와 연결되지 않음 | xvalue+ prvalue | ⏹️ 일부 가능(xvalue) | ✅ | ❌ | “무엇인가”를 나타내는 값. 복사나 이동으로 소비됨. | 10, a + b, std::move(a) |
간단히 말해 glvalue는 lvalue와 xvalue의 합집합이며, rvalue는 xvalue와 prvalue의 합집합이라 할 수 있다.
이동 의미(move semantics)란?
앞서 살펴본 값의 범주(value category)는 단순한 이론적 구분이 아니라, C++가 객체를 어떻게 전달하고 소유권을 어떻게 이전할 지 결정하는 핵심 기반이다.
그 중에서도 이동(move)는 모던 c++를 대표하는 개념으로, 값을 복사하지 않고, 그 객체가 보유한 자원(메모리, 파일 핸들 등)의 소유권을 넘기는 행위를 말한다.
복사와 이동의 차이
복사는 데이터를 "복제"하기 위해 메모리에 새로운 공간을 할당하지만, 이동은 "포인터"만 옮겨서 원본을 비우는 방식이다.
예를 들자면 다음과 같다. 복사는 내가 이사갈 집을 새로 짓고, 기존 집의 가구를 하나하나 새로 옮겨 담는 것과 같다. 즉, 새 집과 옛 집이 모두 존재하며, 둘 다 완전한 상태로 남는다.
반면 이동은 새 집을 짓지 않고, 기존 집의 열쇠를 다른 사람에게 넘겨주는 것과 같다. 집(자원) 자체는 그대로지만, 이제 그 집의 주인은 나에서 다른 사람으로 바뀌었기 때문에 나는 더 이상 그 집을 사용할 수 없다.
| 구분 | 복사(Copy) | 이동(Move) |
|---|---|---|
| 동작 | 원본의 데이터를 그대로 복제 | 원본의 내부 자원을 새 객체로 넘김 |
| 비용 | 데이터 크기만큼 복사 비용 발생 | 포인터/핸들만 옮기므로 거의 0 |
| 결과 | 원본과 복사본이 각각 존재 | 원본은 “비워진(valid but unspecified)” 상태 |
| 사용 시점 | 두 객체가 모두 계속 필요할 때 | 원본이 더 이상 필요하지 않을 때 |
이동이 필요한 이유
올드 C++시절에는 객체를 복사만 가능했기 때문에 임시 객체나 반환값이 많아지는 코드는 불필요한 복사가 쌓여서 비효율적이었다.
std::string makeMessage(bool flag)
{
std::string hello = "Hello";
std::string world = "World";
if (flag)
{
return hello;
}
else
{
return world;
}
}
복사로 msg를 내보내는 경우
위와 같은 괴상한 코드가 있다고 생각해보자. 올드 C++에서는 무조건 복사 후 반환을 하게 되어있어서 매번 복사비용이 발생하게 된다.
그러나 모던 C++에 이동이 도입된 이후 컴파일러는 다음과 같이 이동으로 최적화해서 내보낸다. 이제는 hello 또는 world가 곧 소멸할 지역 변수(rvalue) 로 인식되므로, 복사 대신 이동 생성자가 호출되어 내부 버퍼 포인터만 전달한다. 따라서 성능 상 이점이 생기게 되는 것이다.
std::string makeMessage(bool flag)
{
std::string hello = "Hello";
std::string world = "World";
if (flag)
{
return std::move(hello); // 이동 발생 (C++11+)
}
else
{
return std::move(world); // 이동 발생 (C++11+)
}
}
이동으로 내보내는 경우. 현대 C++ 컴파일러는 대부분 이런 식으로 최적화해서 내보낸다.
위 코드에서 보듯 c++에서 이동을 가능하게 만들어 주는 함수가 바로 std::move이다.
std::move
std::move는 이름과 그 동작을 보았을 때는 실제로 값을 이동시켜주는 함수일 것 같지만, 사실은 그건 틀린 해석이다. 실제로는 주어신 표현식을 rvalue로 캐스팅해주는 것일 뿐이다. 즉, std::move(obj)는 내부적으로 아래 코드와 같다.
static_cast<T&&>(obj);이 말은 곧, std::move는 “이 객체는 이제 더 이상 안 쓸 거야”라는 의도를 컴파일러에 전달하는 도구다. 그 표시를 받은 쪽(T&&를 받는 함수나 생성자)이 이동 생성자/대입 연산자를 호출해서 실제 자원을 옮긴다.
#include <iostream>
#include <string>
#include <utility>
int main()
{
std::string a = "Hello";
std::string b = std::move(a); // 이동 생성자 호출
std::cout << "b = " << b << '\n'; // Hello
std::cout << "a = " << a << '\n'; // 비워진 상태 (valid but unspecified)
}
std::move 예제 코드
rvalue 참조 (T &&)
std::move가 "rvalue"로 캐스팅해주는 도구라면, 그렇게 만들어진 값을 받아주는 통로가 바로 rvalue 참조 (T &&)이다.
T && 는 곧 소멸할(rvalue) 객체에만 바인딩 할 수 있는 참조다. 즉, "이 객체는 금방 소멸될 예정이니 내부 자원을 통째로 옮겨 써도 된다"는 약속을 의미한다.
#include <iostream>
#include <string>
void printRef(std::string &s)
{
std::cout << "lvalue ref\n";
}
void printRef(std::string &&s)
{
std::cout << "rvalue ref\n";
}
int main()
{
std::string name = "kim";
printRef(name); // lvalue → lvalue ref 호출
printRef("guest"); // 임시 객체 → rvalue ref 호출
printRef(std::move(name)); // xvalue → rvalue ref 호출
}
출력:
lvalue ref
rvalue ref
rvalue ref즉, T&&는 임시 객체나 이동 대상(rvalue) 에만 바인딩되며, 그 객체의 내부 자원을 새로 복사하지 않고 직접 옮길 수 있다.
이동 생성자 / 이동 대입 연산
rvalue 참조를 가장 실용적으로 쓰는 곳이 바로 이동 생성자와 이동 대입 연산자이다. 이 두 함수가 있어야 클래스 복사 없이 자원(포인터, 버퍼, 핸들 등)을 안전하게 이전할 수 있다.
이동 생성자(Move Constructor)
#include <iostream>
#include <string>
class Data
{
public:
std::string text;
Data(const std::string &t) : text(t) {}
Data(const Data &other) : text(other.text) { std::cout << "Copy\n"; }
Data(Data &&other) noexcept : text(std::move(other.text)) // 이동 생성자
{
std::cout << "Move\n";
}
};
int main()
{
Data a("Hello");
Data b = std::move(a); // 이동 생성자 호출
}
출력
MoveData(Data&& other)는 rvalue 참조를 인자로 받는다.std::move(other.text)를 통해 내부 문자열의 버퍼를 그대로 옮긴다.other는 이동 후 “비워진(valid but unspecified)” 상태가 된다.
이동 대입 연산자 (Move Assignment)
class Buffer
{
public:
Buffer(size_t size = 0) : data_(size ? new int[size] : nullptr), size_(size) {}
~Buffer() { delete[] data_; }
Buffer(Buffer &&other) noexcept
: data_(other.data_), size_(other.size_)
{
other.data_ = nullptr;
other.size_ = 0;
}
Buffer &operator=(Buffer &&other) noexcept //이동 대입 연산자
{
if (this != &other)
{
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
}
return *this;
}
private:
int *data_;
size_t size_;
};
- 이동 생성자: 새 객체를 만들 때 자원을 옮김
- 이동 대입 연산자: 기존 객체에 다른 객체의 자원을 덮어씀
noexcept를 붙여야std::vector같은 컨테이너가 이동을 선호한다.
noexcept 키워드를 사용하는 이유
C++ 표준 컨테이너 (std::vector, std::string, std::map 등)는 객체를 재배치(reallocate)할 때 이동(move) 을 사용할지, 복사(copy) 를 사용할지 판단해야 한다. 이때 이동 생성자가 noexcept일 때만 컨테이너는 이동(move)을 사용한다. 그렇지 않으면 안전을 위해 복사(copy)를 사용하기 때문에 반드시 이동 생성자 / 이동 대입 연산자를 오버로딩 할 떄는 noexcept로 해줘야 한다.
생성자 정리
| 구분 | 인자 타입 | 시점 | 역할 |
|---|---|---|---|
| 복사 생성자 | const T& | 새 객체 생성 시 | 자원을 새로 복제 |
| 이동 생성자 | T&& | 새 객체 생성 시 | 자원을 그대로 이전 |
| 복사 대입 연산자 | const T& | 기존 객체 덮어쓰기 | 기존 자원 해제 후 복제 |
| 이동 대입 연산자 | T&& | 기존 객체 덮어쓰기 | 기존 자원 해제 후 이전 |