프로그래밍 언어/Effective Modern C++

[Effective Modern C++] 항목 23 ~ 30 : 이동, 완벽 전달

우향우@ 2024. 11. 23. 23:10

개요: LValue, RValue

이부분은 책의 내용은 아니고 LValue와 RValue에 대한 개념정리를 하면 도움이 될 것 같아 나름대로 해보았다.

LValue와 RValue는 2개의 개념으로 분리할 수 있다.

1. 한 표현식은 LValue또는 RValue이다.

2. 레퍼런스 타입에는  LValue 레퍼런스, RValue 레퍼런스가 있다.

이 두개는 연관되어 있지만 별개의 개념이다.

예시) RValue 레퍼런스 타입의 변수 하나로 이루어진 표현식은 LValue이다.

 

레퍼런스 타입에는  LValue 레퍼런스, RValue 레퍼런스가 있다.

int&는 LValue 레퍼런스 타입이고

int&&는 RValue 레퍼런스 타입이다.

 

한 표현식은 LValue또는 RValue이다.

한 변수의 이름 하나로 이루어진 표현식은 변수의 타입과 상관없이 무조건 LValue이다.

리터럴 하나로 이루어진 표현식은 RValue이다.

리턴 타입이 LValue 레퍼런스인 함수의 호출 표현식은 LValue이고
리턴 타입이 그외인 경우 함수의 호출 표현식은 RValue이다.

 

그래서 LValue, RValue로 뭘할 수 있느냐

LValue, RValue 레퍼런스 타입 매개변수 오버로딩 함으로써 각 경우에 대한 분기를 만들 수 있다.

void f(int& a); // 1번

void f(int&& a); // 2번

함수가 위와 같이 존재할때 f에 LValue 표현식을 넣으면 1번이, RValue 표현식을 넣으면 2번이 호출된다.
이때 1번이 없을때는 f에 RValue 표현식만 넣을 수 있지만

2번이 없을때는 f에 L,R 표현식 둘다 넣을 수 있으며 1번이 호출된다.

 

 

항목 23: std::move, std::forward

std::move(표현식) 은 표현식을 넣으면 해당 표현식의 레퍼런스를 RValue 레퍼런스 타입의 Rvalue 표현식으로써 리턴한다.

 

std::forward은 템플릿 보편 참조 매개변수의 T를 템플릿 인자로 넘겨주면서 보편 참조 레퍼런스를 매개변수로 넘겨주면 템플릿 함수에 LValue 표현식을 넣었던 경우에는 LValue 레퍼런스 타입의 LValue 표현식을 리턴하고

RValue 표현식을 넣었던 경우에는 RValue 레퍼런스 타입의 RValue 표현식을 리턴한다. 

상세한 구현과 원리는 항목 28에서 설명한다.

 

항목 24: 보편 참조

T&&에서 T가 템플릿 클래스 인자의 원형이거나 auto이면 T&&는 보편참조이다.

보편 참조는 해당 변수를 초기화한 표현식이 LValue면 LValue 레퍼런스, RValue면 RValue 레퍼런스가 된다.

하지만 해당 변수 하나로 이루어진 표현식은 LValue 표현식이다. RValue 표현식으로 생성되었어도 RValue 레퍼런스 타입일뿐 변수 하나로 이루어진 표현식은 무조건 LValue이기 때문이다. 

보편 참조는 forward와 함께쓰면 RValue를 넘겨받았을 경우에만 내부에서 RValue 표현식을 다시 얻도록 할 수 있다.

 

항목 25: RValue 레퍼런스에는 std::move, 보편 참조에는 std::forward

RValue 레퍼런스 타입의 매개변수는 RValue 표현식만 받았으므로 이동을 해도 되거나 하고 싶은 레퍼런스만 넘어온다는 것이 보장된다. 따라서 std::move로 변환하여 이동을 강제한다.

보편 참조는 LValue 표현식을 받았을땐 복사를, RValue 표현식은 이동을 유도하기 위해 std::forward를 사용해야한다.

 

근데 보편 참조 대신 오버로딩 하면 안될까?

class Widget
{
public:
	template<typename T>
	void setName(T&& newName)
	{
		name = std::forward<T>(newName);
	}
}

class Widget
{
public:
	void setName(const std::string& newName)
	{
		name = newName;
	}
    
	void setName(std::string&& newName)
	{
		name = std::move(newName);
	}
}

Widget w;
w.setName("abc");

아래 구문이 실행되면 보편 참조는 const char* &&를 받고 바로 대입한다.

오버로드는 아래것이 호출되며 string 임시객체가 생성되고 이동된다.

효율이 보편 참조가 좋다.

추가로 매개변수가 여러개면 2^n만큼 오버로드 해야된다.

class... Arg는 진짜 보편 참조 밖에 구현 못한다.

 

