[C++] R-Value Reference와 Move Semantic

언어/C++ 2019. 9. 3. 01:36
R-Value Reference(이하 오른값 참조)는 C++11부터 추가된 아주 복잡하면서도 멋진 녀석이다.

이게 대체 뭐하는 놈인고 하면, 아주 원론적인 부분까지 접해들어갈 필요가 있다.
아래 코드를 봐보자.

int num;
num = 33;

L: 여기서 num은 왼쪽에 있다. 그리고 이름이 있다. 그리고 표현식이 종료되어도 존재한다. 또한 메모리 셀이므로 주소연산(&)을 사용할 수 있다. 이것이 왼값이다.
R: 오른쪽에는 33이 있다. 이건 이름이 없다. 그냥 값이 num으로 전달될 뿐이고, 표현식이 끝나면 증발한다. 또한 상수이므로 주소연산을 사용할 수 없다. 이것이 오른값이다.

const string& s... 같은,
이제까지 우리가 사용했던 참조는 왼값참조였다.
기존에 존재하던 변수(왼값)를 참조했으니까.

하지만 이제 우리가 접근해볼 오른값 참조는 저런 이름없는 임시 값들마저 참조할 수 있다.
표기법은 아래와 같이, &대신에 &&를 사용한다.
int && ref = 333; //오른값 333을 참조

왼값도 std::move를 사용하면 오른값 참조에 묶을수 있다.
std::string s = "text";
std::string&& ref = std::move(s);
//이름이 move지만 진짜 이동하는건 아니고 &&참조로 캐스팅만 해줌

오른값 참조자도 기존의 왼값 참조자처럼 값에 접근해서 이래저래 쓸수 있다.

근데 이게 다라면 이걸 구분해서 쓸 이유가 전혀 없다. 그냥 왼값참조 써도 똑같이 되잖아?


그렇다. 이 오른값 참조로 값이 다른 변수에 전달되고, 그 변수의 타입에 해당하는 이동 생성자(혹은 대입자)가 있다면, 무브 시맨틱(Move Semantic)이라는 것이 발생한다.
말 그대로 이동(move)을 수행한다.

컴퓨터에서 무거운 영상 파일들을 옮겨본 적이 있을것이다.
파일이 너무 무겁다면 그 파일의 복사하고 붙여넣는(Ctrl+c,v) 속도도 엄청하게 느려진다.
하지만 파일을 잘라내고 붙일(Ctrl+x,v) 때는 별다른 시간이 소요되지 않고 뚝딱 완료된다. 이것이 파일이 한 군데만 존재할 것을 보장하는 데서 발생하는 일종의 이동이다.
앞서 언급한 무브시맨틱도 이와 같은 원리라 보면 된다.

예시를 한번 보자.
//가정1: s 안에 담긴 문자열은 크기가 크다.
std::string s = "...";
//이 시점에서 s는 더이상 필요가 없다.
//s2에서 옮겨서 s2로만 쓰면 된다.
std::string s2 = s;

이러면 s2의 복사생성자가 호출되면서 s의 내부값을 일일이 복사해서 가져간다.
기존 s의 크기가 클수록 소요되는 시간도 선형적으로 증가할 것이다.

하지만 이렇게 한다면
std::string s2 = std::move(s);
//s를 std::string&&으로 캐스팅
//s2의 이동생성자 호출

s의 길이와 상관없이 이 작업은 아주 작은 상수시간에 종료된다.
그리고 기존의 s는 텅 비게된다.

그리고 무브시맨틱은 연산중에 발생한 임시객체를 다룰때도 효용을 보인다.
std::string s = "hello";
std::string s2 = " world";
std::string s3 = s+s2;
옛날 방식대로 생각해보자. 위 코드의 세번째줄에서 s와 s2가 더해져서 둘이 이어진 임시객체 std::string("hello world")가 생성된다. 여기까진 좋다. 그럼
std::string s3 = std::string("hello world");
의 형태가 된다.
그럼 저 임시객체의 값을 복사해가기 위해  복사생성자가 호출되고 저 값들을 전부 복사해갈 것이다.
당장 저 길이라면 상관없지만, 임시객체가 저것보다 훨씬 크다면 이건 문제가 된다.

하지만 이제 우리에겐 무브 시맨틱이 있다.
s+s2의 결과물은 오른값이다.
그러므로 이동생성자가 호출돼서 s+s2의 결과값이 낭비없이 s3에게 쏙 넘어간다.


그리고 이런 성능의 향상 빼고도, 이 무브시맨틱은 하나의 장점이 또 있다.

무브시맨틱은 말 그대로 이동이다.
값이 아래에 있던 것이 위로 가면, 그 값은 위에만 존재한다. 아래에는 존재하지 않는다.
그러므로 이동의 동작은 의미론적으로 유일성(unique)을 보장한다. 복사와 다르게.

