전문가를 위한 C++/ 클래스와 객체 마스터하기

Contents

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

friend

C++는 클래스 안에서 다른 클래스나 다른 클래스의 멤버 함수 또는 비멤버 함수를 friend로 선언하는 기능을 제공한다. friend로 지정한 대상은 이 클래스의 protected나 private 데이터 멤버와 메서드에 접근할 수 있다. (이런 거를 왜 만들어 놨지? 이런거는 설계가 너무 난잡해진다. 상속이나 interface, component를 이용할 것)

예컨대 Foo와 Bar라는 두 클래스가 있다고 하자. 그리고 다음과 같이 Bar 클래스를 Foo의 프렌드로 지정한다.

class Foo
{
friend class Bar;
// ...
}

이렇게 하면 Bar에 있는 모든 메서드는 Foo의 private이나 protected 데이터 멤버 및 메서드에 접근할 수 있다.

Bar에 있는 메서드 중 특정한 메서드만 프렌드로 만들기 원할 수 있다. 그럴 때는 다음과 같이 작성한다.

class Foo
{
friend void Bar::processFoo(const Foo& foo);
// ...
}

스탠드언론 함수도 클래스의 프렌드가 될 수 있다. 예컨대 Foo 객체에 있는 데이터를 콘솔에 출력하는 함수를 만든다고 하자. 이 함수를 Foo 클래스 밖에서 검사하는 모델로 만들려고 하는데, 제대로 검사하라면 이 객체의 내부 데이터 멤버의 값에 접근해야 한다. 이때 Foo 클래스 정의에 다음과 같이 dumpFoo() 함수를 프렌드로 만들면 된다.

class Foo
{
friend void dumpFoo(const Foo& foo);
// ...
}

이 클래스에서 friend 선언문은 함수 프로토타입의 역할을 한다. 이렇게 지정한 프로토타입은 다른 곳에 따로 선언하지 않아도 된다. (물론 그렇게 해도 문제가 발생하지는 않는다)

이 함수의 정의는 다음과 같다.

void dumpFoo(const Foo& foo)
{
// private 및 protected 데이터 멤버를 비롯한 foo의 데이터를 모두 콘솔에 출력한다.
}

이 함수를 작성하는 방법은 다른 함수와 같다. Foo의 private와 protected 데이터 멤버에 직접 접근할 수 있다는 점만 다르다. 이렇게 함수를 정의할 때는 friend 키워드를 생략해도 된다.

프렌드로 지정할 클래스, 메서드, 함수는 반드시 접근할 클래스 안에서 지정해야 한다. 이들을 다른 곳에서 대상 클래스의 프렌드라고 선언해서 그 클래스의 private이나 protected 멤버에 접근하게 할 수는 없다.

클래스마 네서드를 프렌드로 지정하는 기능을 너무 많이 사용하면 클래스의 내부가 외부 클래스나 함수에서 드러나서 캡슐화 원칙이 깨진다. 따라서 꼭 필요할 때만 사용한다.

객체에 동적 메모리 할당하기

Spreadsheet 클래스

8장에서 SpreadsheetCell 클래스를 다루었는데 여기서는 Spreadsheet 클래스를 작성해보겠다. 먼저 Spreadsheet를 SpreadsheetCell 타입의 2차원 배열로 만든다. 그리고 Spreadsheet에서 특정 위치에 있는 셀을 설정하거나 조회하는 메서드를 정의한다. 상용 스프레드시트 애플리케이션은 한 축은 문자, 다른 축은 숫자를 사용하지만 여기서는 모두 숫자로 표시한다. 

Spreadsheet의 첫 버전은 다음과 같다.

#include <cstddef>
#include "SpreadsheetCell.h"

class Spreadsheet
{
public:
Spreadsheet(size_t width, size_t height);
void setCellAt(size_t x, sizt_t y, const SpreadsheetCell& cell);
SpreadsheetCell& getCellAt(size_t x, size_t y);
private:
bool inRange(size_t value, size_t upper) const;
size_t mWidth = 0;
size_t mHeight = 0;
SpreadsheetCell** mCells = nullptr;
}

Note) Spreadsheet 클래스는 mCells 배열에 대해 일반 포인터를 사용한다. 이 장에서는 동적 메모리 할당의 결과와 클래스에서 동적 메모리를 다루는 방법을 설명하기 위해 이렇게 작성했지만 실전에서는 std::vector와 같은 C++표준 컨테이너를 사용하기 바란다. 참고로 최신 C++에서는 절대로 일반 포인터를 사용하면 안 된다.

여기서 Spreadsheet 클래스를 보면 멤버의 타입을 SpreadsheetCell 타입의 표준 2차원 배열이 아니라 SpreadsheetCell** 타입으로 정의했는데, 이렇게 한 이유는 Spreadsheet 객체마다 크기가 다를 수 있기 때문에 이 클래스의 생성자에서 클라이언트가 지정한 높이와 너비에 맞게 2차원 배열을 동적으로 할당해야 하기 때문이다.

2차원 배열을 동적으로 할당하려면 다음과 같이 코드를 작성한다. 이때 C++은 자바와 달리 new SpreadsheetCell[mWidth][mHeight]와 같이 간단히 작성할 수 없다는 점에 주의하라

Spreadsheet::Spreadsheet(size_t width, size_t height) : mWidth(width), mHeight(height)
{
mCells = new SpreadsheetCell*[mWidth];

for (size_t i = 0; i < mWidth; i++)
{
mCells[i] = new SpreadsheetCell[mHeight];
}
}

다음 그림은 너비 4, 높이 3의 크기를 가진 s1이란 이름의 Spreadsheet 객체가 스택에 할당되었을 때의 메모리 상태를 보여준다.

다음은 셀 하나를 읽고 쓰는 메서드를 구현한 코드다.

void Spreadsheet::setCellAt(int x, int y, const SpreadsheetCell& cell)
{
if (!inRange(x, mWidth) || !inRange(y, mHeight))
{
throw std::out_of_range("");
}
mCells[x][y] = cell;
}

SpreadsheetCell& Spreadsheet::getCellAt(int x, int y)
{
if (!inRange(x, mWidth) || !inRange(y, mHeight))
{
throw std::out_of_range("");
}
return mCells[x][y];
}

두 메서드 코드에 x, y가 스프레드 시트에 실제 존재하는 좌표인지 확인하는 작업을 inRange()라는 헬퍼 메서드로 처리했다. 

setCellAt()과 getCellAt() 메서드를 보면 코드가 중복된 것을 알 수 있는데, 다음과 같이 verifyCoordinate() 메서드를 만들어서 사용하자.

// 선언부
void verifyCoordinate(size_t x, size_t y) const;

// 구현부
void Spreadsheet::verifyCoordinate(size_t x, size_t y) const
{
if (x >= mWidth || y >= mHeight)
{
throw std::out_of_range("");
}
}

// get, set은 이렇게 고친다.
void Spreadsheet::setCellAt(int x, int y, const SpreadsheetCell& cell)
{
verifyCoordinate(x, y);
mCells[x][y] = cell;
}

SpreadsheetCell& Spreadsheet::getCellAt(int x, int y)
{
verifyCoordinate(x, y);
return mCells[x][y];
}

소멸자로 메모리 해제하기

동적으로 할당한 메모리를 다 썼다면 반드시 해제해야 한다. 객체 안에서 동적으로 할당한 메모리는 그 객체의 소멸자에서 해제하는 것이 바람직하다. 그러면 컴파일러는 이 객체가 소멸될 때 소멸자를 호출하게 해준다. 이를 위해 Spreadsheet 클래스에 다음과 같이 소멸자를 선언한다.

class Spreadsheet
{
public:
Spreadsheet(size_t width, size_t height);
~Spreadsheet();
// 이하 코드 생략
}

소멸자의 이름은 생성자의 이름과 같고 그 앞에 ~를 붙인다. 소멸자는 인수를 받지 않으며 생성자와 달리 단 하나만 존재한다. 소멸자는 익셉션이 발생하지 않기 때문에 기본적으로 noexcept가 적용된다.

Note) 아무런 익셉션도 던지지 않는 함수 앞에는 noexcept 키워드를 붙인다. void myNonThrowingFunction() noexcept { /* … */ } 소멸자는 기본적으로 noexcept가 적용되므로 키워드를 따로 적지 않아도 된다. noexcept로 지정한 함수에서 익셉션이 발생하면 프로그램이 멈춘다.

Spreadsheet 클래스의 소멸자는 다음과 같이 구현한다.

Spreadsheet::~Spreadsheet()
{
for (size_t i = 0; i < mWidth; i++)
{
delete [] mCells[i];
}
delete [] mCells;
mCells = nullptr;
}

복제와 대입 처리하기

8장에서 설명했듯이 복제 생성자나 대입 연산자를 직접 작성하지 않으면 컴파일러가 자동으로 만들어준다. 이렇게 컴파일러에서 생성된 메서드는 객체의 데이터 멤버에 대해 복제 생성자나 대입 연산자를 재귀적으로 호출한다.

하지만 int, double, 포인터와 같은 기본 타입에 대해서는 비트 단위 복제(bitwise copy), 얕은 복제(shallow copy) 또는 대입이 적용된다. 즉, 원본 객체의 데이터 멤버를 대상 객체로 단순히 복제하거나 대입하기만 한다. 그런데 메모리를 동적으로 할당한 객체를 이렇게 처리하면 문제가 발생한다. 