추가로 RValue 레퍼런스, 보편참조 매개 변수를 값으로 리턴할때는 std::move 또는 forward로 리턴하자.

이동 연산이 적용되어 효율적이다.

Matrix operator+(Matrix&& lhs, const Matrix& rhs)
{
	lhs += rhs;
	return std::move(lhs)
}

 

그렇다면 지역 변수에는 std::move하면 안될까?

 

안된다.

RVO되면 이동이 아니라 아예 비용이 안드는데 std::move가 RVO를 막을 수 있다. RVO가 불가능한 상황이어도 리턴값과 타입 같은 지역변수를 그대로 리턴하면 RValue 표현식으로 자동 변환이 된다. 그냥 두자.

 

 

항목 26: 보편 참조에 대한 오버 로드를 피하자 

template<typename T>
void logAndAdd(T&& name) //1번
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(std::forward<T>(name));
}

void logAndAdd(int idx) //2번
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(nameFromIdx(idx));
}

std::string petName("Darla");

logAndAdd(petName); //1번
logAndAdd("abcd"); //1번
LogAndAdd(1); //2번

short a = 1;
LogAndAdd(a); // 1번 -> 컴파일 에러

템플릿의 보편 참조는 오버로드 선택에서 굉장히 높은 우선 순위를 가지기 때문에 조심해서 사용해야한다.

short는 int로 상향 변환이 필요해서 보편 참조가 더 우선순위가 높아진다.

 

class Person {
public:
    template<typename T>
    explicit Person(T&& n) 
    : name(std::forward<T>(n)) {} //1번
    
	Person(const Person& rhs); //2번
    
private:
    std::string name;
};

Person p1;
Person p2(p1); //1번 호출 -> 컴파일 에러

p1이 const가 아니어서 보편 참조 우선

 

class SpecialPerson: public person {
public:
    SpecialPerson(const SpecialPerson& rhs)
    : Person(rhs)
    { ... }
    
    SpecialPeron(SpecialPerson&& rhs)
    : Person(std::move(rhs))
    { ... ]
};

SpecialPerson도 변환이 필요해 보편 참조 우선

 

항목 27: 항목 26의 해결법

1. 중복적재 포기

이름을 다른걸로 하자

 

2. const T&만 쓰기

이동 연산 이점을 포기하자

 

3. 값 전달 매개변수 쓰기

void f(std::string s)
{
	name = std::move(s);
}

어차피 이동할거면 복사로 일단 받고 안에서 이동하자. 하지만 여전히 비효율적이다.

 

4. 꼬리표 배분

template<typename T>
void logAndAdd(T&& name)
{
    logAndAddImpl(
        std::forward<T>(name),
        std::is_integral<typename std::remove_reference<T>::type>()
    );
}

template<typename T>
void logAndAddImpl(T&& name, std::false_type)
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(std::forward<T>(name));
}

void logAndAddImpl(int idx, std::true_type)
{
    logAndAdd(nameFromIdx(idx));
}

타입과 오버로드로 분기주기때문에 컴파일 타임 분기가 가능함.

하지만 매개변수 따로 추가해야하기에 생성자에는 못씀

 

5. 템플릿 제한

template<typename = typename std::enable_if<조건>::type> //C++11


template<typename = std::enable_if_t<조건>> //C++14

템플릿에 위와 같이 매개변수를 추가하면 조건이 true일때만 오버로드 해소 후보에 오른다.

 

 

class Person {
public:
    template<
      typename T,
      typename = std::enable_if_t<
         !std::is_base_of<Person, std::decay_t<T>>::value
         &&
         !std::is_Integral<std::remove_reference_t<T>>::value
      >
    >
    explicit Person(T&& n)
    : name(std::forward<T>(n))
    { ... }
    ...
private:
    std::string name;
};

T가 Person, Interger류가 아닐때만 오버로드 해소 후보에 오른다.

 

절충점

보편 참조의 단점은 잘못된 타입을 넣었을때 에러가 정확하게 잘 안나온다는 점이다. 이는 static_assert등을 알아서 잘 추가해서 해결하자.

 

 

항목 28: 참조 축약

다음 네가지의 경우에 레퍼런스의 레퍼런스가 가능하다. 그리고 이는 다음과 같이 변환되어 해석된다.

두 레퍼런스 중 하나라도 LValue 레퍼런스이면  LValue레퍼런스, 아니면 RValue 레퍼런스이다.
ex)
T& && => T&
T&& && => T&&

 

1. auto& , auto&&

auto&& w1 = w;

Widget& && w1 = w;

Widget& w1 = w;

 

2. 템플릿 보편참조 매개변수

아래에 자세히 설명

 

3. typedef,  별칭 선언

typedef T&& a;

에서 T가 Widget&면

typedef Widget& && a;

typedef Widget& a;

가 된다.

 

4. decltype

 

