본문 바로가기

프로그래밍 언어/C++

value category의 서로 다른 유형간 변환

반응형

개요

computation expression에서 피연산자는 연산자에게 특정 value category 타입의 value를 요구한다. 연산자가 가지고 있는 value category와 요구 사항이 맞는다면, 문제없이 사용되지만, 다른 종류의 value category를 갖고 있다면, value category에 적용되는 conversion 규칙에 따라, 피연산자가 요구하는 value category 타입으로 변경하게 된다.

모든 value는 lvalue, xvalue, prvalue 중 하나다. 서로 다른 타입으로 변환은 8(2x2x2) 경우 수가 존재한다. 일부 변환은 암묵적으로 변환할 수 있고, 일부 변환은 명시적 구문을 사용해 변환할 수 있고, 일부 변환은 몇 단계를 거쳐 변환해야 한다.

스펙 문서에서 암묵적 변환에 대해서만 고유한 이름을 부여한다.

lvalue과 xvalue를 묶어 glvalue라고 부르고, xvalue과 prvalue를 묶어 rvalue라고 부른다.

conversion의 3가지 구현 방법과 미묘함

conversion 의미를 구현 방식 관점에서 조금 깊게 고민해보자.

conversion의 첫번째 구현 방법은 변환하려는 대상을, 그대로 두고 관점만 바꾸는 변환이다.
conversion의 두번째 구현 방법은 목적 대상을 새로 만들고, 소스 정보로 덥혀쓰기하는 변환이 있다. 첫번째 방법으로 불가능한 모든 변환을 두번째 방법으로 구현 가능하다.
conversion의 세번째 구현 방법은 목적 타입이 value 자체일 때, 구현 방법으로 prvalue로 변환 과정에서 작동한다. 컴파일러는 메모리가 아닌 위치로 value를 복사하는데, 복사 자체가 변환이다.

변환 구현은 참으로 오묘해서 어떤 방식으로 구분되고 있는지 코딩만으로 구분하기 힘든 경우도 있다. 코딩 구문과 무관하게 별도 조항을 추가해 자주 사용되는 일부 변환을 스펙에서 허용한다. 예를 들어 const & 타입으로 변환이 그렇다. 심블 &는 그대로 두고 관점만 바꾸는 변환을 의미하는데, prvalue를 소스로 받으면 목적 대상을 생성하는 두번째 구현 방법을 스펙이 허용한다.

prvalue는 순수한 value다. 일반 함수나 연산자 함수의 파라미터 타입로 rvalue 아규먼트를 전달하면, 목적 대상을 새로 만들고 소스 정보를 덥혀쓰기하는 방법으로 변환한다. 새로운 목적 타입을 필요로 하는 두번째 방법외에는 구현할 방법이 없다. 따라서 prvalue를 파라미터로 대응하는 유일한 방법은 심블&를 모두 제거해야 가능하다. C++ 스펙 문서를 자세히 분석해보면 prvalue를 모두 plain type으로 명시한 이유가 여기에 있다. 현 시점에 다르게 표현할 방법도 없다.

대응하는 value category가 서로 다른 경우

앞선 게시글에서 설명한 내용을 다시 한번 깊게 생각해보자.
일반 함수나 연산자 함수를 명시함으로써 아규먼트나 피연산자에게 요구하는 value category를 정의할 수 있다고 했다.

함수나 연산자 함수는 파리미터 타입, 반환 타입을 통해 함수나 연산자가 요구하는 value category를 명시한다. 함수 호출 과정의 아규먼트나 피연산자를 통해, 자신이 갖고 있는 value category로 이러한 요구에 응대하게 된다. 대응하는 두 value category가 같다면, 다음에 언급할 내용을 고려하지 않는다.

두 간극을 해결하는 방법은 크게 두 가지로 나눌 수 있다.
쉽게 생각할 수 있는 첫번째 방법은 함수나 연산자 함수를 피연산자나 아규먼트의 value category 타입에 맞게 overloading하면 된다.
두번째 방법은 표준에서 지원하는 value category간 conversion를 활용하는 것이다. 두번째 방법은 C++에서 만들어진 타입에 특별한 문제가 없다면, 일관되게 적용된다. 자신이 만든 타입에 대해 암묵적으로 제공하는 value category간 conversion 중 일부를 필요를 따라, 사용할 수 없도록 역시 제어할 수 있다.

참고로 우리가 다루고 있는 주제는 서로 다른 타입간 변환이 아니라, 타입은 같은데, value category 유형이 다른 경우의 conversion를 설명한다.

glvalue-to-prvalue conversion