다음 코드를 보자. 여기 나온 printSpreadsheet() 함수에 스프레드시트 객체 s1을 전달하면 이 함수의 매개변수인 s를 초기화하는 과정에서 s1을 복제한다.

void printSpreadsheet(Spreadsheet s)
{
// 나머지 코드 생략
}

int main()
{
Spreadsheet s1(4, 3);
printSpreadsheet(s1);
return 0;
}

이렇게 전달한 Spreadsheet는 mCells라는 포인터 변수 하나만 갖고 있다. 얕은 복제를 적용하면 대상 객체는 mCells에 담긴 데이터가 아닌 mCells 포인터의 복제본만 받는다. 따라서 아래 그림과 같이 s와 s1이 같은 데이터를 가리키는 포인터가 되는 상황이 발생한다.

이 상태에서 mCells가 가리키는 대상을 s가 변경하면 그 결과가 s1에도 반영된다. 더 심각한 문제는 printSpreadsheet() 함수가 리턴할 때 s의 소멸자가 호출되면서 mCells가 가리키던 메모리를 해제해버린다는 것이다.

이렇게 되면 s1은 더는 올바른 메모리를 가리키지 않게 된다. 이런 포인터를 댕글링 포인터라고 한다.

대입 연산을 수행할 때는 이보다 더 심각한 문제가 발생한다. 예컨대 다음과 같은 코드를 작성했다고 하자.

Spreadsheet s1(2, 2), s2(4, 3);
s1 = s2;

첫 번째 줄만 실행했을 때 두 객체가 생성된 상태는 아래 그림과 같다.

이어 두 번째 줄에 나온 대입 문장을 실행한 결과는 아래 그림과 같다.

이 상태를 보면 s1과 s2에 있는 mCells 포인터가 가리키는 메모리가 똑같을 뿐만 아니라 s2에서 mCells가 가리키던 메모리는 미아(orphan)가 된다. 이런 상황을 메모리 누수라 부른다. 그래서 대입 연산자에서는 반드시 깊은 복제(deep copy)를 적용해야 한다.

이처럼 C++ 컴파일러가 자동으로 생성하는 복제 생성자나 대입 연산자를 그대로 사용하면 위험하다.

Spreadsheet 복제 생성자

Spreadsheet 클래스에 다음과 같이 복제 생성자를 선언한다.

class Spreadsheet
{
public:
Spreadsheet(const Spreadsheet& src);
// 나머지 코드 생략
}

이 복제 생성자를 다음과 같이 정의한다.

Spreadsheet::Spreadsheet(const Spreadsheet& src) : Spreadsheet(src.mWidth, src.mHeight)
{
for (size_t i = 0; i < mWidth; i++)
{
for (size_t j = 0; j < mHeight; j++)
{
mCells[i][j] = src.mCells[i][j];
}
}
}

이 코드는 위임 생성자를 사용했다. 이 복제 생성자의 생성자 이니셜라이저를 보면 적절한 양의 메모리를 할당하는 작업을 비복제 버전의 생성자에 맡긴다. 그러고 나서 실제로 값을 복제하는 작업을 수행한다. 이렇게 하면 동적으로 할당된 2차원 배열인 mCells를 깊은 복제로 처리할 수 있다.

여기서는 기존 mCells를 삭제하는 작업을 할 필요가 없다. 이 코드는 복제 생성자이기 때문에 현재(this) 객체에 아직 기존에 생성된 mCells이 존재하지 않기 때문이다.

Spreadsheet 대입 연산자

이번에는 Spreadsheet 클래스에 대입 연산자를 선언하자.

class Spreadsheet
{
public:
Spreadsheet& operator=(const Spreadsheet& rhs);
// 나머지 코드 생략
}

구현은 다음과 같다.

Spreadsheet& Spreadsheet::operator=(const Spreadsheet& rhs)
{
// 자신을 대입하는지 확인한다. --사실 내 스타일로는 어차피 최종적으로 *this를 리턴하기 때문에 this != &rhs 일 때 대입하는 코드를 넣고 최종적으로 return *this를 하는 식으로 구현했을 것.
if (this == &rhs)
{
return *this;
}

// 기존 메모리를 해제한다.
for (size_t i = 0; i < mWidth; i++)
{
delete[] mCells[i];
}
delete[] mCells;
mCells = nullptr;

// 새로운 메모리를 할당한다.
mWidth = rhs.mWidth;
mHeight = rhs.mHeight;

mCells = new SpreadsheetCell*[mWidth];
for (size_t i = 0; i < mWidth; i++)
{
mCells[i] = new Spreadsheet[mHeight];
}

// 데이터를 복제한다.
for (size_t i = 0; i < mWidth; i++)
{
for (size_t j = 0; j < mHeight; j++)
{
mCells[i][j] = rhs.mCells[i][j];
}
}

return *this;
}

이 코드는 먼저 자기 자신을 대입하는지 검사한 다음 this 객체에 현재 할당된 메모리를 해제한다. 그 후 새로 메모리를 할당하고 마지막으로 개별 원소를 복제한다. 이 메서드는 하는 일이 상당히 많은데 그만큼 문제가 발생할 여지도 많다. 즉 this 객체가 비정상적인 상태가 될 수 있다.

예컨대 메모리를 정상적으로 해제해서 mWidth, mHeight는 제대로 설정됐지만 메모리를 할당하는 루프문에서 익셉션이 발생했다고 하자. 그러면 이 메서드의 나머지 코드를 건너뛰고 리턴해버린다. 이렇게 Spreadsheet 인스턴스가 손상됐기 때문에 여기에 있는 mWidth, mHeight 데이터 멤버는 일정한 크기를 갖는다고 선언했지만 실제로는 mCells 데이터 멤버에 필요한 만큼 메모리를 갖고 있지 않다. 결국 이 코드는 익셉션이 발생하면 문제가 생긴다.

이럴 때는 모두 정상적으로 처리하거나 그렇지 못하면 this 객체를 건드리지 않아야 한다. 예외가 발생해도 문제가 생기지 않게 대입 연산자를 구현하려면 복제 후 맞바꾸기(copy-and-swap) 패턴을 적용하는 것이 좋다.

즉, 비멤버 swap() 함수를 만들고 Spreadsheet 클래스의 프렌드로 지정한다. 이렇게 하지 않고 클래스 멤버로 swap() 메서드를 추가해도 되지만 swap()을 비멤버로 구현하는 것이 더 낫다. 그래야 다양한 알고리즘에서 활용할 수 있기 때문이다. (별도의 static 클래스에서 동작하는 메서드로 만드는게 나은거 아닌가?)

class Spreadsheet
{
  public:
  Spreadsheet& operator=(const Spreadsheet& rhs);
friend void swap(Spreadsheet& first, Spreadsheet& second) noexcept;    
    // 나머지 코드 생략
}

복제 후 맞바꾸기 패턴을 익셉션에 안전하게 하려면 swap() 함수에서 절대로 익셉션을 던지면 안 된다. 따라서 noexcept로 지정한다. 그리고 swap() 함수에서 실제로 데이터 멤버를 교체하는 작업은 표준 라이브러리에서 제공하는 유틸리티 함수인 std::swap()로 처리한다.

void swap(Spreadsheet& first, Spreadsheet& second) noexcept
{
using std::swap;

swap(first.mWidth, second.mWidth);
 swap(first.mHeight, second.mHeight);
swap(first.mCells, second.mCells);
}

이렇게 swap() 함수를 익셉션에 안전하게 만들면 다음과 같이 대입 연산자를 구현하는데 활용할 수 있다.

Spreadsheet& Spreadsheet::operator=(const Spreadsheet& rhs)
{
if (this == &rhs)
{
return *this;
}

Spreadsheet temp(rhs); // 모든 작업을 임시 인스턴스에서 처리한다.
swap(*this, temp); // 익셉션을 발생하지 않는 연산으로만 작업을 처리한다.
return *this;
}

위와 같이 작성하면 익셉션이 발생하더라도 Spreadsheet 객체는 변하지 않는다. 이렇게 구현하는 과정은 다음 세 단계로 구성된다.

  • 1단계는 임시 복제본을 만든다. 이렇게 하면 현재 Spreadsheet 객체의 상태를 변경하지 않는다. 따라서 이 과정에서 익셉션이 발생해도 문제가 되지 않는다.
  • 2단계는 swap() 함수를 이용하여 현재 객체를 생성된 임시 복제본으로 교체한다. swap() 함수에서는 익셉션이 절대로 발생하면 안 된다.
  • 3단계에서는 임시 객체를 제거한다. 그러면 모든 메모리를 정리해서 원본 객체가 남게 된다.

Note) C++은 복제뿐만 아니라 이동 의미론도 지원한다. 이를 사용하려면 이동 생성자와 이동 대입 연산자를 정의해야 한다. 그러면 특정 상황에서 성능을 높일 수 있다.

대입과 값 전달 방식 금지

때로는 클래스에서 메모리를 동적으로 할당할 때 그냥 아무도 이 객체에 복제나 대입을 한 수 없게 만드는게 간편할 때가 있다. 이렇게 하려면 operator= 과 복제 생성자를 명싲거으로 삭제하면 된다. 그러면 이 객체를 값으로 전달하거나 함수나 메서드에서 이 객체를 리턴하거나 이 객체에 뭔가를 대입하려면 컴파일러에서 에러 메시지를 발생시킨다. 이렇게 대입이나 값 전달 방식을 금지하려면 Spreadsheet 클래스를 다음과 같이 정의한다.