템플릿 보편참조 매개변수

template<typename T>
void SetMyName(T&& name)
{
    MyName = std::forward<T>(name);
}

//-----------------------------------------------

std::string a;
logAndAdd(a);

//-----------------------------------------------

template<typename std::string&>
void SetMyName(std::string& && name)
{
    MyName = std::forward<std::string&>(name);
}

//-----------------------------------------------

template<typename std::string&>
void SetMyName(std::string& name)
{
    MyName = std::forward<std::string&>(name);
}

//-----------------------------------------------

logAndAdd(std::move(a));

//-----------------------------------------------

template<typename std::string>
void SetMyName(std::string&& name)
{
    MyName = std::forward<std::string>(name);
}

//-----------------------------------------------

템플릿 보편 참조 매개변수에 LValue 표현식을 넣으면 T는 LValue 레퍼런스 타입이 되고 param의 타입은 참조 축약을 통해 LValue 래퍼런스 타입이 된다.

RValue 표현식을 넣으면 T는 논 레퍼런스가 되고 param의 타입은 RValue 레퍼런스 타입이 된다.

 

그리고 forward는 명시된 템플릿 타입 인수가 논 레퍼런스면 RValue 표현식을, LValue 레퍼런스면 LValue 표현식을 리턴하는 식으로 동작해온 것이었다.

template<typename T>
T&& forward(remove_reference_t<T>& param)
{
    return static_cast<T&&>(param);
}

Widget w;

//----------------------------------------

forward(w)

//----------------------------------------

template<typename Widget&>
Widget& forward(Widget& param)
{
    return static_cast<Widget&>(param);
}

//----------------------------------------

forward(std::move(w))

//----------------------------------------

template<typename Widget>
Widget&& forward(Widget& param)
{
    return static_cast<Widget&&>(param);
}

그래서 사실 보편 참조는 참조 축약에 의해 축약되면서 변환되는거지 그 자체는 RValue 레퍼런스였다.

 

 

항목 29: 이동연산 이점을 얻을 수 없는 경우를 생각하자

이동 연산의 이점을 얻을 수 없는 경우는 다음과 같은 경우가 있다.

 

이동연산이 없는 클래스

말그대로 이동 연산이 없는 클래스는 이점이 없다.

이동이 빠르지 않은 클래스

std::array는 스택에 데이터를 저장하기에 이동 대상의 각 원소에 이동 연산을 호출한다. int등의 데이터를 저장하고 있다면 복사 연산과 똑같은 성능이 든다.

이동을 사용할 수 없는 상황

noexcept 등

 

어떤 타입이 올지 알 수 없는 일반화된 템플릿 함수등을 만들때는 이동연산을 믿고 대입을 마구 쓰면 안된다. 이동 연산이 안될 수도 있다.

 

항목 30: 완벽 전달이 실패하는 경우

완벽 전달이란

template<typename... Ts>
void fws(Ts&&... params)
{
	f(std::forward<Ts>(params)...)
}

 

에서 f(표현식) 과 fws(표현식)이 같은 결과를 내는 것을 말한다.

 

완벽 전달이 실패하는 경우는 다음과 같은 경우가 있다.

1. 중괄호 초기치

보편 참조에 중괄호 초기치를 넣으면 컴파일 에러가 발생한다.

즉 f({1,2,3})은 괜찮지만 fws({1,2,3})은 컴파일 에러가 나낟.

 

2. 널 포인터를 뜻하는 0 또는 NULL

항목 8과 동일

 

3. 선언만 된 정수 static const 또는 constexpr 자료 멤버

해당 자료 멤버들을 선언에서 값을 넣고 정의를 안하고 사용하면 인라인처럼 작동하여 넣은 상수가 바로 기입된것 처럼 동작한다. 즉 메모리가 없다. 따라서 해당 멤버를 보편 참조에 넣으면 참조가 불가능하기에 링크 에러가 난다. f에 바로 넣는 것은 가능하다. 이를 해결 하려면 정의를 추가하면 메모리가 생겨 해결된다.

 

4. 중복 적재된 함수 이름 또는 템플릿 이름

void f(int pf(int)) 처럼 함수 포인터를 받는 함수가 있을때 중복 적재된 함수의 이름을 넣어도 적절한 시그니처의 함수 포인터가 잘 넘어간다. 하지만 보편 참조를 거치면 불가능하기에 컴파일 에러가 난다. 이를 막기 위해선 static_cast로 정확한 타입으로 변환 후 넣거나 함수 포인터 변수에 대입후 넣어야한다.

 

5. 비트 필드

비트 필드의 논 const 참조는 금지되어있어 fwd에 넣으면 컴파일 에러가 난다. const 레퍼런스로 넣거나 복사한 int 변수를 넣자. const 레퍼런스는 내부적으로 복사후 복사본의 레퍼런스를 넘겨준다.