10. C++의 까다롭고 유별난 부분들
C++언어에는 까다로운 문법이나 특이한 의미를 가지는 부분이 많다. 이런 부분이 유별나기는 하지만 C++프로그래머로서 계속 적응하다 보면 어느새 자연스럽게 느껴지기 시작한다. 하지만 어떤 부분은 계속해서 혼란을 야기하기도 한다. 속 시원하게 설명해주는 책이 없거나 자꾸 잊어버려서 다시 찾아봐야 한다거나 둘 중 하나 또는 둘 다가 원인일 것이다. 이 장에서는 가장 끈질기게 프로그래머를 괴롭히는 C++의 까다롭고 유별난 부분을 명쾌하게 설명함으로써 반복되는 혼란을 줄여보고자 한다.
10.1 참조형
전문 개발자가 작성한 C++코드에서는 참조형이 광범위하게 사용된다. 참조형의 정체가 무엇이고, 그 행동 방식은 어떤지 이해하면 큰 도움이 된다.
C++에서 참조(reference)는 다른 변수에 대한 별명(alias)이다. 참조에 대한 모든 수정 사항은 참조형 변수가 가리키고 있는 실제 변수에 반영된다. 참조형 변수를 주솟값 추출과 역참조 연산이 자동으로 수행되는 포인터 변수로 생각하면 이해하기 쉽다.
포인터가 어려우면 같은 변수에 대한 다른 이름으로 생각해도 된다. 참조형 변수는 독립적으로 생성할 수 있고, 클래스 데이터 멤버에도 이용할 수 있으며, 함수와 메서드의 파라미터나 리턴 타입으로도 사용할 수 있다.
10.1.1 참조형 변수
참조형 변수는 다음처럼 생성하자마자 초기화해야 한다.
int x = 3;
int& xRef = x;
위 선언 이후부터 xRef는 x의 또 다른 이름이 된다. 변수 xRef를 읽는 것은 변수 x를 읽는 것과 완전히 동일하고 xRef에 대한 변경은 x에 바로 반영된다. 예를 들어 다음과 같이 xRef를 이용해서 x에 10을 대입할 수 있다.
xRef = 10;
클래스 멤버가 아니라면 초기화 없이 참조형 변수를 선언할 수 없다.
int& emptyRef; // 컴파일 에러 발생!
참조형 변수는 항상 생성 시점에 초기화 해야한다. 보통 참조형 변수는 선언과 동시에 생성되지만, 클래스 멤버 변수로 선언된 참조형 변수는 클래스 생성자에서 초기화되어야 하기 떄문에 선언과 동시에 초기화하지 않아도 된다.
숫자와 이름을 가지지 않는 값에 대해서는 참조를 할 수 없다. 단, const값은 참조할 수 있다.
다음 코드에서 unnamedRef1은 'const가 아닌 숫자값' 으로 참조형 변수를 초기화하려 하기 때문에 컴파일되지 않는다. const가 아닌 참조형을 가진다는 것은 값을 읽는 것 뿐만 아니라 변경도 가능하다는 것 인데 다음 코드처럼 상수 표현식에 대해 값을 대입하는 것은 이상한 행위다.(코드를 보면 상수값 5를 변경할 수 있다는 의미인데, 타당치 않다) unnamedRef2의 경우 cosnt참조이기 때문에 선언하는 데 아무런 문제가 없다. 단,const이기 때문에 unnamedRef2 = 7과 같이 값을 변경할 수는 없다.
int& unnamedRef1 = 5; // 컴파일 오류 발생
cosnt int& unnamedRef2 = 5; // 정상적으로 컴파일됨
10.1.1.1 참조 대상의 변경
참조형 변수는 초기화할 때 가리킬 변수가 한 번 지정되고 나면 바뀌지 않는다. 이러한 동작 방식은 약간의 혼란을 야기한다. 참조형 변수를 선언할 떄 대입을 통해 참조가 가리킬 변수를 지정했는데, 그 이후에는 변수를 대입해도 그 값만 가져와서 원래 변수를 업데이트할 뿐 참조할 변수가 대입하는 변수로 바뀌지 않는다. 다음 코드는 이런 상황을 설명한다.
int x = 3, y = 4;
int& xRef = x;
xRef = y; // 변수 x의 갑싱 4로 바뀜, xRef가 가리키는 대상이 y로 바뀌지는 않음.
값만 이용되는 것이 아니라 참조 대상을 바꾸기 위해 y의 주소로 시도할 수도 있다. 다음 코드를 보자.
int x = 3, y = 4;
int& xRef = x;
xRef = &y; // 컴파일 안됨!
위 코드는 컴파일이 안된다. y의 주소는 포인터 타입인데 xRef는 int에 대한 참조라서 타입이 서로 다른 값 사이에 대입 연산을 시도하기 때문이다.
어떤 프로그래머는 참조 대상 자체를 바꾸기 위해 다음처럼 참조형 변수에 참조형 변수를 대입해보기도 하지만 이 역시 의도대로 동작하지 않는다. 참조형 변수를 대입한다고 해서 특별히 취급되는 것은 아니다.
int x = 3, z = 5;
int& xRef = x;
int& zRef = z;
zRef = xRef; // 값이 이용될 뿐 참조 대상이 바뀌지 않는다.
마지막 라인에서 zRef가 가리키는 참조 대상이 바뀌지 않는다. xRef가 가리키는 변수 x의 값이 3이기 때문에 zRef를 통해 변수 z의 값을 3으로 바꿀 뿐이다.
참조형 변수의 참조 대상은 선언할 때 한 번 초기화하고 나면 바꿀 수 없다. 단지 그 값만 바꿀 수 있을 뿐이다.
10.1.1.2 포인터에 대한 참조형 변수, 참조형변수에 대한 포인터
참조형 변수는 어떤 타입이든 만들 수 있다. 포인터 또한 예외가 아니다. 다음은 참조형 변수로 int타입 포인터 변수를 가리키는 예다.
int* intP;
int*& ptrRef = intP;
ptrRef = new int;
*ptrRef = 5;
위 코드는 다소 이상해보일 수 있다. *와 &를 연달아 사용하는 문법이 낯설어 보이지만 의미를 따져보면 매우 간단하다. ptrRef는 int타입 포인터 변수 intP에 대한 참조다. 포인터에 대해 참조형 변수를 사용하는 경우는 드물지만 어떤 경우에는 매우 유용하게 사용되기도 한다.
참조형 변수의 주솟값을 얻으면 참조되는 실제 변수의 주솟값과 동일한 값이 나온다. 다음 예를 보자.
int x = 3;
int& xRef = x;
int* xPtr = &xRef; // 참조형 변수의 주소는 참조되는 실제 변수의 포인터와 같다.
*xPtr = 100;
위 코드는 xPtr을 x의 주솟값으로 세팅한다. *xPtr에 100을 대입하면 x의 값이 100으로 바뀐다. 당연하게도 xRef는 포인터가 아닌 int타입 변수이기 때문에 'xPtr == xRef'와 같이 비교하면 타입 불일치로 컴파일이 안된다. 대신 'xPtr == &xRef'또는 'xPtr == &x'와 같이 비교하면 정상적 으로 컴파일되고 두 주솟값이 같으므로 실행 결과는 모두 참이된다.
마지막으로, 참조에 대한 참조 또는 참조에 대한 포인터는 선언할 수 없다는 것을 알아두자.
10.1.2 참조형 데이터 멤버
참조형 변수를 클래스 데이터 멤버로도 사용할 수 있다. 참조형 변수는 다른 변수를 가리키지 않는 상태로는 존재할 수 없다. 참조형 데이터 멤버의 초기화는 선언 시점이 아닌 생성자 초기화 리스트에서 이루어진다. 생성자 바디가 아닌 초기화 리스트임에 유의하자.
다음은 이렇게 초기화하는 예 이다.
class MyClass
{
pulbic:
MyClass(int& ref) : mRef(ref) {}
private:
int& mRef;
};
10.1.3 참조형 파라미터
일반 변수나 클래스 데이터 멤버처럼 참조형 변수나 참조형 데이터 멤버를 별도로 선언해서 사용하는 경우는 그렇게 많지 않다. 참조가 가장 흔하게 사용되는 곳은 함수와 메서드의 파라미터다. 이미 배웠듯이 기본적인 파라미터 전달 방법은 값에 의한 전달이다.
함수는 인자의 복제본을 넘겨받고 넘겨받은 파라미터를 수정하더라도 원본 인자에는 전혀 반영되지 않는다. 파라미터를 참조형으로 선언하면 복제가 일어나는 값에 의한 전달이 아닌 원본 자체가 넘겨지는 참조에 의한 전달을 할 수 있다.
참조에 의한 전달에서는 함수나 메서드 안에서 파라미터를 수정할 때 원본 인자도 그대로 반영된다. 예를 들어 다음과 같이 두 개의 int 변수를 받는 간단한 swap 함수를 보자.
void swap(int& first, int& second)
{
int temp = first;
first = second;
second = temp;
}
이 함수는 다음처럼 이용할 수 있다.
int x = 5, y = 6;
swap(x, y);
swap()함수를 인자 x와 y로 호출하면, 참조형 파라미터 first와 second는 각각 변수 x와 y로 초기화된다. 그리고 swap()함수의 바디에서 first와 second의 값을 변경하면 x와 y의 값이 바뀐다.
보통의 참조형 변수를 상수값으로부터 초기화할 수 없듯이, 참조형 파라미터도 상수값 인자를 받을 수 없다.
swap(3, 4); // 컴파일 안됨!
우측값 참조(rvalue reference)를 통해서 인자를 참조형 파라미터로 넘길 수 있다.
10.1.3.1 포인터로부터의 참조
함수의 인자로사용할 변수가 포인터일 때 어떻게 해야 할지 당황할 수 있다, 이 때는 역참조를 이용해서 단순히 포인터를 참조형으로 '변환'하기만 하면 된다. 원래 포인터를 역참조하면 포인터가 가리키는 값을 얻어오지만 참조형 파라미터의 인자로 쓰일 떄는 컴파일러가 참조형 파라미터의 초기화에 해당 변수를 이용하게 된다. 즉, 포인터 변수를 swap()함수의 인자로 사용해야 할 떄는 다음과 같이 하면 된다.
int x = 5, y = 6;
int *xp = &x, *yp = &y;
swap(*xp, *yp);
10.1.3.2 참조에 의한 전달과 값에 의한 전달
함수나 메서드 안에서 파라미터를 수정한 내용이 원본 인자에도 반영되어야 한다면 참조에 의한 전달을 사용해야 한다. 하지만 꼭 이때만 참조에 의한 전달을 사용하는 것은 아니다. 참조에 의한 전달을 사용하면 인자가 복제되는 것을 피할 수 있기 때문에 다음과 같은 두 가지 장점이 있다.
- 효율성
큰 객체나 struct를 복제하면 실행 시간 오버헤드가 크다. 참조에 의한 전달을 이용하면 객체나 struct의 포인터에만 함수에 전달된다. - 정확성
모든 객체가 값에 의한 전달을 지원하지는 않는다. 지원한다고 하더라도 깊은 복제(depp copy)가 올바르게 수행되지 않을 수도 있다. 동적으로 할당된 메모리를 멤버로 가진다면 반드시 커스텀 복제 생성자를 통해 깊은 복제가 구현되어야 한다.
참조형 파라미터를 통해 이러한 장점을 취하고 싶다면 파라미터를 참조형으로 하되 const제한자를 이용하여 파라미터 원본이 실수로 수정되는 것을 방지하는 것이 좋다.
이러한 장점을 생각하면 거의 항상 참조에 의한 전달을 사용하는 것이 바람직하다. 값에 의한 전달은 int나 double과 같이 인자를 수정할 필요가 없는 기본 데이터 타입에만 사용하도록 한다.
10.1.4 참조형 리턴값
함수나 메서드의 리턴값에도 참조형을 사용할 수 있다. 리턴값에 참조형을 사용하는 가장 큰 이유는 효율성이다. 전체 객체를 리턴하는 대신 그 객체의 참조만 리턴하면 불필요한 복제 오버헤드를 피할 수 있다. 당연하지만 참조형 리턴 타입은 리턴되는 객체나 함수나 메서드 종료 후에도 계속 유효할 때만 사용할 수 있다.
함수나 메서드에서 리턴 타입으로 참조형을 이용할 때 절대로 로컬 변수(오토 변수라고도 한다)를 리턴해서는 안된다. 로컬 변수는 함수가 구동되는 동안만 유효한 스택에 저장되어있다가 함수가 리턴할 떄 스택과 함께 삭제된다.
만약 리턴하고자 하는 타입이 이동 시멘틱을 지원한다면 값으로 리턴하더라도 참조로 리턴하는 것만큼 효율적이다.
참조형 리턴 타입을 사용하는 또 다른 이유로는 함수 자체를 좌항(lvalue[대입문의 좌측항])으로 사용하기 위해서다. 몇몇 오버로딩된 연산자는 참조형 리턴 타입을 흔하게 사용한다.
10.1.5 참조와 포인터의 선택기준
C++에서 참조형은 중복된 기능으로 볼 수도 있다. 참조형이 할 수 있는 일은 거의 모두 포인터로도 할 수 있다. 예를 들어 앞서 보았던 swap()함수는 다음처럼 참조 대신 포인터를 이용해서 재작성할 수 있다.
void swap(int* first, int* second)
{
int temp = *first;
*first = *second;
*second = temp;
}
하지만 위 코드는 참조를 이용할 때보다 지저분하다.
참조는 프로그램을 간결하고 이해하기 쉽게 만들어준다. 그리고 참조는 포인터보다 더 안전하다. 포인터와 달리 참조는 유효하지 않은 데이터를 가리키는 것이 거의 불가능하다. 그리고 참조는 역참조 연산을 할 일이 없음으로 포인터에서 만나는 역참조 오류를 볼 일이 없다. 그런데 참조가 가지는 이러한 안정성은 참조의 이용에서 포인터가 사용되지 않을 떄만 유효하다. 예를 들어 다음과 같이 참조형 int파라미터를 받는 함수를 보자.
void refcall(int* t) { ++t; }
포인터 변수는 선언하고 나서 null 값으로 초기화하는 것이 가능하다. 그리고 참조형 변수의 초기화 대상으로 그 포인터 변수를 이용할 수 있고 다음 코드처럼 그것을 다시 refcall()의 인자로 사용할 수도 있다. 다음 코드는 아무런 문제 없이 컴파일되지만, 실행되자마자 죽어버린다.
int* ptr = nullptr;
refcall(*ptr);
대부분 포인터 대신 참조를 이용할 수 있다. 그리고 객체에 대한 참조는 포인터가 그렇듯이 다형성까지 지원한다. 포인터를 사용할 수 밖에 없는 유일한 상황은 가리키는 대상을 바꾸어야 할 때 뿐이다. 참조형 변수는 한 번 초기화되고 나면 가리키는 대상을 변경할 수 없다. 예를 들어 포인터를 사용해야 하는 또 다른 예는 옵셔널한 파라미터를 사용하는 경우다.포인터는 nullptr과 같은 디폴트값을 지정할 수 있기 때문에 호출 측에서 인자를 생략할 수 있다. 하지만 참조형 파라미터로는 디폴트값을 변경할 수 없다.
파라미터나 리턴값에서 참조와 포인터 중 어느 것을 이용해야 할지 판정하는 또 다른 기준은 해당 메모리를 누가 소유하고 있는지 생각해보는 것이다. 만약 변수를 넘겨받는 측이 그 객체와 관련된 메모리의 해제를 책임져야 한다면 리턴 타입으로 포인터를 사용해야 한다.
더 나은 방법은 스마트 포인터를 이용하는 것이다. 소유권을 이전할 때는 스마트 포인터를 이용하는 것만이 바람직하다. 반대로 변수를 넘겨받는 측에서 객체를 삭제하면 안 된다면 리턴 타입으로 참조형을 이용한다.
참조 대상을 바꾸어야 할 필요가 없다면 포인터 대신 참조형을 이용하는 것이 좋다.
이렇게나 원칙을 엄격하게 적용하다 보면 낯선 형태의 구문을 만들게 된다. 예를 들어 하나의 int배열을 짝수와 홀수 두 개의 배열로 나누는 함수를 생각해보자. 이 함수는 배열에 들어 있는 짝수와 홀수의 개수가 얼마나 많을지 미리 알 수 없다. 이 때문에 메모리를 동적으로 할당하여 결과를 저장해야 한다.
더불어서 결과와 배열의 크기 값도 리턴해야 한다. 결과적으로 새롭게 생성된 홀수 배열의 포인터와 짝수 배열의 포인터 그리고 홀수 배열의 크기와 짝수 배열의 크기 총 네 가지 데이터를 리턴해야 한다. 파라미터를 통해 리턴해야 하므로 결과를 받을 인자는 당연히 참조에 의한 전달을 이용해야 한다. 이 함수를 전형적인 C스타일로 작성하면 다음과 같다.
void separateOddsAndEvents(cosnt int arr[], int size, int** odds, int* numOdds,
int** evens, int* numEvens)
{
// 먼저 결과 배열의 크기를 결정한다.
*numOdds = *numEvens = 0;
for(int i = 0; i < size; ++i) {
if(arr[i] % 2 == 1) {
++(*numOdds);
} else {
++(*numEvens);
}
}
// 결과 배열을 적합한 크기로 할당한다.
*odds = new int[*numOdds];
*evens = new int[*numEvens];
// 새로운 배열에 짝수와 홀수 값을 복사한다.
int oddsPos = 0, evensPos = 0;
for (int i = 0; i < size; ++i) {
if(arr[i] % 2 == 1) {
(*odds)[oddsPos++] = arr[i];
} else {
(*evens)[evensPos++] = arr[i];
}
}
}
vector<int> vecUnSplit = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
vector<int> odds, evens;
separateOddsAndEvens(vecUnSplit, odds, evens);
10.1.6 우측값 참조
C++에서 좌항(lvalue)은 그 주소를 얻을 수 있거나 변수 이름을 가진 대상을 말한다. 좌항은 이름이 의미하는 바와 같이 대입 구문의 왼쪽에 위치한다. 반면에 우항(rvalue)은 좌항이 아닌 것을 의미한다. 우항은 상수값이나 임시 객체 같은 것들이다.
우항은 보통 대입 연산자의 오른쪽에 위치한다.
C++는 우측값 참조(rvalue reference)라는 개념을 가지고 있다. 이름 그대로 대입문 오른쪽에 오는 값에 대한 참조로, 특히 우항이 이밋 객체일 때 적용되는 개념이다. 우측값 참조는 임시 객체가 생성되는 코드 구문에서 함수나 메서드, 특히 복제 생성자나 대입 연산자를 컴파일러가 선택할 때 사용된다. 이를 이용하면 삭제될 예정인 크기가 큰 임시 객체를 복제해야 할 떄 전체 데이터를 깊이 복제하지 않고 포인터 주소만 얕게 복제하여 오버헤드를 줄일 수 있다.
우측값 참조형 파라미터는 파라미터를 선언할 때 &&를 붙인다. 예를 들어 type&& name과 같은 형태로 선언한다. 보통 임시 객체는 const type&로 취급되지만 우측값 참조형 파라미터를 가지는 메서드는 이 임시 객체 타입을 받아들일 수 있다. 아래 예제는 두 개의 incer()함수를 정의하고 있다. 하냐는 좌측값 참조형 파라미터로 받고, 다른 하나는 우측값 참조형 파라미터를 받는다.
void incr(int& value)
{
cout << "increment with lvalue reference" << endl;
++value;
}
// 우측값 참조형 파라미터를 이용해서 값을 증가시킴
void incr(int&& value)
{
cout << "increment with rvalue reference" << endl;
++value;
}
incr()함수는 다음처럼 일반 변수를 인자로 하여 호출할 수 있다. a는 이름을 가진 변수이기 때문에 좌측값 참조형 파라미터를 받는 incr()함수가 호출된다. 함수가 호출된 후에는 a의 값이 11로 증가한다.
int a = 10, b = 20;
incr(a); //좌측값 참조형 함수 incr(int& value)가 호출됨
다음처럼 표현식을 인자로 해서 incr()함수를 호출할 수도 있다. 이 때 좌측값 참조형 함수는 이용할 수 없다.
a + b 의 결과는 임시 변수이므로 좌항이 될 수 없기 때문이다. 이 경우에는 우측값 참조형 함수가 호출된다. 인자가 임시 변수이므로 결괏값은 함수 종료와 함께 사라진다.
incr(a + b); // 우측값 참조형 함수 incr(int&& value)가 호출됨
상수 구문도 incr()함수의 인자로 사용될 수 있다. 상수는 우항이 될 수 없으므로 이 때도 우측값 참조형 함수가 호출된다.
incr(3); // 우측값 참조형 함수 incr(int&& value)가 호출됨
만약 우측값 참조형 변수를 정의하지 않은 상태에서 위와 같이 일반 변수(변수 int b)가 아닌 인자로 incr()함수를 호출하면 컴파일 에러가 발생한다. C++표준에서는 좌측값(변수 int b)을 우측값 참조형 파라미터(int&& value)에 연결하지 않기 때문이다. 일반 변수를 이용해서 우측값 참조형 incr()함수를 호출하고 싶을 떄는 std;:move()함수를 이용해서 좌항 변수를 우측값 참조로 변환하면 된다.
다음은 강제로 우측값 참조형 함수를 호출하게 한 예로 incr()함수 호출 후 변수 b의 값이 21로 증가한다.
incr(std::move(b)); //우측값 참조형 함수 incr(int&& value)가 호출됨
우측값 참조가 함수 파라미터에만 이용되는 것은 아니다. 일반 변수도 우측값 참조형으로 선언해서 다른 타입과 마찬가지로 값을 대입하는 등 동일하게 이용할 수 있다. 하지만 일반 변수를 우측값 참조형으로 이용하는 경우는 흔하지 않다.
다음 코드는 C++에서 허용되지 않는다.
int& i = 2; // 허용되지 않음: 삼수값에 대한 참조
int a = 2, b = 3;
int& j = a + b; // 허용되지 않음: 임시 객체에 대한 참조
하지만 우측값 참조를 이용하면 정상적인 코드가 된다.
int&& i = 2;
int a = 2, b = 3;
int&& j = a + b;
위 코드처럼 우측값 참조를 독립적인 변수에서 이용하는 경우는 매우 드물다.
10.1.6.1 이동 시맨틱
이동 시맨틱은 이동 생성자와 이동 대입 연산자를 통해서 지원된다. 컴파일러는 대입 원본 객체가 임시 객체여서 대입 대상으로의 복제 또는 대입이 끝난 후 소멸할 떄 이들 메서드를 이용해서 효율성을 도모한다. 이동 생성자와 이동 대입 연산자는 원본 객체로부터 새로운 객체로 멤버 변수를 복사한 다음 원본 객체의 멤버를 null값으로 초기화 시킨다. 이렇게 함으로써 메모리에 대한 소유권을 객체 간에 이동시킨다.
이들 메서드는 기본적으로 멤버 변수에 대해 얕은 복제를 하여 메모리에 대한 소유권을 바꿈으로써 댕글린 포인터나 메모리 릭의 발생을 방지한다.
이동 시맨틱은 우측값 참조로 구현된다. 어떤 클래스가 이동 시맨틱을 갖게 하려면 이동 생성자와 이동 대입 연산자를 구현해야 한다. 이동 생성자와 이동 대입 연산자는 반드시 noexcept로 선언되어 컴파일러가 익셉션을 발생시키지 않도록 해야 한다.
이 부분은 표준 라이브러리와 온전하게 호환되기 위해 특히 중요하다. 표준 라이브러리에서도 객체가 이동될 떄는 익셉션이 발생하지 않도록 보증하고 있다.
class Spreadsheet
{
public:
SpreadSheet(SpreadSheet&& src) noexcept; // 이동 생성자
SpreadSheet& operator=(SpreadSheet&& rhs) noexcept; // 이동 대입 연산자
};
이들 메서드는 다음과 같이 구현될 수 있다. 여기에는 mCells 배열을 메모리에서 해제하기 위한 편의 메서드 freeMemory()가 사용되고 있는데 구현부는 생략했다. 이 편의 메서드는 소멸자, 대입 연산자, 이동 대입 연산자에서 호출된다. 비슷한 방식으로 원본 객체에서 대상 객체로 데이터를 이동시키는 편의 메서드를 정의해서 사용할 수도 있다. 그러한 편의 메서드는 이동 생성자와 이동 대입 연산자에서 이용될 수 있다.
SpreadSheet::SpreadSheet(SpreadSheet&& src) noexcept
{
// 데[이터의 얕은 복제
mWidth = src.mWidth;
mHeight = src.mHeight;
mCells = src.mCells;
// 소유권이 이동되었기 떄문에 원복 객체를 리셋한다!
sec.mWidth = 0;
src.mHeight = 0;
src.mCells = nullptr;
}
// 이동 대입 연산자
SpreadSheet& SpreadSheet::operator=(SpreadSheet&& rhs) noexcept
{
// 자기 대입 여부 검사
if (this == &rhs) {
return *this;
}
// 기존 메모리의 해제
freeMemory();
// 데이터의 얕은 복제
mWidth = rhs.mWidth;
mHeight = rhs.mHeight;
mCells = rhs.mCells;
// 소유권이 이동되었기 떄문에 원복 객체를 리셋한다!
rhs.mWidth = 0;
rhs.mHeight = 0;
rhs.mCells = nullptr;
return *this;
}
이동 생성자와 이동 대입 연산자 모두 데이터 메모리 mCells의 소유권을 원본 객체로부터 새로운 객체로 이동시키고 있다. 그러고 나서 원본 객체의 mCells의 포인터를 null값으로 리셋해서 원본 객체 소멸 시 이미 소유권이 넘어간 메모리를 해제해버리지 않도록 한다.
이렇게 구현된 이동 생성자와 이동 대입 연산자는 다음 코드로 시험해볼 수 있다.
SpreadSHeet CreateObejct()
{
return SpreadSheet(3, 2);
}
int main()
{
vector<SpreadSheet> vec;
for (int i = 0; i < 2; i++) {
cout << "Iteration " << i << endl;
vec.push_back(SpreadSheet(100, 100));
cout << endl;
}
SpreadSheet s(2,3);
s = CreateObejct();
SpreadSheet s2(5, 6);
s2 = s;
return 0;
}
vector는 동적으로 커지면서 새로운 객체를 담는다. 이러한 동적 확장은 더 큰 메모리를 할당받아 원래 있던 vector에서 새로운 vector로 객체를 옮겨넣는 방식으로 동작한다. 만약 컴파일러가 이동 생성자를 발견하면 복제 대신 이동이 일어나서 깊은 복제에 의한 오버헤드를 줄일 수 있다.
SpreadSheet클래스의 모든 생성자와 대입 연산자에 메시지를 출력하도록 해놓았다면 위 코드의 실행 결과는 다음처럼 된다. 이 결과와 관련된 내용은 모두 마이크로소프트 비주얼 C++2013을 기반으로 하고있다. 표준 C++에서는 vector의 기본 용량이나 크기 증가 방식에 대해 규정하고 있지 않다. 따라서 컴파일러에 따라 결과가 다를 수 있다.
Iteration 0
Normal constructor (1)
Move constructor (2)
Iteration 1
Normal constructor (3)
Move constructor (4)
Move constructor (5)
Normal constructor (6)
Normal constructor (7)
Move assignment operator (8)
Normal constructor (9)
Assignment operator (10)
루프에 처음 진입했을 떄는 vector가 비어있다. 각 루프 안에서 다음 코드가 실행되면
vec.push_back(SpreadSheet(100, 100));
새로운 SpreadSheet 객체가 일반 생성자 (1)를 통해 만들어진다.
vector는 자신의 크기를 늘리고 새로운 객체를 담게 되는데 이때 새로운 SpreadSheet객체가 vector의 저장 공간으로 이동되면서 이동 생성자 (2)가 실행된다.
두 번째 루프 반복에서 두 번째 SpreadSheet객체가 일반 생성자(3)로 만들어진다.
이때 vector는 크기를 다시 한번 늘려서 두 번쨰 SpreadSheet객체를 저장하려 한다. vector의 크기가 바뀔 떄 이전에 저장된 항목은 기존의 vector에서 크기가 늘려진 새로운 vector로 옮겨져야 한다.
이때 이전에 들고 있던 SpreadSheet객체의 이동 생성자(4)가 호출된다.
그 다음에 이동 생성자(5)가 호출되어 새로운 SpreadSheet객체를 크기가 늘어난 vector로 이동시킨다.
그 다음에 SpreadSheet객체 s가 일반 생성자(6)을 이용해서 생성된다.
CreateObejct() 함수는 일반 생성자 (7)을 통해 SpreadSheet임시 객체를 만들고 이를 리턴하면서
변수 s에 이동 대입 연산자 (8)를 통해 옮겨진다.
임시 객체는 함수 리턴 후에 소멸할 것이기 때문에 컴파일러는 일반 복제 대입 연산자 대신 이동 대입 연산자를 호출한다.
반면 s2 = s; 구문에서는 일반 복제 대입 연산자 (10)가 호출된다.
우항의 객체가 임시 객체가 아닌 이름을 가진 일반 객체기 때문이다.
만약 SpreadSheet클래스가 이동 생성자와 이동 대입 연산자를 정의하고 있지 않다면, 이전 코드의 실행 결과는 다음처럼 될 것이다.
Iteration 0
Normal constructor (1)
Copy constructor (2)
Iteration 1
Normal constructor (3)
Coy constructor (4)
Copy constructor (5)
Normal constructor (6)
Normal constructor (7)
Assignment operator (8)
Normal constructor (9)
Assignment operator (10)
위 결과에서 볼 수 있듯이 이동 생성자와 이동 대입 연산자 대신 복제 생성자와 복제 대입 연산자가 호출된다. 앞의 예제에서 SpreadSheet객체는 루프 안에서 100 X 100 = 10,000개 셀을 갖도록 생성되었다. 이동 생성자와 이동 대입 연산자가 실행될 떄는 메모리 할당 작업이 전혀 일어나지 않지만
복제 생성자와 복제 대입 연산자는 셀 배열을 매번 새로 만들기 떄문에 101번의 메모리 할당을 발생시킨다.
즉, 이동 시맨틱을 활용하면 이러한 상황에서 성능을 크게 향상시킬 수 있다.
그리고 생성자 또는 대입 연산자에 대한 명시적인 삭제와 디폴트 사용 선언을 이동 생성자와 이동 대입 연산자에 대해서도 동일하게 적용할 수 있다.
이동 시맨틱을 이용해서 성능을 향상시키는 또 다른 예로 swap()함수를 들 수 있다. 이동 시맨틱이 지원되지 않을 떄는 swap()함수를 다음처럼 구현했다.
swapCopy(T& a, T& b)
{
T temp(a);
a = b;
b = temp;
}
위 함수는 먼저 파라미터 a를 로컬 변수 temp에 복제한 후 파라미터 b를 a에 복제하고 다시 temp를 b에 복제한다. 만약 타입 T를 복제할 떄 부하가 크다면 이러한 swap구현 방식은 성능을 떨어뜨린다. 이동 시맨틱이 지원된다면 swap()함수를 다음처럼 구현하여 오버헤드를 피할 수 있다.
swapMove(T& a, T& b)
{
T temp(std::move(a));
a = std::move(b);
b = std::move(temp);
}
당연하지만 이동 시맨틱은 원본 객체가 삭제되리라는 것을 알 때만 유용하게 활용할 수 있다.
'전문가를 위한 C++정리' 카테고리의 다른 글
10. C++의 까다롭고 유별난 부분들 10.3 타입과 캐스팅 (0) | 2024.03.08 |
---|---|
10. C++의 까다롭고 유별난 부분들 10.2 키워드 혼동 (0) | 2024.03.07 |
9. 클래스 상속 활용 테크닉 9.6 상속과 관련된 미묘한 문제들 (0) | 2024.03.04 |
9. 클래스 상속 활용 테크닉 9.5 다중 상속 (0) | 2024.02.29 |
9. 클래스 상속 활용 테크닉 9.4 다형성을 위한 상속 (0) | 2024.02.27 |