class Spreadsheet
{
public:
Spreadsheet(size_t width, size_t height);
Spreadsheet(const Spreadsheet& src) = delete;
~Spreadsheet();
Spreadsheet& operator=(const Spreadsheet& rhs) = delete;
// 나머지 코드 생략
}

delete로 지정한 메서드는 구현할 필요 없다. 컴파일러는 이러한 메서드를 호출하는 것을 허용하지 않기 때문에 링커는 이렇게 지정된 메서드를 전혀 참조하지 않는다.

이동 의미론으로 이동 처리하기

객체에 이동 의미론을 적용하려면 이동 생성자와 이동 대입 연산자를 정의해야 한다. 그러면 컴파일러는 원본 객체를 임시 객체로 만들어서 대입 연산을 수행한 뒤 임시 객체를 제거한다. 이 과정에서 이동 생성자와 이동 대입 연산자를 활용한다.

이동 생성자와 이동 대입 연산자는 원본 객체에 있는 데이터 멤버를 새 객체로 이동 시키기 땜누에 원본 객체는 정상적이지만 미확정된 상태로 남게 된다. 이러한 원본 객체의 데이터 멤버는 대부분 널 값으로 초기화된다. 이렇게 함으로써 메모리를 비롯한 리소스의 소유권을 다른 객체로 이동 시킨다.

이 과정은 멤버 변수에 대한 얕은 복제와 비슷하다. 또한 메모리나 다른 리소스에 대한 소유권을 전환함으로써 댕글링 포인터나 메모리 누수를 방지한다.

이동 의미론을 구현하기 전에 먼저 우측값(rvalue)과 우측값 레퍼런스(rvalue reference)부터 알아둘 필요가 있다.

우측값 레퍼런스

C++에서 좌측값(lvalue)은 변수처럼 이름과 주소를 가진 대상이다. 대입문에서 왼쪽에 나온다고 해서 좌측값이라 부른다. 반면 우측값(rvalue)은 리터럴, 임시 객체, 값처럼 좌측값이 아닌 모든 대상을 가리킨다. 일반적으로 우측값은 대입문의 오른쪽에 나온다.

int a = 4 * 2;

이 문장에서 a는 좌측값이며 이름을 갖고 있으며 &a로 주소를 가져올 수 있다. 반면 4 * 2라는 표현식의 결과는 우측값이다. 우측값은 임시값이라서 이 문장을 실행하고 나면 제거된다. 여기서는 임시 변수에 있는 값의 복사본을 a란 이름의 변수에 저장한다.

우측값 레퍼런스란 개념도 있다. 말 그대로 우측값에 대한 레퍼런스다. 구체적으로 말하면 우측값이 임시 객체일 때 적용되는 개념이다. 우측값 레퍼런스는 임시 객체에 대해 적용할 함수를 컴파일러가 선택하기 위한 용도로 사용한다. 우측값 레퍼런스로 구현하면 크기가 큰 값(객체)을 복사하는 연산이 나오더라도 컴파일러는 이 값이 나중에 삭제될 임시 객체라는 점을 이용하여 그 값에 우측값에 대한 포인터를 복사하는 방식으로 실행할 수 있다.

함수의 매개변수에 &&를 붙여서 우측값 레퍼런스로 만들 수 있다. 일반적으로 임시 객체는 const type&로 취급하지만 함수의 오버로딩 버전 중에서 우측값 레퍼런스를 사용하는 것이 있다면 그 버전으로 임시 객체를 처리한다.

예컨대 다음 코드와 같다. 여기서는 먼저 handleMessage() 함수를 두 버전으로 정의한다. 하나는 좌측값 레퍼런스를 받고 다른 하나는 우측값 레퍼런스를 받는다.

// 좌측값 레퍼런스 매개변수
void handleMessage(std::string& message)
{
cout << "handleMessage with lvalue reference: " << message << endl;
}

// 우측값 레퍼런스 매개변수
void handleMessage(std::string&& message)
{
cout << "handleMessage with rvalue reference: " << message << endl;
}

먼저 handleMessage()를 다음과 같이 이름 있는 변수를 인수로 전달해서 호출해 보자.

std::string a = "Hello ";
std::string b = "World";
handleMessage(a); // handleMessage(string& value)를 호출

전달한 인수가 a라는 이름을 가진 변수이므로 handleMessage() 함수 중에서 좌측값 레퍼런스를 받는 버전이 호출된다. 이 함수 안에서 매개변수로 받은 레퍼런스로 변경한 사항은 a 값에도 똑같이 반영된다.

이번에는 handleMessage() 함수를 다음과 같이 표현식을 인수로 전달해서 호출해 보자.

handleMessage(a + b);  // handleMessage(string& value)를 호출

a + b 표현식으로 임시 변수가 생성되는데, 임시 변수는 좌측값이 아니므로 handleMessage() 함수 중에서 우측값 레퍼런스 버전이 호출된다. 전달한 인수는 임시 변수이기 때문에 함수 안에서 매개변수의 레퍼런스로 변경한 값은 리턴 후에 사라진다.

handleMessage() 함수의 인수로 리터럴을 전달해도 된다. 이때도 우측값 레퍼런스 버전이 호출된다. 리터럴은 좌측값이 될 수 없기 때문이다.

여기서 좌측값 레퍼런스를 받는 handleMessage() 함수를 삭제한 뒤 handleMessage(b) 처럼 이름 있는 변수를 전달해서 호출하면 컴파일 에러가 발생한다. 우측값 레퍼런스 타입의 매개변수(string&& message)를 좌측값(b) 인수에 바인딩할 수 없기 때문이다. 

이때 좌측값을 우측값으로 캐스팅하는 std::move()를 사용하면 컴파일러가 우측값 레퍼런스 버전의 handleMessage()를 호출하게 만들 수 있다.

handleMessage(std::move(b));

다시 한 번 강조하면 이름 있는 변수는 좌측값이다. 따라서 handleMessage() 함수 안에서 우측값 레퍼런스 타입인 message 매개변수 자체는 이름이 있기 때문에 좌측값이다. 이처럼 타입이 우측값 레퍼런스인 매개변수를 다른 함수에 우측값으로 전달하려면 std::move()를 이용하여 좌측값을 우측값으로 캐스팅해야 한다. 앞서 본 코드에 다음과 같이 우측값 레퍼런스 매개변수를 받는 함수를 추가해 보자.

void helper(std::string&& message)
{
}

이 함수를 다음과 같이 호출하면 컴파일 에러가 발생한다.

void handleMessage(std::string&& message)
{
helper(message);
}

helper() 함수는 우측값 레퍼런스를 받는 반면 handleMessage()가 전달하는 message는 좌측값이기 때문에 컴파일 에러가 발생한다. 이를 해결하려면 다음과 같이 std::move()로 좌측값을 우측값으로 캐스팅해서 전달해야 한다.

void handleMessage(std::string&& message)
{
helper(std::move(message));
}

함수의 매개변수 말고도 우측값 레퍼런스를 사용하는 경우가 있다. 변수를 우측값 레퍼런스 타입으로 선언해서 값을 할당할 수도 있다. 물론 이런 사례는 흔치 않다. 예컨대 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;

이동 의미론 구현 방법

이동 의미론은 우측값 레퍼런스로 구현한다. 클래스에 이동 의미론을 추가하려면 이동 생성자와 이동 대입 연산자를 구현해야 한다. 이때 이동 생성자와 이동 대입 연산자를 noexcept로 지정해서 두 메서드에서 익셉션이 절대 발생하지 않는다고 컴파일러에 알려줘야 한다. 특히 표준 라이브러리와 호환성을 유지하려면 반드시 이렇게 해야 한다.

예컨대 표준 라이브러리 컨테이너와 완벽하게 호환되도록 구현할 때 이동 의미론을 적용했다면 저장된 객체를 이동시키기만 한다. 또한 이 과정에서 익셉션도 던지지 않는다. 다음과 같이 Spreadsheet 클래스에 이동 생성자와 이동 대입 연산자를 추가한 코드를 살펴보자. 여기에 cleanup()과 moveFrom() 이란 헬퍼 메서드도 추가했다. cleanup() 은 소멸자와 이동 대입 연산자에서 사용하고, moveFrom()은 원본 객체의 멤버 변수를 대상 객체로 이동시킨 뒤 원본 객체를 리셋한다.

class Spreadsheet
{
public:
Spreadsheet(Spreadsheet&& src) noexcept; // 이동 생성자
Spreadsheet& operator=(Spreadsheet&& rhs) noexcept; // 이동 대입 연산자
// 나머지 코드 생략

private:
void cleanup() noexcept;
void moveFrom(Spreadsheet& src) noexcept;
// 나머지 코드 생략
}

구현 코드는 다음과 같다.

void Spreadsheet::cleanup() noexcept
{
for (size_t i = 0; i < mWidth; i++)
{
delete[] mCells[i];
}
delete[] mCells;
mCells = nullptr;
mWidth = mHeight = 0;
}

void Spreadsheet::moveFrom(Spreadsheet& src) noexcept
{
// 데이터에 대한 얕은 복제
mWidth = src.mWidth;
mHeight = src.mHeight;
mCells = src.mCells;

// 소유권이 이전 됐기 때문에 소스 객체를 리셋한다.
src.mWidth = 0;
src.mHeight = 0;
src.mCells = nullptr;
}

// 이동 생성자
Spreadsheet::Spreadsheet(Spreadsheet&& src) noexcept
{
moveFrom(src);
}