스펙 문서의 lvalue-to-rvalue conversion 용어 설명 과정에서 glvalue에서 prvalue 변환이라고 언급하고, 주석으로 해당 용어가 의미를 정확하게 표현하지 못하고 있다고 언급하고 있다. 따라서 오해의 소지가 없도록, glvalue-to-prvalue 용어를 사용하겠다. gvalue는 lvalue나 xvalue를 의미한다. glvalue-to-prvalue 변환은 따라서 lvalue나 xvalue에서 prvalue로의 암묵적 변환을 의미한다. 세번째 구현 방법에 따라 conversion를 구현한다.

C++ 스펙에서는 타입 변환과 value category 변환까지 광범위하게 정의한 standard conversion를 명시하고 있다. 특히 standard conversion 과정은 4 단계 과정으로 진행되는데, 1 단계에 glvalue-to-prvalue conversion를 명시하고 있다.

여러 단계로 진행하기 때문에 standard conversion sequence라고 부른다.

standard conversion sequence (→ user-defined conversion → standard conversion sequence) 절차를 통해 전체 conversion를 진행한다. user-define conversion 이후에 두번째 standard conversion sequence가 다시 작동한다.

standard conversion sequence를 포함하는 3개 절차를 합해서, implicit conversion sequence라고 부른다.

glvalue-to-prvalue conversion를 그대로 들어내는 코딩을 작성해보자.

특정 value category 타입을 자연스럽게 뿜어낼 소스 타입을 설계해보자.

XValue 클래스의 int 타입으로 변환 연산자를 주목하자. int&&를 반환한다. 즉 int 타입으로 변환될 때, 자연스럽게 xvalue를 반환하도록 한다.

LValue 클래스의 int 타입으로 변환 연산자는 int&를 반환한다. 즉 int 타입으로 변환될 때, 자연스럽게 lvalue를 반환하도록 한다.

PValue 클래스의 int 타입으로 변환 연산자를 int를 반환한다. 즉 int 타입으로 변환될 때, 자연스럽게 prvalue를 반환하도록 한다.

소스 타입에 설정한 변환 연산자를 건드는 순간, 소스의 value category 유형을 출력해준다.

import std;
struct Value {
    int v_{};
    Value(int a) :v_{ a } {}
};

class PRValue:private Value{
public:
    using Value::Value;
    operator int() { std::print("prvalue -> "); return v_; }
};

class XValue :private Value {
public:
    using Value::Value;
    operator int&&() { std::print("xvalue -> "); return std::move(v_); }
};

class LValue :private Value {
public:
    using Value::Value;
    operator int& () { std::print("lvalue -> "); return v_; }
};

소스 value category 유형에서 목적 value category 유형을 명시할 함수 타입을 설계해보자. 함수가 호출되면 value category 유형을 출력하고 마지막에 "OK" 문구를 출력해준다.

void toLValue(int&)  { std::println("lvalue: OK"); }
void toXValue(int&&) { std::println("xvalue: OK"); }
void toPRValue(int)  { std::println("prvalue: OK"); }

목적 value category 유형으로 변환이 실패했다는 사실을 확인하는 코딩은 overload resolution를 항상 성공하도록 유도하지만, 후보군 중에서 기존 함수들보다 체급이 약한 함수를 추가해야 한다. 그러면서도 기존 변환 연산 함수를 건들어서 소스 출력을 출력해줄 함수가 딱 필요하다. 바로 long int 타입이다. prvalue를 받아내도록 설계했음으로, 아래에서 설명하겠지만, 다른 유형의 value category를 자연스럽게 흡수한다.

void toLValue(long)   { std::println("lvalue: FAIL"); }
void toXValue(long)   { std::println("xvalue: FAIL"); }
void toPRValue(long)  { std::println("prvalue: FAIL"); }

glvalue-to-prvalue conversion를 아래 코딩으로 테스트할 수 있다. value category의 3 유형 중에서 다른 두개의 value category은 prvalue로 암묵적 변환을 허용한다.

int main(){ 
   LValue  lv{ 10 };
   XValue  xv{ 10 };

   std::println("glvalue-to-prvalue conversion test code");
   toPRValue(lv);
   toPRValue(xv);

   return 0;
}
[출력 결과]
glvalue-to-prvalue conversion test code
lvalue -> prvalue: OK
xvalue -> prvalue: OK

temporary materialization conversion

비교적 최근 버전에서 추가된 value category간 변환이다. prvalue-to-xvalue 변환이다.해당 변환은 prvalue가 임시 객체, 즉 단기 수명을 갖고 있는 변수의 value로 사용될 때, 작동하는 변환이다. 단기 수명 객체는 스펙의 lifetime expansion 조항에 따라, 생각보다 오랜 기간 장수를 누릴 수 있다. 이 부분은 조금 난이도가 요구되는 조항이고, C++23에서 크게 개선된 상태다. 목적 타입을 생성후 구현하는 두번째 구현 방법으로 conversion한다.