그래서 스마트포인터 std::unique_ptr 같은 경우는 이런 이동만을 행할 수 있다. 복사는 금지되어있다.
이 외에도 복사는 금지하고 이동만을 허가하는 클래스들이 꽤 있다.

auto p = std::make_unique<T>(...);
std::unique_ptr<T> p2 = p; //에러! 복사 불가
std::unique_ptr<T> p3 = std::move(p); //p->p3 이동. p는 null이 됨


아 그리고 오른값참조가 등장하면서 원래 4개였던 클래스의 기본 함수들이, 6개로 늘어났다.
클래스명이 T일 경우, 기본 함수들은 아래와 같다.

T()=default; //기본 생성자
~T() = default; //소멸자
T(const T&) = default; //복사생성자
T(T&&) = default; //이동생성자
T& operator=(const T&) = default; //복사대입자
T& operator=(T&&) = default; //이동대입자

이동생성자와 이동대입자는 구현할 필요가 없다. 복사생성자와 같은 얕은복사의 문제는 없기 때문에 그냥 =default만 해줘도 된다.(컴파일러에게 자동구현시키기)
그래도 만약 구현하고 싶다면, 아래처럼 하면 된다.
//가정: 타입 T에는 멤버변수로 a와 b를 가진다.
//가정: a와 b는 전부 이동이 가능하다.
T(T&& other) :
a(std::move(other.a)),
b(std::move(other.b)
{}
T& operator=(T&& other)
{
    this->a=std::move(other.a);
    this->b=std::move(other.b);
    return *this;
}

아무튼 오른값 참조는 굉장히 유용하다.
문제는 자잘한 규칙과 함정이 많고, 복잡하다는 것인데

가장 헷갈릴만한 것은, 오른값 참조자는 오른값이 아니라는 것이다. 오른값 참조자 자체는 이름도 있고, 주소연산도 가할 수 있으며, 표현식이 종료되어도 생존한다.
때문에 아래와 같은 경우는 이동이 발생하지 않는다.
std::string s = "...";
std::string&& s2 = std::move(s); //s->s2 이동
std::string s3 = s2; //s2->s3 이동 실패. 그냥 복사 생성자 호출

이동의 과정은 순수하게 오른값으로만 전달되어야 한다.
아래와 같이 고치면 이동이 수행된다.
std::string s = "...";
std::string&& s2 = std::move(s); //s->s2 이동
std::string s3 = std::move(s2); //s2->s3 이동

그리고 오른값 참조자는 const를 붙이면 안된다.
에러는 나지 않지만 이동이 실패한다.


더 자세한 사항들은 모던 이펙티브 C++를 참조하길 바란다.


'언어 > C++' 카테고리의 다른 글

[C++] Q: 왜 동적배열이 vector가 된건가요?  (0) 2019.09.03
[C++] C++17 optional  (0) 2018.12.30
[C++] C++17 variant  (0) 2018.12.30

설정

트랙백

댓글

[C++] Q: 왜 동적배열이 vector가 된건가요?

언어/C++ 2019. 9. 3. 01:27
https://stackoverflow.com/questions/581426/why-is-a-c-vector-called-a-vector


Q: 수학적 의미의 벡터는 대략 알긴 하는데요.
c++의 벡터에 대한 뜻은 알지 못해서 이렇게 질문을 올립니다.


A: vector의 수학적 정의는 집합 Sn의 멤버면서 특정 집합 S에 들어있는 값의 정렬된 시퀀스입니다.
이게 c++의 벡터가 저장하는거죠


A2: 이건 STL의 설계자인 Alex Stepanov가 built-in 배열과 구분하기 위해 vector라고 부른 데서 유래합니다.
근데 수학적 의미의 vector는 길이가 고정된 시퀀스죠... c++의 vector는 길이가 변하는 시퀀스고요.
알렉스도 자신의 실수를 인정했습니다.

게다가 C++0X(C++11)에서는 수학적 의미의 vector와 같은 기능을 하는 클래스를 'array'로 정의해버렸습니다.
엉망진창이네요.

알렉스의 교훈: 이름은 언제나 신중하게 결정하자

'언어 > C++' 카테고리의 다른 글

[C++] R-Value Reference와 Move Semantic  (0) 2019.09.03
[C++] C++17 optional  (0) 2018.12.30
[C++] C++17 variant  (0) 2018.12.30

설정

트랙백

댓글

[C++] C++17 optional

언어/C++ 2018. 12. 30. 22:29

C++17부터 옵셔널 라이브러리가 추가되었다.


이 옵셔널 타입은 원래 함수형 언어에서 나온 개념인데, 개념적으로는 모나드라고 부른다.


예외처리 방법 중 하나며, 안전성이 높아서 명령형 언어들에서도 채용을 많이 하는 편이다.

C#의 Nullable이 그렇고, Swift의 옵셔널, 코틀린의 ??, 러스트의 Result 등이 있다.


단어 자체의 뜻을 보면 '선택적인'이라는 뜻인데. 값이 선택적으로 있을수도 없을수도 있다는 뜻이다.


좀 다르긴 하지만 포인터 타입도 이런 면에서 옵셔널의 특성을 가진다고 할 수도 있다. 

널 포인터를 가지면 값이 없는 것이고 뭐라도 갖고있으면 주소가 있는거니까 값이 있는 것이다.


옵셔널은 이러한 특성을 포인터뿐만 아니라 모든 타입에 적용시킬 수가 있다.

게다가 값이 들어있는지 아닌지 명시적으로 확인을 하게 해서 포인터에 비하면 아주 안전하다.

그대신 귀찮은 면이 있긴 하지만 어느정도 감수할만한 부분이라 생각된다.



옵셔널로 선언된 객체는 그 자체로는 사용할 수 없다.

값이 들어있는 상태인지 아닌지 모르기 때문이다.



옵셔널의 값을 사용하려면 value 메서드를 쓰면 된다. 

다만 값이 없을 때 쓰면 예외가 뜨니까 has_value나 operator bool로 값 여부를 체크하고 사용해야 한다.

"John"이라는 값이 멀쩡히 들어있으니 아주 잘 수행된다. 


값이 없으니 "??"가 나온다.


포인터에서 빈 주소를 표현하는 용도로 nullptr가 예약된것처럼

빈 옵셔널을 표현하는 객체로 nullopt가 있다.


근데 값을 사용할때마다 조건문을 달아주는건 꽤 귀찮은 작업이다.

위에서 했던건 value_or 메서드 하나로 퉁칠 수 있다.

이건 값이 들어있으면 값 그냥 반환하고, 값이 없으면 인자로 받은걸 반환한다.


'언어 > C++' 카테고리의 다른 글

[C++] R-Value Reference와 Move Semantic  (0) 2019.09.03
[C++] Q: 왜 동적배열이 vector가 된건가요?  (0) 2019.09.03
[C++] C++17 variant  (0) 2018.12.30

설정

트랙백

댓글

[C++] C++17 variant

언어/C++ 2018. 12. 30. 22:21

variant는 진화된 공용체다.


기존의 union은 문제가 많았다.


예를 들어 기존의 union을 쓸 때, 

double 버전으로 값을 넣고 int 버전으로 사용을 한다 해도 아무런 문제 없이 작동이 된다.


하지만 출력값을 보면 알겠지만 값이 정상적이지 않다.


C의 공용체는 아무런 타입 검사를 수행하지 않는다. 이 때문에 C의 공용체는 타입-safe하지 못하다고 한다.


아무튼 저런 맛이 간 출력값이 나온 이유는 아무런 조치 없이 메모리에 존재하는 비트값을 그대로 읽어왔기 때문이다. 정수 타입과 실수 타입의 메모리 구조는 완전히 다르다. 게다가 엔디언과 바이트 크기까지 고려하면 같은 계열의 타입이라도 괴리가 발생할 수가 있다.


물론 이런걸 이용해서 편법적으로 사용할 수도 있지만, 

실제로 이런 식의 접근은, 99% 사용자의 실수에서 발생한다. 그리고 실수는 결함으로 이어진다.

union은 이처럼 안전성에 문제가 많다.


게다가 기존의 union은 생성자/소멸자/가상메서드가 없는 이른바 POD 타입만을 멤버로 둘 수가 있다는 심각한 기능적 결함이 있었다.

다시말해 C++스타일의 타입은 제대로 담아둘수 없었다는 것이다.



C++17에서는 이런 문제들을 해결한 variant라는 멋진 녀석을 내놓았다.


variant는 타입-safe한 공용체 템플릿이다.

당연히 C++ 스타일의 클래스도 멤버로 둘 수 있다.


기존의 공용체와 다른 특징으로, 이녀석은 반드시 초기화를 해야만 접근 타입을 바꿀 수가 있다.

해당 타입으로의 초기화를 수행하지 않고 접근을 한다면 예외가 발생한다.

아마 이런 방식을 통해서 타입체킹도 엄격하게 하고 생성자/소멸자도 써먹을 수 있게 한 것 같다.



일단 아래 코드는 아주 정상적으로 작동하는 코드다.

int 타입으로 값 대입 한번 해주고, 접근해서 출력하고.

double로 초기화해주고, 접근해서 출력한다.




하지만 초기화를 수행하지 않으면 이렇게 예외가 나온다.

이렇게



그리고 애초에 없는 타입이나 인덱스는 당연히 컴파일부터가 안 된다.



암튼 그렇다.


자세한 건 레퍼런스를 참조

https://en.cppreference.com/w/cpp/utility/variant

'언어 > C++' 카테고리의 다른 글

[C++] R-Value Reference와 Move Semantic  (0) 2019.09.03
[C++] Q: 왜 동적배열이 vector가 된건가요?  (0) 2019.09.03
[C++] C++17 optional  (0) 2018.12.30

설정

트랙백

댓글