// 이동 대입 연산자
Spreadsheet& Spreadsheet::operator=(Spreadsheet&& rhs) noexcept
{
// 자기 자신을 대입하는지 확인한다.
if (this == &rhs)
{
return *this;
}

// 예전 메모리를 해제한다.
cleanup();

moveFrom(rhs);

return *this;
}

이동 생성자와 이동 대입 연산자는 모두 mCells에 대한 메모리 소유권을 원본 객체에서 새로운 객체로 이동 시킨다. 그리고 원본 객체의 소멸자가 이 메모리를 해제하지 않도록 원본 객체의 mCells 포인터를 널 포인터로 리셋한다. 이 시점에서는 이미 메모리에 대한 소유권이 새 객체로 이동했기 때문이다.

당연한 말이지만 이동 의미론은 원본 객체를 삭제할 때만 유용하다. 이동 생성자와 이동 대입 연산자도 일반 생성자와 복제 대입 연산자와 마찬가지로 명시적으로 삭제하거나 디폴트로 만들 수 있다.

사용자가 클래스에 복제 생성자, 복제 대입 연산자, 이동 대입 연산자, 소멸자를 직접 선언하지 않았다면 컴파일러가 디폴트 이동 생성자를 만들어준다. 또한 사용자가 클래스에 복제 생성자, 이동 생성자, 복제 대입 연산자, 소멸자를 직접 선언하지 않았다면 컴파일러는 디폴트 이동 대입 연산자를 만들어준다.

Note) 클래스에 동적 할당 메모리를 사용하는 코드를 작성했다면 소멸자, 복제 생성자, 이동 생성자, 복제 대입 연산자, 이동 대입 연산자를 반드시 구현한다. 이를 5의 규칙(Rule of Five)라 부른다.

객체 데이터 멤버 이동하기

moveFrom() 메서드는 데이터 멤버 세 개를 직접 대입한다. 세 값이 기본 타입이기 때문이다. 반면 데이터 멤버가 객체라면 std::move()로 이들 객체를 이동시켜야 한다. Spreadsheet 클래스에 mName이란 이름의 std::string 타입 데이터 멤버가 있다고 하자. 그러면 moveFrom() 메서드를 다음과 같이 구현한다.

void Spreadsheet::moveFrom(Spreadsheet& src) noexcept
{
// 객체 데이터 멤버를 이동시킨다.
mName = std::move(src.mName);

// 이동 대상
// 데이터에 대한 얕은 복제
mWidth = src.mWidth;
mHeight = src.mHeight;
mCells = src.mCells;

// 소유권이 이전됐으므로 원본 객체를 초기화 한다.
src.mWidth = 0;
src.mHeight = 0;
src.mCells = nullptr;
}
swap() 함수로 구현한 이동 생성자와 이동 대입 연산자

앞서 이동 생성자와 이동 대입 연산자를 구현할 때 데이터 멤버를 모두 얕은 복제로 이동시키는 작업을 moveFrom() 헬퍼 메서드로 처리했다. 이렇게 구현하면 Spreadsheet 클래스에 데이터 멤버를 새로 추가할 때 swap() 함수와 moveFrom() 메서드를 동시에 수정해야 한다. 만약 둘 중 어느 하나라도 깜박 잊고 수정하지 않으면 버그가 발생한다. 이런 버그가 발생하지 않게 하려면 이동 생성자와 이동 대입 연산자를 디폴트 생성자와 swap() 함수로 구현한다.

가장 먼저 Spreadsheet 클래스에 디폴트 생성자부터 추가한다. 이 클래스의 사용자가 디폴트 생성자를 직접 사용할 일은 없기 때문에 private로 지정한다.

class Spreadsheet
{
private:
Spreadsheet() = default;
// 나머지 코드 생략
}

다음으로 cleanup()과 moveFrom() 헬퍼 메서드를 삭제한다. cleanup() 메서드에 있던 코드를 소멸자로 옮긴다. 그러면 이동 생성자와 이동 대입 연산자를 다음과 같이 작성할 수 있다.

Spreadsheet::Spreadsheet(Spreadsheet&& src) noexcept : Spreadsheet()
{
swap(*this, src);
}

Spreadsheet& Spreadsheet::operator=(Spreadsheet&& rhs) noexcept
{
Spreadsheet temp(std::move(rhs));
swap(*this, temp);
return *this;
}

이동 생성자는 먼저 디폴트 생성자에 작업을 위임한다. 그런 다음 디폴트 생성자가 만든 *this를 원본 객체와 맞바꾼다. 이동 대입 연산자는 먼저 rhs로 이동 생성해서 Spreadsheet에 대한 로컬 인스턴스를 만든다. 그러고 나서 이동 생성된 로컬 Spreadsheet 인스턴스를 *this와 맞바꾼다.

이동 생성자와 이동 대입 연산자를 디폴트 생성자와 swap() 함수로 구현하면 앞서 moveFrom()으로 구현했던 것보다 효율성이 떨어질 수 있다. 하지만 코드가 줄고, 클래스에 데이터 멤버를 새로 추가할 때 swap()만 수정하면 되기 때문에 버그 발생 확률을 낮출 수 있다는 장점이 있다.

Spreadsheet의 이동 연산자 테스트

앞서 작성한 Spreadsheet의 이동 생성자와 이동 대입 연산자를 다음과 같이 테스트해 보자

Spreadsheet createObject()
{
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 = createObject();

Spreadsheet s2(5, 6);
s2 = s;
return 0;
}

vector는 객체를 추가할 때마다 동적으로 커진다. 이렇게 할 수 있는 이유는 필요에 따라 더 큰 덩어리의 메모리를 할당해서 기존 vector에 있던 객체를 복제하거나 이동하기 때문이다. 이때 이동 생성자가 정의돼 있으면 컴파일러는 해당 객체를 복제하지 않고 이동시킨다. 이처럼 이동 방식으로 옮기기 때문에 깊은 복제를 수행할 필요가 없어서 훨씬 효율적이다.

moveFrom() 메서드로 구현한 Spreadsheet의 모든 생성자와 대입 연산자에 출력 문장을 추가하면 다음과 같은 결과를 볼 수 있다. (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)
Copy assignment operator (10)
Normal constructor (11)
Copy constructor (12)

반복문을 처음 실행할 때는 vector가 비어 있다. 반복문에 나온 다음 코드를 살펴보자.

vec.push_back(Spreadsheet(100, 100));

이 문장에서 일반 생성자 (1)을 호출하여 Spreadsheet 객체를 새로 생성한다. 그리고 새로 들어온 객체를 담을 수 있도록 이 vector의 공간을 적절히 조정한다. 그런 다음 이동 생성자 (2)를 호출해서 방금 생성된 Spreadsheet 객체를 vector로 이동시킨다.

반복문을 두 번째 실행할 때 Spreadsheet 객체(두 번째 객체)가 다시 생성되면서 일반 생성자 (3)을 호출한다. 이 시점에서 vector는 원소를 하나만 가지고 있다. 따라서 두 번째 객체를 담을 수 있도록 공간을 다시 조정한다. vector의 크기가 변했기 때문에 이전에 추가한 원소를 새로 크기를 조정한 vector로 이동시켜야 한다. 그래서 이전에 추가한 원소마다 이동 생성자가 호출된다. 현재는 vector에 원소가 하나 뿐이어서 이동 생성자 (4)도 한 번만 호출된다. 마지막으로 새로 생성한 Spreadsheet 객체를 이 객체의 이동 생성자 (5)를 통해 vector로 이동시킨다.

다음에는 Spreadsheet 객체 s를 일반 생성자 (6)을 사용해서 생성한다. createObject() 함수는 임시 Spreadsheet 객체를 일반 생성자 (7)로 생성해서 리턴하며 그 결과를 변수 s에 대입한다. 이렇게 대입한 뒤에는 createObject() 함수로 생성한 임시 객체가 사라지기 때문에 컴파일러는 일반 복제 대입 연산자가 아닌 이동 대입 연산자 (8)를 호출한다. 

이어서 s2라는 이름으로 Spreadsheet 객체를 하나 더 만든다. 이번에도 일반 생성자 (9)가 호출된다. s2=s라는 대입문으로부터 복제 대입 연산자 (10)이 호출된다. 우변의 객체는 임시 객체가 아닌 이름 있는 객체이기 때문이다. 

이 복제 대입 연산자는 임시 복제본을 생성하는데, 여기서 복제 생성자를 호출한다. 호출된 복제 생성자는 먼저 일반 생성자를 호출하고 나서 복제 작업을 수행한다. (11, 12)

Spreadsheet 클래스에 이동 의미론을 구현하지 않으면 이동 생성자와 이동 대입 연산자를 호출하는 부분은 모두 복제 생성자와 복제 대입 연산자로 대체된다. 앞의 예제에서 반복문에 있는 Spreadsheet 객체에 담긴 원소는 10,000(100 x 100)개다. 

Spreadsheet의 이동 생성자와 이동 대입 연산자를 구현할 때는 메모리를 할당할 필요가 없지만, 복제 생성자와 복제 대입 연산자를 구현할 때는 각각 101개를 할당한다. 이처럼 이동 의미론을 적용하면 특정한 상황에서 성능을 크게 높일 수 있다.

이동 의미론으로 swap 함수 구현하기

이동 의미론으로 성능을 높이는 또 다른 예제로 두 객체를 스왑하는 swap() 함수를 살펴보자. 다음의 swapCopy() 함수는 이동 의미론을 적용하지 않았다.

void swapCopy(T& a, T& b)
{
T temp(a);
a = b;
b = temp;
}