테스트 코딩은 prvalue로 출발해 다른 value category로 변환을 시도할 것이다. 예상대로라면 prvalue에서 lvalue로는 실패해야 한다.

int main() {
    PRValue pr{ 10 };

    std::println("temporary materialization conversion test code");
    toLValue(pr);
    toXValue(pr);

    return 0;
}
[출력 결과]
temporary materialization conversion test code
prvalue -> lvalue: FAIL
prvalue -> xvalue: OK

위에 언급하지 않는 value category간 conversion

위에서 언급한 conversion의 3가지 유형 관점에서 lvalue과 xvalue간 변환을 고민해보자.

lvalue에서 xvalue로 변환

연산에 참여한 xvalue는 unspecified value state를 갖기 때문에, 이후에 더 이상 사용하지 않거나, xvalue 상태를 조사한 후 사용해야 한다. 이런 이유로 lvalue에서 xvalue 변환은 암묵적으로 허용하지 않고, static_cast나 std::move 함수를 통해 명시적으로 수행하는데, conversion의 첫번째 유형인 제자리 변환 방식을 따른다. lvalue를 xvalue처럼 만드는 효과를 갖지만, 완전한 xvalue는 아니다. 특히 lifetime 속성은 전혀 다르다.

xvalue에서 lvalue로 변환

xvalue는 단기 lifetime를 갖는다. 완전한 xvalue는 조만간 소멸할 날짜를 받고 사는 시한부 인생이다. xvalue에서 lvalue로 변환을 conversion의 첫번째 유형인 제자리 변환 방식으로 구현한다면, 소멸된 메모리 위치에 lvalue가 살게 된다.

결국 두번째 방법으로 구현할 수 밖에 없다. 결국 새로운 타입을 만들어 xvalue 정보를 부어줄 수 밖에 없다. 따라서 첫번째 방식으로 변환을 유도하는 static_cast를 이용한 xvalue에서 lvalue로 변환은 컴파일 오류다.

암묵적인 방법으로는 두 value category간 변환은 불가능하다. 하나는 명시적 구문으로, 하나는 몇 단계 과정을 거쳐 conversion를 수행해야 한다.

관련 코딩 구현

암묵적 변환을 시도하는 아래 코딩은 모두 실패한다.

int main() {
    LValue  lv{ 10 };
    XValue  xv{ 10 };

    toXValue(lv);
    toLValue(xv); 
    return 0;
}
[출력 결과]
lvalue -> xvalue: FAIL
xvalue -> lvalue: FAIL

명시적 구문이나 몇 단계 과정을 거쳐 아래 코딩처럼 xvalue과 lvalue간 변환을 수행할 있다.

int main() {
    LValue  lv{ 10 };
    // 첫번째 변환 방법으로 구현.
    std::print("lvalue -> std::move(lvalue) -> xvalue :");
    toXValue(std::move(lv.operator int &()));

    XValue  xv{ 10 };
    // 첫번째 변환 방법을 강제로 유도한 구문
    // error: non-const lvalue reference to type 'int' cannot bind to a temporary of type 'int'
    // toLValue(static_cast<int&>(xv.operator int &&()));

    // 두번째 변환 방법으로
    std::print("xvalue -> newobject -> lvalue :");
    int obj_from_xvalue = xv;
    toLValue(obj_from_xvalue);

    return 0;
}
[출력 결과]
lvalue -> std::move(lvalue) -> xvalue :lvalue -> xvalue: OK
xvalue -> newobject -> lvalue :xvalue -> lvalue: OK

prvalue에서 lvalue로 변환

마지막으로 prvalue에서 lvalue로 변환을 살펴보자.
prvalue는 단순히 value 자체이고, lifetime 자체도 없고, 메모리 주소도 없다. 따라서 무조건 새로운 목적 타입을 생성하고 변환하는 두번째 방법으로 구현할 수 밖에 없다. 따라서 첫번째 방법으로 구현을 유도하는 코딩은 컴파일 오류다.

int main() {
    PRValue  pr{ 10 };
    // 첫번째 변환 방법을 강제로 유도한 구문
    // non-const lvalue reference to type 'int' cannot bind to a temporary of type 'int'
    // toLValue(static_cast<int&>(pr.operator int()));

    // 두번째 변환 방법으로
    std::print("prvalue -> newobject -> lvalue :");
    int obj_from_prvalue = pr;
    toLValue(obj_from_prvalue);
    return 0;
}
[출력 결과]
prvalue -> newobject -> lvalue :prvalue -> lvalue: OK
728x90
반응형