티스토리 뷰
카테고리 없음
다시 읽고 싶은 C++ 값 범주, Rule of Three/Five/Zero, 그리고 noexcept 한 번에 정리
ccc124213131 2025. 8. 24. 23:41728x90
반응형
- Expression에는 두 속성이 있다: 타입(type)과 값 범주(value category)
- 타입은 int, std::string처럼 익숙하죠.
- 값 범주는 표현식이 “끝난 뒤에도 해당 값이 살아남는지”를 가르는 개념입니다.
- 왜 중요할까? 이동 의미론(move semantics)과 완벽한 전달(perfect forwarding) 같은 현대 C++의 핵심 기능을 가능하게 하는 근간이기 때문입니다.
- 비싸게 복사할 필요가 없는 “곧 사라질 값”의 리소스를 그냥 “훔쳐와서” 재사용하면 훨씬 효율적입니다. 이때 “훔쳐와도 되는가?”를 판단하는 기준이 값 범주입니다.
- lvalue vs rvalue (전통적인 구분)
- lvalue: 이름(식별자)이 있고, 여러 번 참조 가능한 지속 객체. 주소를 가질 수 있고, =의 왼쪽에 올 수 있음.
- rvalue: 이름이 없는 임시 값. 표현식이 끝나면 사라지며, 전통적으로 =의 오른쪽에만 올 수 있음.
예시
text
int x = 10; // x: lvalue, 10: rvalue int y = x; // x, y: lvalue
- C++11 이후의 값 범주 세분화 (현대적 분류)
- 큰 축
- glvalue: “이름(identity)이 있는 것들” — 메모리 위치가 특정됨. 하위에 lvalue, xvalue.
- rvalue: “이동 가능한 것들” — 하위에 prvalue, xvalue.
- 기본 3가지
- lvalue (locator value)
- 대부분의 변수, 객체를 가리키는 식(식별자).
- 특징: 주소를 가질 수 있음(& 사용 가능), 대입문의 왼쪽 가능, 표현식 이후에도 계속 존재. 그래서 보통 복사가 일어남.
- prvalue (pure rvalue)
- 이름 없는 임시 값. 특정 메모리 위치와 직접 연결되지 않음.
- 특징: 주소 불가, 값으로만 존재, 표현식 끝나면 즉시 소멸.
- 예: 산술 연산 결과, 값을 반환하는 함수 호출, 리터럴(문자열 리터럴 제외), 람다 리터럴 자체 등.
- 예시“어차피 사라질 값”이니, 복사 대신 이동으로 리소스를 재사용할 길을 열어 둠.
-
textint result = a + b; // a + b는 곧 사라질 prvalue
- xvalue (eXpiring value)
- “곧 수명이 끝나는 객체”를 가리키는 표현식. 메모리 위치는 있지만, 이제 이동해도 좋다는 표지판이 붙은 상태.
- lvalue처럼 ‘객체’를 참조하지만, rvalue처럼 이동 가능.
- 대표 예: std::move(some_lvalue)의 결과, rvalue 참조(&&)를 반환하는 함수 호출 결과.
- 중요한 포인트: std::move는 “이동”을 실행하는 게 아니라, 인자를 rvalue 참조로 캐스팅해 “이동 대상”임을 표시해 줄 뿐.
- 예시
-
textstd::string a = "hello"; std::string b = std::move(a); // a는 xvalue로 간주, b가 a의 리소스를 가져감(이동)
- lvalue (locator value)
- 한눈에 요약
- lvalue: 이름 O, 이동 기본적으로 X, 지속 객체. 예) 변수 var, arr
- xvalue: 이름 O, 이동 O, 소멸 예정. 예) std::move(var)
- prvalue: 이름 X, 이동 O, 임시 순수 값. 예) 42, a + b
- 관계
- glvalue = lvalue + xvalue (이름이 있는 것들)
- rvalue = prvalue + xvalue (이동이 가능한 것들)
- 기억법: “lvalue는 안정적인 내 것, rvalue는 곧 사라질 남의 것(그러니 훔쳐도 된다)”
이 구분이 있어야 컴파일러가 복사할지, 이동할지, 어떤 최적화를 할지 결정할 수 있습니다. Rule of Three/Five/Zero 같은 규칙을 이해하는 바닥 체력도 여기서 나옵니다.
- Rule of Three / Five / Zero
Rule of Three (전통 규칙)
- 요지: 소멸자, 복사 생성자, 복사 대입 연산자 중 하나라도 직접 정의했다면, 보통 나머지 둘도 직접 정의해야 안전합니다.
- 왜? 얕은 복사 문제 때문.
- 컴파일러가 자동 생성하는 복사 생성자/복사 대입은 멤버를 그대로 복제(얕은 복사). 포인터 같은 리소스를 “주소만 복사”하면, 한쪽이 소멸되며 delete를 하면 다른 쪽은 댕글링 포인터가 되고, 나중에 또 delete로 터지는(double free) 재앙 발생.
- 해법: 깊은 복사 구현
- 소멸자(~MyClass()): 내가 소유한 리소스를 확실히 해제.
- 복사 생성자(MyClass(const MyClass&)): 남의 리소스를 그대로 공유하지 말고 “동등한 새 리소스”를 만들어 소유.
- 복사 대입 연산자(MyClass& operator=(const MyClass&)): 내 기존 리소스 해제 후, 상대 기반으로 새 리소스를 만들어 소유.
Rule of Five (현대 규칙)
- 요지: 이동 의미론 이점까지 챙기려면 위 3개에 더해 “이동 생성자”와 “이동 대입 연산자”까지 총 5개를 명시적으로 다뤄야 함.
4) 이동 생성자(MyClass(MyClass&&)): rvalue로부터 리소스 소유권을 훔쳐옴.
5) 이동 대입 연산자(MyClass& operator=(MyClass&&)): 내 기존 리소스 해제 후, rvalue로부터 소유권을 훔쳐옴. - 왜 꼭 5개를 챙겨야 하나?
- C++은 사용자가 소멸자/복사 관련 멤버를 직접 정의하면, 컴파일러가 “괜히 암시적 이동을 만들어 주다가 문제 날까봐” 묵살하는 경우가 많습니다. 즉, Rule of Three만 지키면 이동 최적화가 비활성화될 수 있음.
- 해결: 다섯 개를 전부 명시적으로 정의하거나, 가능한 것은 =default, 금지는 =delete로 의도를 분명히 하자.
Rule of Zero (가장 이상적 👍)
- 요지: 클래스가 리소스 소유권을 직접 다루지 않는다면, 다섯 특별 멤버 중 아무것도 정의하지 말자.
- 방법: 리소스 관리는 “전담 클래스”에게 넘겨라.
- char* 대신 std::string
- new[] 대신 std::vector<T>
- new/delete 대신 std::unique_ptr<T>
- 이들은 내부에서 Rule of Five를 잘 지켜서 구현되어 있음. 우리 클래스는 이런 “똑똑한 멤버”만 가지면 특별 멤버를 직접 만들 필요가 없다.
- noexcept는 뭐고, 왜 필요한가?
- 한 줄 정의: “이 함수는 절대 예외를 던지지 않는다”는 강한 약속.
- 왜 도입됐나(C++11)?
- 컴파일러가 더 공격적인 최적화를 하게 해 주기 위해서. 예외 가능성이 있으면 스택 해제 코드 같은 부가 비용이 붙습니다. noexcept면 그런 코드 생략 가능.
- 약속을 어기고 예외를 던지면? 표준은 std::terminate로 즉시 종료. 그만큼 강력한 약속.
- 특히 이동 연산자에서 왜 중요?
- 표준 컨테이너는 “강력한 예외 보장(연산 실패 시 상태를 연산 전으로 온전히 롤백)”을 지키려 합니다.
- vector가 재할당(reallocation)할 때 내부 원소들을 새 버퍼로 옮깁니다. 이때 이동이 예외를 던질 수 있으면, 중간 실패 시 기존/새 버퍼가 반쯤 옮겨진 “손상 상태”가 될 수 있죠.
- 그래서 컨테이너는 정책적으로 이렇게 행동합니다:
- “T의 이동 생성자가 noexcept가 아니면, 위험하니 이동 대신 복사하겠다.”
- 복사는 원본을 남겨두므로 중간에 실패하면 새 버퍼를 버리고 원래 상태를 유지하기 쉬워서 강한 보장을 지키기 좋습니다. 하지만 느립니다.
- 반대로 이동 생성자가 noexcept면, 컨테이너는 안심하고 빠른 이동을 사용합니다.
- 표준 라이브러리는 std::is_nothrow_move_constructible_v<T> 같은 타입 특성으로, 컴파일 타임에 “이 타입을 안전하게(예외 없이) 이동할 수 있는지” 확인하고 이동할지 복사할지 결정합니다.
정리하자면: 이동 생성자/대입 연산자는 가능하면 noexcept로 선언하세요. 그래야 std::vector 같은 컨테이너가 복사 대신 이동을 써서 성능을 뽑아줍니다.
- 코드 스니펫: 이동 연산자에 noexcept 붙이기
text
struct NonCopyableMovable { NonCopyableMovable(NonCopyableMovable&&) noexcept = default; NonCopyableMovable& operator=(NonCopyableMovable&&) noexcept = default; // 필요에 따라 복사 금지 NonCopyableMovable(const NonCopyableMovable&) = delete; NonCopyableMovable& operator=(const NonCopyableMovable&) = delete; // 직접 리소스를 소유하지 않는다면, 소멸자/특별 멤버를 굳이 만들 필요 없음 (Rule of Zero) // ~NonCopyableMovable() = default; };
- 위에서 핵심은 이동 생성자/대입 연산자에 noexcept를 붙였다는 점입니다.
- 복사를 금지하고 이동만 허용하는 패턴이라면 이렇게 의도를 분명히 하는 것이 좋습니다.
- 반대로, 리소스를 직접 소유하지 않는 타입이라면 아예 아무것도 정의하지 않는 것이 최선입니다.
마무리 포인트
- 값 범주는 “복사할지, 이동할지”를 컴파일러가 판단하는 신호체계다.
- std::move로 xvalue를 만들어 “훔쳐가도 된다”는 의사를 표시한다.
- 리소스를 직접 소유/관리하면 Rule of Five를 의식적으로 설계하자. 가능하면 Rule of Zero로 가자.
- 컨테이너 성능을 살리고 강력한 예외 보장을 유지하려면 이동 연산자에 noexcept를 붙여라.
-------------------------------------------------------------------------------------------------------
💡 C++ 값 범주 한 눈에 보는 다이어그램

조금 더 단순화하면 이렇게 기억하세요:
- lvalue: 안정적으로 계속 남는 값 (내 거)
- xvalue: 이름은 있지만 곧 사라질 값 (내 거지만 당장 치워버릴 거니까 가져가도 됨)
- prvalue: 이름도 없고 금방 사라질 값 (남의 거, 가져다가 쓰면 됨)
🍎 사과 바구니 비유
- lvalue → 내 집 냉장고 속 사과 🍎
- 언제든 꺼내 먹을 수 있고, 계속 보관 가능
- 여러 번 참조할 수 있음
- prvalue → 슈퍼마켓 시식 코너 사과 조각 🍏
- 지금 한 입 먹고 없어짐
- 이름도, 보관도 없음
- xvalue → 친구가 "이거 다 먹고 버릴 거니까 가져가" 하고 준 사과 상자 🍐
- 아직 이름은 있지만 곧 소멸될 예정
- “어차피 버릴 거니” 소유권을 가져와서 쓰면 효율적
이 감각을 잡아두면, 왜 이동 연산이 필요한지 바로 이해됩니다.
🚀 Rule of Five/Noexcept 연결 비유
- 복사 (lvalue 복사) = 내 집 사과 그대로 하나하나 새로 사와서 또 채워 넣는 것 (비용 큼)
- 이동 (xvalue, prvalue 이동) = 남이 쓰던 사과 상자를 그냥 내 집으로 가져오는 것 (싸고 빠름)
- noexcept = 친구가 “이 상자 옮기는 중에 절대로 문제 안 생길 거야”라고 보증해주는 약속 → 컨테이너(std::vector)가 안심하고 ‘이동’을 선택할 수 있음
👉 이렇게 다이어그램 + 일상 비유 두 가지를 같이 기억하면, 값 범주 → Rule of Five → noexcept 필수 까지 한 줄로 이어져서 머리에 쏙 들어올 겁니다.
728x90
반응형
댓글