먼저 a를 temp에 복제한 뒤, b를 a에 복제하고, 마지막으로 temp를 b에 복제한다. 그런데 만약 T가 복제하기에 상당히 무거우면 성능이 크게 떨어진다. 이럴 때는 다음과 같이 이동 의미론을 적용해서 복제가 발생하지 않도록 구현한다.

void swapCopy(T& a, T& b)
{
T temp(std::move(a));
a = std::move(b);
b = std::move(temp);
}

표준 라이브러리의 std::swap()이 바로 이렇게 구현됐다.

영의 규칙

앞에서 5의 규칙을 설명ㅎ했다. 지금까지 설명한 내용은 모두 이러한 다섯 가지 특수 멤버 함수(소멸자, 복제 생성자, 이동 생성자, 복제 대입 연산자, 이동 대입 연산자)를 구현하는 방법에 대한 것이다. 여기에 최신 C++은 일명 영의 규칙(0의 규칙)이란 것도 추가했다.

영의 규칙이란 앞서 언급한 다섯 가지 특수 멤버 함수를 구현할 필요가 없도록 클래스를 디자인해야 한다는 것이다. 이렇게 하려면 먼저 예전 방식대로 메모리를 동적으로 할당하지 말고 표준 라이브러리 컨테이너와 같은 최신 구문을 활용해야 한다. 

예컨대 Spreadsheet 클래스에서 SpreadsheetCell** 이란 데이터 멤버 대신 vector<vecotr<SpreadsheetCell>> 을 사용한다. 이 벡터는 메모리를 자동으로 관리하기 때문에 앞서 언급한 다섯 가지 특수 멤버 함수가 필요 없다.

메서드의 종류

static 메서드

메서드도 데이터 멤버처럼 특정 객체 단위가 아닌 클래스 단위로 적요오디는 것이 있다. 이를 static(정적, 스태틱) 메서드라 부르며 데이터 멤버를 정의하는 단계에 함께 작성한다.

(이하 설명 생략)

const 메서드

const 객체란 값이 바뀌지 않는 객체를 말한다. const 객체나 이에 대한 레퍼런스 또는 포인터를 사용할 때는 그 객체의 데이터 멤버를 절대로 변경하지 않는 메서드만 호출할 수 있다. 그렇지 않으면 컴파일 에러가 발생한다.

이처럼 어떤 메서드가 데이터 멤버를 변경하지 않는다고 보장하고 싶을 때 const 키워드를 붙인다. 예컨대 데이터 멤버를 변경하지 않는 메서드를 SpreadsheetCell 클래스에 추가하려면 다음과 같이 메서드를 const로 선언한다.

class SpreadsheetCell
{
public:
double getValue() const;
std::string getString() const;
// 나머지 코드 생략
}

const는 메서드 프로토타입의 일부분이기 때문에 다음과 같이 메서드를 구현하는 코드에서도 반드시 적어야 한다.

double SpreadsheetCell:getValue() const
{
return mValue;
}

std::string SpreadsheetCell::getString() const
{
return doubleToString(mValue);
}

(이하 설명 생략)

mutable 데이터 멤버

때로는 의미상으로 const인 메서드에서 객체의 데이터 멤버를 변경하는 경우가 있다. 이렇게 해도 사용자 데이터에 아무런 영향을 미치지 않지만 엄연히 수정하는 것이기 때문에 이런 메서드를 const로 선언하면 컴파일 에러가 발생할 수 있다.

예컨대 스프레드시트 애플리케이션을 프로파일링해서 여기에 담긴 데이터를 얼마나 자주 읽는지 확인한다고 하자. 가장 간단한 방법은 SpreadsheetCell 클래스에 카운터를 두고 getValue()나 getString()이 호출될 때마다 카운터를 업데이트하는 식으로 호출 횟수를 기록하는 것이다. 

하지만 이런식으로 하면 컴파일러 입장에서 볼 때 non-const 메서드가 되어버리기 때문에 의도에 맞지 않는 방법이다. 이럴 때 횟수를 세는 카운터 변수를 mutable로 선언해서 컴파일러에 이 변수를 const 메서드에서 변경할 수 있다고 알려주면 된다. 이렇게 SpreadsheetCell 클래스를 수정하면 다음과 같다.

class SpreadsheetCell
{
private:
double mValue = 0;
mutable size_t mNumAccesses = 0;
}

getValue()와 getString()은 다음과 같이 정의한다.

double SpreadsheetCell::getValue() const
{
mNumAccesses++;
return mValue;
}

std::string SpreadsheetCell::getString() const
{
mNumAccesses++;
return doubleToString(mValue);
}

메서드 오버로딩

메서드나 함수는 매개변수의 타입이나 개수만 다르게 지정해서 이름이 같은 함수나 메서드를 여러 개 정의할 수 있는데 이를 오버로딩이라 부른다.

(이하 설명 생략)

컴파일러가 set()을 호출하는 코드를 발견하면 매개변수 정보를 보고 어느 버전의 set()을 호출할지 결정한다. 매개변수가 string_view 타입이면 string 버전의 set()을 호출하고, double 타입이면 double 버전의 set()을 호출한다. 이를 오버로딩 결정(overload resolution)이라 한다.

getValue()와 getString()도 get()으로 통일하고 싶을 수 있지만, 여기서는 오버로딩을 적용할 수 없다. C++는 메서드의 리턴 타입에 대한 오버로딩은 지원하지 않는데, 호출할 메서드의 버전을 정확히 결정할 수 없기 때문이다.

const 기반 오버로딩

const를 기준으로 오버로딩할 수도 있다. 예컨대 메서드를 두 개 정의할 때 이름과 매개변수는 같지만 하나는 const로 선언하고, const 객체에서 이 메서드를 호출하면 const 메서드가 실행되고, non-const 객체에서 호출하면 non-const 메서드가 실행된다.

간혹 const 버전과 non-const 버전의 구현 코드가 똑같을 때가 있다. 이러한 코드 중복을 피하려면 스콧 마이어가 제시한 const_cast() 패턴을 적용한다. 예컨대 Spreadsheet 클래스에 non-const SpreadsheetCell 레퍼런스를 리턴하는 getCellAt() 메서드가 있을 때 먼저 다음과 같이 SpreadsheetCell 레퍼런스를 const로 리턴하는 const 버전의 getCellAt() 메서드를 오버로딩 한다.

class Spreadsheet
{
  public:
    SpreadsheetCell& getCellAt(size_t x, size_t y);
  const SpreadsheetCell& getCellAt(size_t x, size_t y) const;
// 코드 생략  
}

그러고 나서 메서드를 정의할 때는 스콧 마이어의 const_cast() 패턴에 따라 const 버전은 예전대로 구현하고, non-const 버전은 const 버전을 적절히 캐스팅해서 호출하는 방식으로 구현한다. 구체적으로 설명하면 std::as_const()로 *this를 const Spreadsheet&로 캐스팅하고, const 버전의 getCellAt()을 호출한 다음 const_cast()를 적용해 리턴된 결과에서 const를 제거하는 방식으로 처리한다.

const SpreadsheetCell& Spreadsheet::getCellAt(size_t x, size_t y) const
{
verifyCoordinate(x, y);
return mCells[x][y];
}

SpreadsheetCell& Spreadsheet::getCellAt(size_t x, size_t y)
{
return const_cast<SpreadsheetCell&>(std::as_const(*this).getCellAt(x, y));
}

std::as_const() 함수는 C++ 17부터 추가됐으므로 현재 자신이 사용하는 컴파일러에서 이를 지원하지 않는다면 다음과 같이 static_cast()를 사용하라

return const_cast<SpreadsheetCell&>(static_cast<const Spreadsheet&>(*this).getCellAt(x, y));

이렇게 getCellAt() 메서드를 두 가지 버전으로 오버로딩하면 const로 지정한 Spreadsheet 객체와 non-const로 지정한 Spreadsheet 객체사 getCellAt() 메서드를 호출하는 방식을 통일시킬 수 있다.

명시적으로 오버로딩 제거하기

오버로딩된 메서드를 명시적으로 삭제할 수 있다. 그러면 특정한 인수에 대해서는 메서드를 호출하지 못하게 된다.

class MyClass
{
public:
void foo(int i);
}

이렇게 정의한 foo() 메서드를 다음과 같이 호출할 수 있다.

MyClass c;
c.foo(123);
c.foo(1.23);

컴파일러는 세 번째 줄을 처리할 때 double 값 1.23을 정수 1로 변환해서 foo를 호출한다. 이렇게 컴파일러가 마음대로 바꿔버리지 않게 하려면 다음과 같이 foo() 메서드의 double 버전을 명시적으로 삭제하도록 선언한다.

class MyClass
{
public:
void foo(int i);
void foo(double d) = delete;
}

인라인 메서드

C++는 메서드(또는 함수)를 별도의 코드 블록에서 구현해서 호출하지 않고 메서드를 호출하는 부분에서 곧바로 구현(정의) 코드를 작성하는 방법도 제공한다. 이를 인라이닝(inlining)이라 부르며, 이렇게 구현한 메서드를 인라인(inline) 메서드라 부른다. 일반적으로 #define 매크로보다 안리안 메서드를 사용하는 것이 더 안전하다.

인라인 메서드를 정의하려면 메서드 정의(구현) 코드에서 이름 앞에 inline 키워드를 지정한다. 예컨대 SpreadsheetCell 클래스의 접근자(accessor)를 인라인 메서드로 만들고 싶으면 다음과 같이 정의한다.

