일련의 연산자(operator)와 피연산자(operand)를 말한다.

  • 피연산자로부터 값을 계산
    3 + 5, x * y, a == b, ptr + 1
  • 객체나 기능을 지정
    x, *p, func, arr[i]
  • Side Effect를 생성하는
    x = y, ++i, vec.push_back(10), cout << "H"

모든 코드를 표현식이라고 칭한다.


Value Category

모든 C++ 표현식은 두가지 독립적인 속성을 가진다.
타입과 값 범주(value category).

value category는 Have identity(identity)Can be moved from(move)의 여부로 구분된다.

(Can move)값 이동이 가능하다 : m

 //prvalue 10을 예를 들면
 int a = 10; // 10 대입됨
 new int(10); // 10으로 새로 생성

(Can't move)값 이동이 불가능하다. : M

 //lvalue a를 예를 들면
 Node a = {};
 Node b = a; // 복사 생성자가 발생
 new Node(a); // 복사 생성자 호출

(Have id)액세스 주소가 있다. : i

  int a = 10; // 언제든지 &a로 주소값 호출 가능
             // a는 lvalue

(Haven't id)액세스 주소가 없다. : I

 5 + 3; // 임시 계산값 지나면 파기됨
         // 따로 저장하지 않는 이상 이후에 접근할 수 없는 표현식

C++11 이전엔 lvalue, rvalue 뿐이었지만 이후로 세분화 되었다.

Primary category

  • xvalue(im) : 액세스 주소 있음, 값 이동 가능 표현식
    표현식이 끝나면 사라짐, std::move가 대표적

    string s = "hello";     // "hello" = lvalue
                        // s = lvalue
    string&& r = std::move(s); // std::move = xvalue
  • lvalue(iM) : 액세스 주소 있음, 값 이동 불가능 표현식
    이름이 있는 변수, 배열 요소, 참조, 포인터 역참조
    주소 연산자&를 쓸 수 있음.
    메모리 공간을 점유하고 있어서 표현식이 끝나도 사라지지 않는다.

    int a = 10; // a는 lvalue
    int& b = a; // b는 lvalue
    int* p = &a; // &a는 prvalue
  • prvalue(Im) : 액세스 주소 없음, 값 이동 가능 표현식
    순수 rvalue, 객체/값의 초기값을 만드는 표현식

    int x = 5 + 3;     // 5 + 3은 prvalue
    string("hi");     // string("hi")는 prvalue
    "hi";             // "hi"는 lvalue
                   // 문자열 리터럴은 생략되어지는 변수가 있음

    요즘은 복사 생략(Copy Elision)이 의무화 되면서 prvalue는 임시 객체라기보다
    레시피라고 보는게 맞다. A a = A();에서 A() 임시객체를 복사하는게 아니라
    A()를 보고 a의 위치에 바로 객체를 만든다.

  • Mixed category

    • glvalue(i) : lvalue $\cup$ xvalue
      액세스 주소가 있는 표현식
    • rvalue(m) : prvalue $\cup$ xvalue
      값 이동 가능 표현식
      이름이 없고 주소를 취할 수 없는 임시적인 표현식
      연산 중간 결과로 잠시 존재했다가 표현식이 끝나면 소멸하는 일회성 데이터
  • decltype : 표현식의 타입을 그대로 가져오는 키워드
    auto와 다르게 constreference, rvalue여부까지 유지함

포인터는 Value Category 시스템보다 낮은 레벨(물리적 주소)에서 동작한다.

포인터는 대상이 lvalue인지 rvalue인지 따지지 않고,
오직 메모리 주소를 가질 수 있는 존재인가만 따진다.

하지만 rvalue(임시 객체)의 주소를 직접 따는 것(예) &10)은 문법적으로 금지되어 있어서,
결과적으로 포인터는 주로 lvalue를 가리키게 된다.


lvalue 참조 (&)

이름이 있는 변수의 메모리 주소에 별명을 붙인다.

lvalue 참조lvalue만 받는다.


rvalue 참조 (&&)

사라질 운명인 임시 객체의 메모리 주소를 붙잡아 수명을 연장하거나
내부 자원을 다른곳으로 이동 시킬 수 있게 한다.
값을 복사하지 않고 이동 시킨다.

rvalue 참조는 구문이 끝나면 없어지는 임시 표현식인prvalue
xvalue표현식의 return값(std::move())을 받을 수 있다.

int&& r = 10; 에서 r의 타입은 rvalue 참조이지만,
r은 이름이 있으므로 lvalue이다.

그래서 func(r)을 호출하면 func(int&&)가 아닌 func(int&) 버전이 호출된다.
이를 rvalue로 다시 바꾸려면 std::move(r)이 필요하다.

다만, const T& (상수 lvalue 참조)는 예외적으로 rvalue를 바인딩할 수 있다.


Universal Reference(만능 참조, T&&)

템플릿이나 auto를 만나 형식 추론이 일어날 때 &&는 만능 참조가 된다.

template <typename T>
void func(T&& param) // 만능 참조
{

}

funclvalue를 넣으면 Tint&가 되고, int& &&int&가 된다.
funcrvalue를 넣으면 Tint가 되고, int&&int&&가 된다.

lvalue, rvalue를 모두 받을 수 있어서 만능 참조라고 불린다.


만능 참조를 사용하는 move를 사용해보면

vector<int> v1 = {1,2,3};
vector<int> v2 = std::move(v1);

v1의 소유권을 넘기고 v1은 해제된다.
v1의 해제는 move가 해주는것 인가?

move를 직접 구현한 코드, std::move와 내부적으로 똑같다.
구조체가 모든 타입에 대응할 수 있게 부분 특수화를 진행한다.


template <class T>
struct m_remove_reference 
{
    using m_type = T;
    using m_Const_thru_ref_type = const T;
};
template <class T>
struct m_remove_reference <T&> {
    using m_type = T;
    using m_Const_thru_ref_type = const T&;
};

template <class T>
struct m_remove_reference <T&&> {
    using m_type = T;
    using m_Const_thru_ref_type = const T&&;
};

template <class T>
using m_remove_reference_t = typename m_remove_reference<T>::m_type;

template <typename T>
m_remove_reference_t<T>&& Move(T&& t)
{
    return static_cast<m_remove_reference_t<T>&&>(t);
}

아래 상황에 사용해보자.

int* v1 = new int[3]{1,2,3};
int* v2 = Move(v1);

v1이 스택에서 주소가 0x1234이라면,
v2Move(v1)의 결과로 0x1234를 복사받는다.
하지만 Heap에는 {1,2,3}이 그대로 존재한다.

이 경우에는 이동 생성자를 사용하는 구조를 사용해야 한다.
원시 포인터는 주소값을 가지는 기본 자료형이기 때문에
이동 생성자나 대입 연산자가 호출되지 않는다.

move자체는 형 이동을 그대로 하게 해줄 만능 참조를 이용할 뿐이지
nullptr 해주거나, 자원할당 해제를 해주지 않는다.

이동 생성자, 대입 연산자에서 해제를 구현해 줘야한다.

class A
{
public:
    int* a = nullptr;
    A() 
    {
        a = new int;
        *a = 3;
        cout << "A Constructor\n";
    }

    ~A()
    {
        delete a;
        cout << "A Destructor\n";
    }

    A(A&& other) noexcept
    {
        this->a = other.a;
        other.a = nullptr;
        cout << "A Copy Constructor\n";
    }
    A& operator=(A&& other) noexcept // 컴파일러가 noexcept없으면 데이터 안전성을 위해
                                    // 이동 대신 복사를 선택하기도 한다.
    {
        if (this != &other)
        {
            delete this->a;
            this->a = new int;
            this->a = other.a;
            other.a = nullptr;
        }
        cout << "A Move Assignment Operator\n";
        return *this;
    }
};
A a1;
A a2 = Move(a1);
A Constructor
A Copy Constructor

a2.a 주소: 0000015186B9D050
이동 후 a1.a 상태: NULL

값 이동도 잘되었고, 해제도 잘 되었다.
우리가 알고있는 move의 형태가 되었다.

"나는 포인터를 쓰고 싶어" 라면 스마트 포인터 같은 래퍼 객체를 쓰자


A a1;
A&& _a = Move(a1);    // ⭕, rvalue 참조는 xvalue 표현식을 받을 수 있음
A& _a = Move(a1);    // ❌, lvalue 참조는 xvalue 표현식을 받을 수 없음
A _a = Move(a1);    // ⭕, 이동 생성자 호출

이 과정도 단순히 생각하면 T&& return은 Move로 받아준다.
non-const lvalue reference (A&) 는
rvalue/xvalue 에 바인딩될 수 없다는 문법 규칙에 의해 A&는 컴파일 에러가 발생한다.
A& _a = Move(a1)은 참조 바인딩이고 A _a = Move(a1)은 생성하는 과정이다.
이동 연산자와, 이동 생성자는 참조 바인딩에 관여할 수 없다.


A& _a가 값을 받으려면 xvaluelvalue로 강제 변환하면 된다.

template <typename T>
T& ForceLvalue(T&& rref) // 만능 참조로 xvalue 받기
{
    return rref; // rvalue 참조를 lvalue 참조로 캐스팅하여 반환
}

rref는 이름을 가진 변수로 lvalue가 되고 그대로 리턴하면
lvalue를 return 받는 lvalue 참조가 되므로 문법상 이상한게 없다.

Move(a1)      → A&& (xvalue)
rref          → A&& 타입이지만 표현식은 lvalue
return rref   → A&

하지만 참조가 alias되는걸 생각하면

A& _a = a1;

이 더 자연스럽다.


Perfect forwarding

원본이 가진 value category를 변화없이 그대로 전달한다.

함수 내부로 들어온 인자는 이름을 가진 변수가 되기 때문에
rvalue를 받았더라도 함수 내부에서는 lvalue로 취급된다.
그냥 넘기면 무조건 복사가 발생해서, rvalue의 이점을 살릴 수 없다.

template <typename T>
void func(T&& param) // 만능 참조
{
    RealFunction(std::forward<T>(param));
}

여기서 std::forward<T>()는 조건부 캐스팅 도구로
rvalue였다면 rvalue로, lvalue였다면 lvalue로 만드는 핵심 역할이다.

항목 T* 포인터 T& lvalue 참조 T&& rvalue 참조
value category lvalue lvalue lvalue
가리킬 수 있는 대상 주소 있는 객체 lvalue rvalue (prvalue/xvalue)
rvalue를 대상으로 가능?
lvalue를 대상으로 가능?
재바인딩 가능? ⭕ (포인터 이동 가능)
nullptr 가능?
임시 객체 수명 연장 ❌ (const&는 가능) ⭕(이름이 붙는 순간 수명 연장)
포인터/참조 자체는 둘 다 lvalue이다.

포인터는 value category와 무관, 주소가 있는 실제 객체만 가리킨다.
포인터 역참조와 참조는 둘 다 항상 lvalue 표현식을 만든다.


Casting

연산자(operator)로, 변수의 타입을 다른 타입으로 변환하는 것.

형변환 없이 그냥 대입하게 되면 암시적 형변환이 일어난다.
타입에 맞게 데이터를 변형만 한다.

int a = 'a';
int a = L'a';
enum WeaponType {Sword, Gun};
int a = Sword;
int a = true;
double a = 1.0F;

명시적으로 형변환해주기 위해서
기존 C에서는 ()를 이용해 캐스팅한다.

int a = 0;
float b = 5.5;
a = (int)b;
// a는 5

C 캐스팅은 타입 검증 개념이 없고 캐스트가 뭘 의미하는지 구분할 수 없다.

또한 메모리 레이아웃이 호환된다는 가정하에 그냥 동작하고,
런타임 안전장치가 없다.

C++에서 C 스타일 캐스팅을 사용하게 되면

  • cosnt_cast
  • static_cast
  • static_cast + const_cast
  • reinterpert_cast
  • reinterpret_cast + const_cast
    순으로 모두 시도해본다.

이 경우 의도하지 않는 캐스팅까지 해버린다 라는 단점도 추가된다.

C++에는 C++만의 캐스팅이 권장된다.


static_cast

변환의 안전성을 보장하기 위해 _컴파일 타임에 타당성을 검사_한다.

논리적으로 연관된 타입 간의 변환에 주로 사용되고
컴파일 타임에 동작하니 런타임 오버헤드가 없지만 안전성을 100% 보장하진 않는다.
일반 자료형들은 전부 안전하게 캐스팅 할 수 있지만, 객체 구조 특히 상속 관계에서 위험할 수 있다.


const_cast

타입의 const, volatile을 추가하거나 제거한다.
원래 cosnt가 아닌 객체에 사용하는건 논리적 문제가 있지만 정의된 행동이다.

void Print(const Player& p);

Player player;
Print(player);

void Print(const Player& p)
{
    Player& mutablePlayer = const_cast<Player&>(p);
    mutablePlayer.hp -= 10; // 논리적 문제는 있지만 미정의 행동은 아님
}

하지만 원래부터 const로 타입 정의하게 되면 메모리가 read-only영역에 있을 수 도 있다.
이 경우 const_cast를 이용해 수정하게 되면 터질 수도(read-only에 있다면), 안 터질 수도 있다.

const Player boss;
Player& p = const_cast<Player&>(boss);
p.hp -= 10; // 미정의 행동

reinterpret_cast

서로 연관이 없는 타입 간의 비트 단위 재해석을 수행한다.
포인터나 참조를 다른 타입 포인터나 참조로 변환할 수 있다.

컴파일러는 아무것도 검사하지 않고 변환만 해준다.
엔디안이나 메모리 정렬등을 직접 책임져야 한다.

int* p =new int(65)
char* ch = reinterpret_cast<char*>(p);

struct Packet { char header[4]; int value; };
char buffer[8];
Packet* pkt = reinterpret_cast<Packet*>(buffer);

char[4] + int == char[8] 이므로 변환 가능함.

struct FStat
{
public:
    double MaxHP = 0.0f;
    double Attack = 0.0f;
    double AttackRange = 0.0f;
    double AttackSpeed = 0.0f;
    double MovementSpeed = 0.0f;

    FStat operator+(const FStat& other) const
    {
        const double* const ThisPtr = reinterpret_cast<const double* const>(this);
        const double* const OtherPtr = reinterpret_cast<const double* const>(&other);

        FStat Result;
        double* ResultPtr = reinterpret_cast<double*>(&Result);
        int StatNum = sizeof(FStat) / sizeof(double);
        for (int i = 0; i < StatNum; ++i)
        {
            ResultPtr[i] = ThisPtr[i] + OtherPtr[i];
        }

        return Result;
    }
};

double멤버 타입만 있는 FStat구조체를 double*포인터로 바꾸어 사용하는 모습.
구조체를 배열처럼 취급해 자동화 했다.
이 경우 컴파일러가 for문을 최적화할 수 있고 캐시 지역성을 높일 수 있다.
대량의 같은 타입이 있는 구조체에서 사용할 수 있다.

코드 가독성이 떨어지고 상속이라도 사용하게 되면 padding, vptr 등 골치아파진다는 점이있다.


dynamic_cast

런타임 타입 정보를 이용해 객체를 다른 타입으로 안전하게 변환한다.

vptrvtableRTTI 정보로 접근해 타입 정보를 확인한다 (vtable에 RTTI 타입 객체가 들어있다.)
캐스팅 실패 시 nullptr를 반환, 참조 캐스팅 실패 시 std::bad_cast예외를 발생 시킨다.
vtable이 없는 클래스에도 사용 가능하지만 그런 경우 타입을 런타임에 확일 할 필요가 없다.


Down casting

상속 구조에서 캐스팅은 두가지로 나뉜다.
상위 타입을 하위 타입으로 캐스팅하는 경우 Down casting이라고 한다.
상위 타입 + 상위 타입 객체 → 하위 타입 : this사용 시, 미정의 동작 발생
상위 타입 + 하위 타입 객체 → 하위 타입 Down casting

class A { public: virtual ~A(){}}
class B : public A {public: int a = 9; ~B(){}}

A* a = new A();
B* b = dynamic_cast<B>(a);
b->a;     // 초기화하지 않은 영역 접근
        // 미정의 동작

A* a = new B();
B* b = dynamic_cast<B>(a);

단일 상속 구조에서는 항상 시작주소가 같다.

A* a = new B();
cout << (void*)a << '\n';
B* b = dynamic_cast<B*>(a);
cout << (void*)b<< '\n';
0000025FBFC95200
0000025FBFC95200

Base* ptr = new Base()

Base* ptr = new Derived()

시작주소가 같더라도 객체의 실제 타입이 다운캐스팅하려는 타입이 아닐 수 도 있기 때문에
type_info를 들려서 타입을 확인해 주어야한다.


Up casting

하위 타입 객체를 상위 타입으로 캐스팅하는 경우를 Up casting이라고 한다.

B* b = new B();
A* a = dynamic_cast<A*>(b);

Up casting은 내부에 서브오브젝트로 A가 이미 존재 중이므로 런타임에 확일 할 필요도 없다.
그냥 레이아웃만 A로 변경하면 된다.

그래서 이 경우는 vtable없이 형변환이 가능하다.
Up casting에는 static_cast 사용이 권장된다.


원래 메모리 레이아웃이 B였으므로 A로 변환 될 때 데이터 소실이 있다.

class A
{
public:
    int a = 0;
    A() { }
    virtual ~A() { }
};
class B : public A
{
public:
    int b = 0;
    int* c = nullptr;
    B() : b(0), c(new int[10]) { }
    ~B() { }
};

B* b = new B();
A* a = dynamic_cast<A*>(b);

delete a;
메모리 릭
Dumping objects ->
{167} normal block at 0x0000024B044C4690, 40 bytes long.
 Data: <                > 0A 00 00 00 CD CD CD CD CD CD CD CD CD CD CD CD 
Object dump complete.

RTTI(Run-time type information)

런타임에 객체의 실제 타입을 알아내는 기능이다.

  • typeid()
    • 가상 함수가 없는 경우 : 컴파일러가 컴파일 타임의 타입을 반환
    • 가상 함수가 있는 경우 : vtabletype_info객체를 반환.
  • type_info : 타입정보를 담고 있음 name(), hash_code()

이러한 구조를 이용한 캐스팅이 dynamic_cast이다.
하지만 dynamic_castvptr포인터를 통해 탐색해야 하므로,
성능 예측이 어렵고 캐시 비효율 적이다.

if (auto* player = dynamic_cast<Player*>(obj))
{
    player->TakeDamage(10);
}
else if (auto* enemy = dynamic_cast<Enemy*>(obj))
{
    enemy->Stun();
}

enum값을 둬서 미리 타입을 정의해 런타임에 불러와 사용한다거나.

enum class ClassType
{
    A,
    B
};

if(obj->GetClassType(A))
{
    obj->AFunc();
}
else
{
    obj->BFunc();
}

타입 분기를 사용하지 않게 Component구조를 사용한다.

if (auto* dmg = obj->GetComponent<Damageable>())
{
    dmg->TakeDamage(10);
}

if (auto* stun = obj->GetComponent<Stunnable>())
{
    stun->Stun();
}

표현식과 캐스팅은 메모리 상의 데이터 정체성(Identity)과 이동 가능성(Move)을
값 범주(Value Category)로 엄격히 구분하고,

이를 물리적(reinterpret/static) 또는 논리적(dynamic/const) 관점에서 재해석하여
자원의 소유권 이전과 타입 안전성을 보장하는 핵심 메커니즘이다.


문제

20문제
https://gemini.google.com/share/d8157131b4dc

내용에 대한 질의나, 수정 요청은 저에게 큰 도움이 됩니다.

'C\C++' 카테고리의 다른 글

Scope  (0) 2026.04.01
Object  (0) 2026.04.01
포인터  (0) 2026.04.01
Virtual  (0) 2026.02.03
This 포인터  (0) 2026.02.03