inline double SpreadsheetCell::getValue() const
{
mNumAccesses++;
return mValue;
}

inline std::string SpreadsheetCell::getString() const
{
mNumAccesses++;
return doubleToString(mValue);
}

그러면 컴파일러는 getValue()와 getString()을 호출하는 부분을 함수 호출로 처리하지 않고 그 함수의 본문을 곧바로 집어넣는다. 여기서 주의할 점은 컴파일러는 inline이란 키워드를 단지 참고만 한다는 것이다. 성능에 문제가 될 것 같으면 그냥 무시할 수도 있다.

한 가지 제약사항이 있다. 인라인 메서드(또는 함수)를 호출하는 코드에서 이를 정의하는 코드에 접근할 수 있어야 한다. 당연한 말이지만 컴파일러가 메서드 정의 코드를 볼 수 있어야 메서드 호출 부분을 본문에 나온 코드로 대체할 수 있기 때문이다. 따라서 인라인 메서드는 반드시 프로토타입과 구현 코드를 헤더 파일에 작성한다.

C++은 inline 키워드를 사용하지 않고 클래스 정의에서 곧바로 메서드 정의 코드를 작성하면 인라인 메서드로 처리해 준다. 다음은 SpreadsheetCell 클래스를 이렇게 정의한 예이다.

class SpreadsheetCell
{
public:
double getValue() const { mNumAccesses++; return mValue; }
std::string getString() const
{
mNumAccesses++;
return doubleToString(mValue);
}
}

메서드를 inline으로 선언할 때 발생하는 파급 효과를 제대로 이해하지 않은 채 무작정 안리안 메서드로 구현하는 프로그래머가 많다. 컴파일러는 메서드나 함수에 선언한 inline 키워드를 단지 참고만 할 뿐이다. 실제로는 간단한 메서드나 함수만 인라인으로 처리한다. 컴파일러가 볼 때 인라인으로 처리하면 안 될 메서드를 inline으로 선언하면 그냥 무시한다. 최신 컴파일러는 코드 블롯(code bloat, 코드 비대화)과 같은 몇 가지 기준에 따라 메서드나 함수를 인라인으로 처리할지 판단해서 큰 효과가 없다면 인라인으로 처리하지 않는다.

디폴트 인수

메서드 오버로딩과 비슷한 기능으로 디폴트 인수(default argument)라는 것도 있다. 이 기능을 이용하면 함수나 메서드의 프로토타입에 매개변수의 기본값을 지정할 수 있다. 사용자가 다른 값으로 지정한 인수를 전달하면 디폴트 값을 무시한다. 반면 사용자가 인수를 지정하지 않으면 디폴트값을 적용한다.

여기에 한 가지 제약사항이 있는데 매개변수에 디폴트값을 지정할 때는 반드시 오른쪽 끝의 매개변수부터 싲가해서 중간에 건너뛰지 않고 연속적으로 나열해야 한다. 그렇지 않으면 컴파일러는 중간에 빠진 인수에 디폴트값을 매칭할 수 없다.

class Spreadsheet
{
public:
Spreadhsheet(size_t width = 100, size_t height = 100);
}

(이하 설명 생략)

데이터 멤버의 종류

static 데이터 멤버

클래스의 모든 객체마다 똑같은 변수를 가지는 것은 비효율적이거나 바람직하지 않을 수 있다. 데이터 멤버의 성격이 객체보다 클래스에 가깝다면 객체마다 그 멤버의 복사본을 가지지 않는 것이 좋다.

예컨대 스프레드시트마다 숫자로된 ID를 부여한다고 하자. 그러면 객체를 새로 생성할 때마다 0번부더 차례로 ID 값을 할당하게 된다. 여기서 스프레드시트 수를 저장하는 카운터는 사실 Spreadsheet 클래스에 속해야 한다. 이 값을 Spreadsheet 객체마다 가지면 각 객체마다 저장된 값을 동기화해야 하기 때문이다.

C++에서 제공하는 static 데이터 멤버를 이용하면 이 문제를 해결할 수 있다. static 데이터 멤버는 객체가 아닌 클래스에 속한다. 이는 전역 변수와 비슷하지만 잣니이 속한 클래스 범위를 벗어날 수 없다.

static 클래스 멤버를 정의하면 소스 파일에서 이 멤버에 대한 공간을 할당해야 하는데, 이 작업은 주로 해당 클래스의 메서드를 정의하는 소스 파일에서 처리한다. 선언과 동시에 초기화해도 되지만 일반 변수나 데이터 멤버와 달리 기본적으로 0으로 초기화 된다. static 포인터는 nullptr로 초기화된다.

(이하 설명 생략)

인라인 변수

C++ 17부터 static 데이터 멤버를 inline으로 선언할 수 있다. 그러면 소스 파일에 공간을 따로 할당하지 않아도 된다. 예컨대 다음과 같다.

class Spreadsheet
{
private:
static inline size_t sCounter = 0;
}

여기서 inline이란 키워드에 주목한다 클래스에서 이렇게 선언하면 소스 파일에 다음과 같이 적지 않아도 된다.

size_t Spreadsheet::sCounter;

클래스 메서드에서 static 데이터 멤버 접근하기

클래스 메서드 안에서는 static 데이터 멤버를 마치 일반 데이터 멤버인 것처럼 사용한다.

(이하 설명 생략)

메서드 밖에서 static 데이터 멤버 접근하기

static 데이터 멤버에 대해서도 접근 제한자/지정자를 적용할 수 있다.

(이하 설명 생략)

const static 데이터 멤버

클래스에 정의된 데이터 멤버를 const로 선언하면 멤버가 생성되고 초기화된 후에는 변경할 수 없게 만들 수 있다. 

(이하 설명 생략)

레퍼런스 데이터 멤버

스프레드시트 애플리케이션 전체를 제어하기 위한 기능을 위해 SpreadsheetApplication 클래스를 작성하자. 한 애플리케이션에서 여러 스프레드시트를 관리하기 때문에 스프레드시트와 통신할 수 있어야 한다. 마찬가지로 스프레드시트마다 애플리케이션 객체에 대한 레퍼런스를 저장할 수도 있다. 그러려면 Spreadsheet 클래스가 SpreadsheetApplication 클래스에 대해 알고 있어야 하고 그 반대도 마찬가지다.

그런데 이렇게 하면 순환 참조가 발생해서 #include 문만으로는 해결할 수 없다. 이럴 때는 헤더 파일 중 어느 한 곳에서 포워드 선언(forward declaration, 전방 선언)을 하면 된다. SpreadsheetApplication에 대해 알 수 있도록 포워드 선언을 적용해서 Spreadsheet 클래스를 다시 정의하면 다음과 같다. 포워드 선언을 하면 컴파일과 링크 속도를 높일 수 있다. (이에 대해선 11장에서 자세히 설명하겠다)

class SpreadsheetApplication;  // 포워드 선언

class Spreadsheet
{
public:
Spreadsheet(size_t width, size_t height, SpreadsheetApplication& theApp);

priavte:
SpreadsheetApplication& mTheApp;
}

SpreadsheetApplication 레퍼런스를 데이터 멤버로 추가했다. 이때 포인터보다 레퍼런스를 사용하는 것이 바람직한데, Spreadsheet는 항상 SpreadsheetApplication을 참조하기 때문이다. 포인터를 사용하면 이런 고나계를 보장할 수 없다.

여기서 애플리케이션에 대한 레퍼런스를 저장한 이유는 레퍼런스를 데이터 멤버로 사용할 수 있다는 것을 보여주기 위해서다. 사실 Spreadsheet와 SpreadsheetApplication 클래스를 이렇게 묶기 보다는 MVC 패러다임에 따라 구성하는 것이 바람직하다.

이렇게 선언한 애플리케이션 레퍼런스는 Spreadsheet 생성자로 전달된다. 레퍼런스는 실제로 가리키는 대상 없이는 존재할 수 없다. 따라서 생성자 이니셜라이저에서 mTheApp의 값을 반드시 지정해야 한다.

Spreadsheet::Spreadsheet(size_t width, size_t height, SpreadsheetApplication& theApp) 
: mID(sCounter++), mWidth(std::min(width, kMaxWidth)), mHeight(std::min(height, kMaxHeight)), mTheApp(theApp)
{
// 코드 생략
}

이 레퍼런스 멤버를 반드시 복제 생성자에서도 초기화해야 한다. 이 ㅈ가업은 자동으로 처리된다. Spreadsheet 복제 생성자는 이 레퍼런스 멤버를 초기화하는 작업을 다른 비복제 생성자에 위임하기 때문이다.

여기서 명심할 점은 레퍼런스를 초기화한 뒤에는 그 레퍼런스가 가리키는 객체를 변경할 수 없다는 것이다. 대입 연산자로 레퍼런스에 값을 대입할 수 없다. 때로는 현재 클래스에서 레퍼런스 데이터 멤버에 대해 대입 연산자를 제공할 수 없을 수도 있다. 이때는 대입 연산자가 deleted로 지정된다.

const 레퍼런스 데이터 멤버

일반 레퍼런스와 마찬가지로 레퍼런스 멤버도 const 객체를 가리킬 수 있다. 예컨대 Spreadsheet가 애플리케이션 객체에 대해 const 레퍼런스만 가지도록 해보자. 다음과 같이 mTheApp을 const 레퍼런스로 선언하도록 클래스 정의를 간단히 수정하면 된다.

class Spreadsheet
{
public:
Spreadsheet(size_t width, size_t height, const SpreadsheetApplication& theApp);

priavte:
const SpreadsheetApplication& mTheApp;
}

const 레퍼런스를 이용해서 정의할 때와 non-const 레퍼런스로 정의할 때 차이는 매우 크다. const 레퍼런스 SpreadsheetApplication 데이터 멤버는 SpreadsheetApplication 객체의 const 메서드만 호출할 수 있다. const 레퍼런스에서 non-const 메서드를 호출하면 컴파일 에러가 발생한다.

레퍼런스 멤버를 static이나 static const로 지정할 수도 있는데, 이렇게 사용할 일은 거의 없다.

중첩 클래스

클래스 정의에 데이터 멤버와 메서드 뿐만 아니라 중첩 클래스와 구조체, 타입 앨리어스(typedef), 열거 타입(enum)도 선언할 수 있다. 이들에 대한 스코프는 모두 그 클래스로 제한된다. public 으로 선언한 멤버를 클래스 외부에서 접근할 때는 ClassName::과 같은 스코프 지정 연산자를 붙여야 한다.

또한 클래스 안에서 다른 클래스를 정의할 수도 있다.

class Spreadsheet
{
public:
class Cell
{
public:
Cell() = default;
Cell(double initialValue);
};
Spreadsheet(size_t width, size_t height, const SpreadsheetApplication& theApp);
}

이렇게 Spreadsheet 클래스 안에 Cell 클래스를 정의하더라도 Cell을 얼마든지 Spreadsheet 클래스 밖에서 참조할 수 있다. 물론 그 앞에 Spreadsheet::라고 스코프를 지정해야 한다. 이 규칙은 메서드 정의 규칙에도 똑같이 적용 된다. 예컨대 Cell의 생성자 중에서 double 타입 인수를 받는 버전을 다음과 같이 정의할 수 있다.

Spreadsheet::Cell::Cell(double initialValue) : mValue(initialValue)
{
}

이렇게 스코프 지정 연산자를 붙이는 규칙은 Spreadsheet 클래스 안에 있는 메서드의 리턴 타입에도 적용된다. 단 매개변수에는 적용되지 않는다.

Spreadsheet::Cell& Spreadsheet::getCellAt(size_t x, size_t y)
{
verifyCoordinate(x, y);
return mCells[x][y];
}

중첩 클래스로 선언한 Cell의 구체적인 정의 코드를 Spreadsheet 클래스 안에 직접 작성하면 Spreadsheet 클래스 저으이 코드가 너무 길어지므로 다음과 같이 선언만 하고 구체적인 정의는 따로 작성하는 것이 좋다.

class Spreadsheet
{
public:
class Cell;
Spreadsheet(size_t width, size_t height, const SpreadsheetApplication& theApp);
}

클래스에 열거 타입 정의하기

클래스 안에서 상수를 여러 개 정의할 때는 각각을 #define 문으로 작성하지 말고 열거 타입을 사용하는 것이 좋다. 예컨대 SpreadsheetCell 클래스에 셀 컬러를 지정하는 기능을 추가하려면 다음과 같이 선언한다.

class Spreadsheet
{
public:
enum class Color { Red = 1, Green, Blue, Yellow };
void setColor(Color color);
Color getColor() const;
private:
Color mColor = Color::Red;
}

(이하 설명 생략)

연산자 오버로딩

예제: SpreadsheetCell에 대한 덧셈 구현

첫 번째 버전: add 메서드

add() 메서드를 선언해서 두 셀을 더해서 그 결과를 새로 생성한 제 3의 셀에 담아서 리턴하는 내용

(설명 생략)

두 번째 버전: operator+ 오버로딩으로 구현하기

클래스에서 operator를 선언해서 덧셈 연산을 구현

class SpreadsheetCell
{
public:
SpreadsheetCell operator+(const SpreadsheetCell& cell) const;
}

Note) operator+에서 operator와 + 사이에 공백을 넣어도 무방하다.

SpreadsheetCell SpreadsheetCell::operator+(const SpreadsheetCell& cell) const
{
return SpreadsheetCell(getValue() + cell.getValue());
}

C++ 컴파일러가 프로그램을 파싱할 때 +, -, =, << 와 같은 연산자를 발견하면 여기 나온 것과 매개변수가 일치하는 operator+, operator-, operator=, operator<<라는 이름의 함수나 메서드가 있는지 확인한다.

예컨대 컴파일러가 다음과 같이 작성된 문장을 발견하면 다른 SpreadsheetCell 객체를 인수로 받는 operator+란 메서드가 있는지 아니면 SpreadsheetCell 객체 두 개를 인수로 받는 operator+란 이름의 전역 함수가 있는지 찾는다.

SpreadsheetCell aThirdCell = myCell + anotherCell;

SpreadsheetCell 클래스에 operator+ 메서드가 있다면 앞 문장을 다음과 같이 변환한다.

SpreadsheetCell aThirdCell = myCell.operator+(anotherCell);

단 여기서 operator+의 매개변수가 반드시 이 메서드가 속한 클래스와 같은 타입의 객체만 받을 필요는 없다. SpreadsheetCell에서 operator+를 정의할 때 Spredsheet 매개변수를 받아서 SpreadsheetCell에 더하도록 작성해도 된다.

또한 operator+의 리턴 타입도 마음껏 정할 수 있다. 함수 오버로딩을 떠올려보면 함수의 리턴 타입을 따지지 않았다. 연산자 오버로딩도 일종의 함수 오버로딩이다.

묵시적 변환

놀랍게도 operator+를 정의하면 셀끼리 더할 수 있을 뿐 아니라 셀에 string_view, double, int 같은 값도 더할 수 있다.

SpreadsheetCell myCell(4), aThirdCell;
string str = "hello";
aThirdCell = myCell + string_view(str);
aThirdCell = myCell + 5.6;
aThirdCell = myCell + 4;

이렇게 할 수 있는 이유는 컴파일러가 단순히 operator+만 찾는데 그치지 않고 타입을 정확히 변환할 수 있는 방법도 찾기 때문이다. 또한 지정된 타입을 변환할 방법도 찾는다. 생성자는 이렇게 타입을 변환하는 역할을 하기에 적합하다.

앞서 본 예제에서 컴파일러가 자신에 double 값을 더하는 SpreadsheetCell을 발견하면 먼저 double 타입의 인수를 받는 SpreadsheetCell 생성자를 찾아서 임시 SpreadsheetCell 객체를 생성한 뒤 operator+로 전달한다. SpreadsheetCell에 string_view를 더하는 문장을 발견할 때도 마찬가지다. string_view 타입의 인수를 받는 SpreadsheetCell 생성자를 호출하고 SpreadsheetCell 객체를 임시로 생성해서 operator+로 전달한다.

이렇게 묵시적 변환을 활용하면 편리할 때가 많다. 하지만 앞서 본 예제처럼 SpreadsheetCell에 string_view를 더하는 것은 상식적으로 맞지 않다. SpreadsheetCell에 string_view를 묵시적으로 변환하지 않게 하려면 생성자 앞에 explicit 키워드를 붙인다.

class SpreadsheetCell
{
public:
SpreadsheetCell() = default;
SpreadsheetCell(double initialValue);
explicit SpreadsheetCell(std::string_view initialValue);
}

explicit 키워드는 클래스를 정의하는 코드에서만 지정할 수 있다. 또한 인수를 하나만 지정해서 호출할 수 있는 생성자에만 적합하다. 매개변수가 하나뿐인 생성자나 매개변수를 여러 개 받더라도 디폴트 값이 지정된 생성자에만 붙일 수 있다.

묵시적 변환을 위해 생성자를 선택하는 과정에서 성능이 떨어질 수 있다. 항상 임시 객체를 생성하기 때문이다. double 값을 더할 때 이렇게 임시 객체가 생성되지 않게 하려면 다음과 같이 operator+도 함께 정의한다.

SpreadsheetCell SpreadsheetCell::operator+(double rhs) const
{
return SpreadsheetCell(getValue() + rhs);
}

세 번째 버전: operator+를 전역 함수로 구현하기

묵시적 변환을 활용하면 SpreadsheetCell 객체에 int나 double을 더하도록 operator+를 정의할 수 있다. 하지만 이렇게 하면 다음과 같이 교환 법칙이 성립하지 않는다.

aThirdCell = myCell + 4;  // 정상 작동
aThirdCell = myCell + 5.6; // 정상 작동
aThirdCell = 4 + myCell; // 컴파일 에러
aThirdCell = 5.6 + myCell; // 컴파일 에러

묵시적 변환은 SpreadsheetCell 객체가 연산자의 좌변에 있을 때만 적용된다. 우변에 있을 때는 적용할 수 없다. operator+를 반드시 SpreadsheetCell 객체에 대해 호출해야 하고 이를 위해 객체가 항상 operator+의 좌변에 나와야 하기 때문이다. C++의 문법이 원래 이래서 어쩔 수 없다.

하짐나 클래스에 정의했던 operator+를 전역 함수로 만들면 가능하다. 전역 함수는 특정 객체에 종속적이지 않기 때문이다. 예컨대 다음과 같다.

SpreadsheetCell SpreadsheetCell::operator+(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs)
{
return SpreadsheetCell(lhs.getValue() + rhs.getValue());
}

전역 함수로 정의하려면 연산자를 헤더 파일에 선언해야 한다.

class SpreadsheetCell
{
// 코드 생략
}

SpreadsheetCell operator+(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);

이제 다음과 같이 덧셈을 적용할 수 있다.

aThirdCell = myCell + 4; // 정상 작동
aThirdCell = myCell + 5.6; // 정상 작동
aThirdCell = 4 + myCell; // 정상 작동
aThirdCell = 5.6 + myCell; // 정상 작동

그렇다면 다음과 같이 작성해도 될까?

aThirdCell = 4.5 + 5.5;

컴파일 에러 없이 제대로 작동하지만 앞에서 정의한 operator+가 호출되지 않고 double 타입에 적용되던 기존 덧셈 연산이 적용돼 다음과 같은 중간 코드 형태로만 변환된다.

aThirdCell = 10;

이 대입문을 셀에 대해 처리하게 하려면 우변에 SpreadsheetCell 객체가 있어야 한다. 따라서 컴파일러는 SpreadsheetCell 클래스에서 explicit이 지정되지 않았으면서 double 타입 인수를 받는 사용자 정의 새엇ㅇ자를 찾아서 double 값을 임시 SpreadsheetCell 객체로 변환한 뒤 대입 연산자를 호출한다.

산술 연산자 오버로딩

(내용 생략)

축약형 산술 연산자의 오버로딩

(내용 생략)

비교 연산자 오버로딩

(내용 생략)

연산자 오버로딩을 지원하는 타입 정의하기

(생략)

사실 거의 모든 연산자를 오버로딩할 수 있다. 대입, 기본 산술, 축약형 산술, 비교 연산자는 일부에 지나지 않는다. 스트림 추가(stream insertion) 및 추출(stream extraction) 연산자도 오버로딩 하면 편하다. 또한 연산자 오버로딩으로 할 수 있다고 전혀 생각하지 못했던 흥미로운 기능도 구현할 수 있다. 참고로 표준 라이브러리는 이러한 연산자 오버로딩 기능을 상당히 많이 사용하고 있다.

안정적인 인터페이스 만들기

인터페이스 클래스와 구현 클래스

인터페이스 클래스는 구현 클래스와 똑같이 public 메서드를 제공하되 구현 클래스 객체에 대한 포인터를 갖는 데이터 멤버 하나만 정의한다. 이를 핌플 이디엄(pimpl idiom, private implementation idiom) 또는 브릿지 패턴(bridge pattern)이라 부른다. 

인터페이스 클래스 메서드는 단순히 구현 클래스 객체에 있는 동일한 메서드를 호출하도록 구현한다. 그러면 구현 코드가 변해도 public 메서드로 구성된 인터페이스 클래스는 영향을 받지 않는다. 따라서 다시 컴파일할 ㅇ리이 줄어든다. 이렇게 정의된 인터페이스 클래스를 사용하는 클라이언트는 구현 코드만 변경됐다면 소스를 다시 컴파일할 필요가 없다. 여기서 주의해야 할 점은 인터페이스 클래스에 존재하는 유일한 데이터 멤버를 구현 클래스에 대한 포인터로 정의해야 제대로 효과를 발휘한다는 것이다. 데이터 멤버가 포인터가 아닌 값 타입이면 구현 클래스가 변경될 때마다 다시 컴파일 해야 한다.

Spreadsheet 클래스에 이 방식을 적용하려면 다음과 같이 Spreadsheet 클래스를 public 인터페이스 클래스로 정의한다.

#include "SpreadsheetCell.h"
#include <memory>

// 포워드 선언
class SpreadsheetApplication;

class Spreadsheet
{
public:
Spreadsheet(const SpreadsheetApplication& theApp, size_t width = kMaxwidth, size_t height = kMaxHeight);
Spreadsheet(const Spreadsheet& src);
~Spreadsheet();

Spreadsheet& operator=(const Spreadsheet& rhs);

void setCellAt(size_t x, size_t y, const SpreadsheetCell& cell);
SpreadsheetCell& getCellAt(size_t x, size_t y);

size_t getId() const;

static const size_t kMaxHeight = 100;
static const sizt_t kMaxWidtdh = 100;

friend void swap(Spreadhseeth& first, Spreadsheet& second) noexcept;

private:
class Impl;
std::unique_ptr<Impl> mImpl;
}

구현 코드는 Impl이란 이름으로 private 중첩 클래스로 정의한다. Spreadsheet 클래스 말고는 구현 클래스에 대해 알 필요가 없기 때문이다. 이렇게 하면 Spreadsheet 클래스는 Impl 인스턴스에 대한 포인터인 데이터 멤버 하나만 갖게 된다. public 메서드는 기존 Spreadsheet 클래스에 있던 것과 같다.

중첩 클래스인 Spreadsheet::Impl의 인터페이스는 기존 Spreadsheet 클래스와 거의 같다. 하지만 Impl 클래스는 Spreadsheet의 private 중첩 클래스이기 때문에 다음과 같이 두 Spreadsheet::Impl 객체를 맞바꾸는 전역 friend swap() 함수를 가질 수 없다.

friend void swap(Spreadsheet::Impl& fisrt, Spreadsheet::Impl& second) noexcept;

따라서 다음과 같이 Spreadsheet::Impl 클래스에서 swap()을 private 메서드로 정의해야 한다.

void swap(Impl& other) noexcept;

구현 코드는 간단하다. 단 중첩 클래스라는 점을 주의한다. 따라서 그냥 Impl::swap()이 아닌 Spreadsheet::Impl::swap()으로 지정해야 한다. 다른 멤버도 마찬가지다. swap() 메서드의 구현 코드는 다음과 같다.

void Spreadsheet::Impl::swap(Impl& other) noexcept
{
using std::swap;

swap(mWidth, other.mWidth);
swap(mHeight, other.mHeight);
swap(mCells, other.mCells);
}

Spreadsheet 클래스는 구현 클래스를 가리키는 unique_ptr을 가지고 있기 때문에 사용자 선언 소멸자가 있어야 한다. 이 소멸자는 특별히 할 일이 없기 때문에 구현 파일에 다음과 같이 디폴트로 지정한다.

Spreadsheet::~Spreadsheet() = default;

이를 통해 특수 멤버 함수를 클래스 정의 뿐만 아니라 구현 파일에서도 디폴트로 지정할 수 있다는 것을 알 수 있다.

setCellAt()이나 getCellAt() 과 같은 Spreadsheet의 메서드에 대한 구현 코드는 들어온 요청을 내부 Impl 객체로 그냥 전달하면 된다.

void Spreadsheet::setCellAt(size_t x, size_t y, const SpreadsheetCell& cell)
{
mImpl->setCellAt(x, y, cell)
}

SpreadsheetCell& Spreadsheet::getCellAt(size_t x, size_t y)
{
return mImpl->getCellAt(x, y);
}

이렇게 하려면 Spreadsheet의 생성자에서 반드시 Impl 객체를 생성하도록 구현해야 한다.

Spreadsheet::Spreadsheet(const SpreadsheetApplication& theApp, size_t width, size_t height)
{
mImpl = std::make_unique<Impl>(theApp, width, height);
}

Spreadsheet::Spreadsheet(const Spreadsheet& src)
{
mImpl = std::make_unique<Impl>(*src.mImpl);
}

복제 생성자 코드가 조금 이상해 보일 수 있다. 원본 스프레드시트(src)의 내부 Impl 객체를 복제해야 하기 때문이다. 복제 생성자는 Impl에 대한 포인터가 아닌 레퍼런스를 인수로 받는다. 따라서 mImpl 포인터를 역참조해서 객체 자체에 접근해야 생성자를 호출할 때 이 레퍼런스를 받을 수 있다.

Spreadsheet의 대입 연산자도 마찬가지로 내부 Impl의 대입 연산자로 전달한다.

Spreadsheet& Spreadsheet::operator=(const Spreadsheet& rhs)
{
*mImpl = *rhs.mImpl;
return *this;
}

대입 연산자의 첫 줄이 이상하게 보일 수 있다. Spreadsheet 대입 연산자는 현재 호출을 Impl의 대입 연산자로 포워딩해야 하는데, 이 연산자는 객체를 직접 복제할 때만 구동된다.

mImpl 포인터를 역참조 하면 강제로 직접 객체 대입 방식을 적용하기 때문에 Impl의 대입 연산자를 호출할 수 있다.

swap() 함수는 다음과 같이 단순히 데이터 멤버를 바꾸기만 한다.

void swap(Spreadsheet& first, Spreadsheet& second) noexcept
{
using std::sawp;
swap(first.mImpl, second.mImpl);
}

이렇게 인터페이스와 구현을 확실히 나누면 엄청난 효과를 얻을 수 있다. 처음에는 좀 번거롭지만 자연스레 이런 스타일로 작성할 수 있다. 

여기서 설명한 것처럼 인터페이스와 구현을 분리하지 않고, 추상 인터페이스(abstract interface), 즉 가상 메서드로만 구성된 인터페이스를 정의한 뒤 이를 구현하는 클래스를 따로 작성해도 된다.

(인터페이스를 다루는 부분이 하도 이상해서 찾아보니 C++은 명시적으로 interface를 지원하지 않는 것 같다. 그래서 좀 이상하게 사용하는 듯. 여기서 소개하는 방식은 너무 번거로운데 10장에서 추상 클래스 형식으로 해도 된다고 하니 그게 내가 알고 있던 interface를 다루는 것과 비슷할 듯)

[ssba]

The author

Player가 아니라 Creator가 되려는 자/ suyeongpark@abyne.com

댓글 남기기

이 사이트는 스팸을 줄이는 아키스밋을 사용합니다. 댓글이 어떻게 처리되는지 알아보십시오.