본문 바로가기
프로그래밍/C & C++

[C++] 클래스(Class) 심화편 (1)

by 별준 2022. 2. 11.

References

Contents

  • friend
  • 객체 동적 할당
  • 이동 생성자, 이동 대입 연산자
  • 우측값 레퍼런스, Move Semantics 구현
  • std::exchange
  • Rule of Zero

[C++] 클래스(Class) 기본편

지난 포스팅에서 클래스와 객체에 대해서 살펴봤습니다. 이번 포스팅에서는 클래스와 객체를 최대한 활용할 수 있는 세부사항들을 완벽히 살펴보도록 하겠습니다.

 


1. friend

C++은 클래스 안에서 다른 클래스나 다른 클래스의 멤버 함수 또는 비멤버(non-member) 함수를 friend로 선언하는 기능을 제공합니다. friend로 지정한 대상은 이 클래스의 protected나 private 데이터 멤버 변수에 접근할 수 있습니다. 예를 들어 Foo와 Bar라는 두 클래스가 있다고 가정해보겠습니다. 그리고 다음과 같이 Bar 클래스를 Foo의 friend로 지정합니다.

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

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

 

Bar에 있는 메소드 중에서 특정한 메소드만 friend로 만들 수도 있습니다. Bar 클래스에 processFoo(const Foo& foo) 메소드가 있을 때, 이 메소드를 Foo의 friend로 만들려면 다음과 같이 작성하면 됩니다.

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

 

Stand-alone(멤버가 아닌 독립) 함수도 클래스의 friend가 될 수 있습니다. 예를 들어 Foo 객체에 있는 데이터를 콘솔에 출력하는 함수를 만든다고 가정해봅시다. 이 함수를 Foo 클래스 밖에서 검사하는 모델로 만드려고 하는데, 제대로 검사하려면 이 객체의 내부 데이터 멤버 값에 접근해야 합니다. 이때 Foo 클래스 정의에 다음과 같이 dumpFoo() 함수를 friend로 만들면 됩니다.

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

이 클래스에서 friend 선언문은 함수 프로토타입의 역할을 합니다. 이렇게 지정한 프로토타입은 다른 곳에 따로 선언하지 않아도 됩니다.

dumpFoo 함수의 정의는 다음과 같습니다.

void dumpFoo(const Foo& foo)
{
    // Print all data of foo to the console,
    // including private and protected data members.
}

이 함수를 작성하는 것은 다른 함수를 작성하는 것과 같으며 Foo의 private와 protected 데이터 멤버에 직접 접근할 수 있다는 것만 다릅니다. 이렇게 함수를 정의할 때는 friend 키워드를 생략해도 됩니다.

 

friend로 지정할 클래스, 메소드, 함수는 반드시 접근할 클래스 안에서 지정해주어야 합니다. 이들을 다른 곳에서 대상 클래스의 friend라고 선언하여 그 클래스의 non-public 멤버에 접근하게 할 수는 없습니다.

 

클래스나 메소드를 friend로 지정하는 기능을 너무 많이 사용하면 클래스의 내부가 외부 클래스와 함수에 드러나서 캡슐화 원칙이 깨집니다. 따라서 꼭 필요한 경우에만 사용해야 합니다.

 


2. 객체 동적 할당

프로그램을 실행하기 전에는 얼마나 많은 메모리가 필요한지 알 수 없을 때가 있습니다. 이럴 때는 프로그램 실행에 충분한 만큼 메모리를 동적으로 할당하면 됩니다. 다만 객체에 메모리를 동적으로 할당할 때는 메모리 해제, 객체 복사 처리, 객체 대입 연산 처리 등을 비롯한 몇 가지 까다로운 문제가 발생합니다.

 

2.1 Spreadsheet 클래스

이전 포스팅에서 SpreadsheetCell 클래스를 작성하며 클래스에 대해 알아봤습니다. 이번 포스팅에서는 Spreadsheet 클래스를 단계별로 작성해보도록 하겠습니다.

(포스팅 맨 아래에 이번 포스팅에서 구현한 Spreadsheet 클래스 전체 코드를 남겨두었습니다.)

 

먼저 Spreadsheet는 단순히 SpreadsheetCell의 2차원 배열이며, Spreadsheet의 특정 위치에 있는 셀을 설정하거나 조회하는 메소드를 가지고 있도록 정의합니다. 상용 스프레드시트 프로그램은 셀의 위치를 표시할 때 한 축은 문자로 표시하고 다른 축은 숫자로 표시하는 경우가 많지만, 여기서는 두 축을 모두 숫자로 표현합니다.

 

Spreadsheet 클래스의 첫 번째 버전은 다음과 같습니다.

/*** Spreadsheet.h ***/
#include <cstddef>
#include "SpreadsheetCell.h"

class Spreadsheet
{
public:
    Spreadsheet(size_t width, size_t height);
    void setCellAt(size_t x, size_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 m_width{ 0 };
    size_t m_height{ 0 };
    SpreadsheetCell** m_cells{ nullptr };
};
size_t 타입을 사용하는데, 이 타입은 <cstddef>에 정의되어 있습니다.
Spreadsheet 클래스에서 m_cells 배열에 대해 일반 포인터를 사용하고 있습니다. 이번 포스팅에서는 동적 메모리 할당의 결과와 클래스에서 동적 메모리를 다루는 방법을 설명하기 위해 일부러 이렇게 작성했지만, 실제로는 std::vector를 비롯한 표준 C++ 컨테이너를 사용하는 것이 좋습니다.

Spreadsheet 클래스를 보면 표준 2차원 배열의 SpreadsheetCell이 아닌 SpreadsheetCell** 타입으로 정의했습니다. 이는 Spreadsheet 객체마다 크기가 다를 수 있기 때문에 이 클래스의 생성자에서 클라이언트가 지정한 높이와 너비에 맞게 2차원 배열을 동적으로 할당해야 하기 때문입니다. 2차원 배열을 동적으로 할당하려면 다음과 같이 코드를 작성합니다.

C++은 자바와 달리 new SpreadsheetCell[m_width][m_height] 와 같이 간단하게 작성할 수 없습니다.

Spreadsheet::Spreadsheet(size_t width, size_t height)
    : m_width{ width }, m_height{ height }
{
    m_cells = new SpreadsheetCell*[m_width];
    for (size_t i = 0; i < m_width; i++)
        m_cells[i] = new SpreadsheetCell[m_height];
}

아래 그림은 너비 4, 높이 3의 크기를 가진 s1이라는 Spreadsheet 객체가 스택에 할당되었을 때 메모리 상태를 보여줍니다.

 

다음 코드는 셀 하나를 읽고 쓰는 메소드를 구현한 코드입니다.

void Spreadsheet::setCellAt(size_t x, size_t y, const SpreadsheetCell& cell)
{
    if (!inRange(x, m_width)) {
        throw std::out_of_range{ std::to_string(x) + " must be less than " + std::to_string(m_width) };
    }
    if (!inRange(y, m_height)) {
        throw std::out_of_range{ std::to_string(y) + " must be less than " + std::to_string(m_height) };
    }
    m_cells[x][y] = cell;
}

SpreadsheetCell& Spreadsheet::getCellAt(size_t x, size_t y)
{
    if (!inRange(x, m_width)) {
        throw std::out_of_range{ std::to_string(x) + " must be less than " + std::to_string(m_width) };
    }
    if (!inRange(y, m_height)) {
        throw std::out_of_range{ std::to_string(y) + " must be less than " + std::to_string(m_height) };
    }
    return m_cells[x][y];
}

위의 두 메소드 코드를 살펴보면 x와 y가 스프레드시트에 실제 존재하는 좌표인지 확인하는 작업을 inRange()라는 헬퍼 메소드로 처리했습니다. 배열의 인덱스가 지정된 범위를 벗어나면 프로그램 실행에 문제가 생길 수 있으며, 여기서는 exception을 사용했습니다.

 

setCellAt()과 getCellAt() 메소드를 보면 코드가 중복되어 있는 것을 알 수 있습니다. 최대한 중복은 피하는 것이 좋기 때문에 inRagne()라는 헬퍼 메소드는 삭제하고, 다음과 같이 verifyCoordinate() 메소드를 이 클래스에 따로 정의하여 사용하도록 하겠습니다.

void verifyCoordinate(size_t x, size_t y) const;

구현은 다음과 같습니다.

void Spreadsheet::verifyCoordinate(size_t x, size_t y) const
{
    if (x >= m_width) {
        throw std::out_of_range{ std::to_string(x) + " must be less than " + std::to_string(m_width) };
    }
    if (y >= m_height) {
        throw std::out_of_range{ std::to_string(y) + " must be less than " + std::to_string(m_height) };
    }
}

그러면, setCellAt()과 getCellAt() 메소드는 다음과 같이 간결하게 구현됩니다.

void Spreadsheet::setCellAt(size_t x, size_t y, const SpreadsheetCell& cell)
{
    verifyCoordinate(x, y);
    m_cells[x][y] = cell;
}

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

 

2.2 소멸자로 메모리 해제

동적으로 할당한 메모리를 다 사용했다면 반드시 해제해야 합니다. 객체 안에서 동적으로 할당한 메모리는 그 객체의 소멸자(destructor)에서 해제하는 것이 바람직합니다. 컴파일러는 이 객체가 소멸될 때 소멸자를 호출되도록 보장합니다.

 

Spreadsheet 클래스에 다음과 같이 소멸자를 선언합니다.

class Spreadsheet
{
public:
    Spreadsheet(size_t width, size_t height);
    ~Spreadsheet();
    // .. 나머지 코드 생략
};
소멸자의 이름은 클래스 및 생성자의 이름과 같으며, 그 앞에 틸드(~) 기호를 붙입니다. 소멸자는 인수를 받지 않으며 생성자와 달리 단 하나만 존재합니다. 또한, 소멸자는 exception을 발생하지 않기 때문에 기본적으로 noexcept가 적용됩니다.

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

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

이제 소멸자는 생성자에서 할당한 메모리를 해제합니다. 소멸자에서 메모리를 해제하는데 규칙은 없습니다. 소멸자에는 원하는 코드를 무엇이든지 작성할 수 있지만, 일반적으로 오직 메모리를 해제하거나 다른 리소스를 반환하는데 사용하는 것이 좋습니다.

 

2.3 복사와 대입 처리

이전 포스팅에서 설명했듯이 복사 생성자(copy constructor)나 대입 연산자(assignment operator)를 직접 작성하지 않으면 컴파일러가 자동으로 만들어줍니다. 이렇게 컴파일러에서 생성된 메소드는 객체의 데이터 멤버에 대해 복사 생성자나 대입 연산자를 재귀적으로 호출합니다. 하지만, int나 double, 포인터와 같인 기본 타입에 대해서는 비트단위(bitwise)나 얕은 복사(shallow copy) 또는 대입이 적용됩니다. 즉, 원본 객체의 데이터 멤버를 대상 객체로 단순히 복사하거나 대입하기만 합니다. 하지만, 메모리를 동적으로 할당한 객체를 이렇게 처리하면 문제가 발생합니다.

예를 들어, 다음의 코드를 살펴보겠습니다. 여기 나온 printSpreadsheet() 함수에 Spreadsheet 객체 s1을 전달하면 이 함수의 매개변수인 s를 초기화하는 과정에서 s1을 복사합니다.

#include "Spreadsheet.h"

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

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

이렇게 전달한 Spreadsheet는 m_cells라는 포인터 변수 하나를 갖고 있습니다. 얕은 복사(shallow copy)가 수행되면 대상 객체는 m_cells에 담긴 데이터가 아닌 m_cells 포인터의 복사본만을 받습니다. 따라서, 아래 그림처럼 s와 s1이 같은 데이터를 가리키는 포인터가 되는 상황이 발생합니다.

이 상태에서 만약 m_cells가 가리키는 대상을 s가 변경하면, 그 결과가 s1에도 반영됩니다. 더 심각한 문제는 printSpreadsheet() 함수가 리턴할 때 s의 소멸자가 호출되면서 m_cells가 가리키던 메모리를 해제해버린다는 것입니다. 그러면 다음과 같은 결과가 발생합니다.

이렇게 되면 m_cells 포인터는 더 이상 유효한 메모리를 가리키지 않으며, 이런 포인터를 댕글링 포인터(danglin pointer)라고 합니다.

 

대입 연산을 수행할 때는 이보다 더 심각한 문제가 발생합니다. 예를 들어 다음과 같은 코드를 작성했다고 가정해봅시다.

Spreadsheet s1{ 2, 2 }, s2{ 4, 3 };
s1 = s2;

위 코드 첫 번째 라인에서 객체가 생성될 때, 메모리 레이아웃은 다음과 같습니다.

하지만, 두 번째 라인의 대입문이 수행되고 나면, 메모리 레이아웃은 다음과 같이 변경됩니다.

s1과 s2의 m_cells 포인터가 동일한 메모리를 가리킬 뿐만 아니라, s1에서 가리키던 메모리는 미아(orphan)가 됩니다. 즉, 메모리 누수(memory leak)가 발생됩니다. 그래서 복사 생성자(copy constructor)와 대입 연산자(assignment operator)는 반드시 깊은 복사(deep copy)를 수행해야 합니다. 그래야 포인터가 가리키는 실제 데이터를 복사할 수 있습니다.

 

이처럼 동적 할당 메모리가 존재할 때, C++ 컴파일러가 자동으로 생성하는 복사 생성자나 대입 연산자를 그대로 사용하면 위험합니다.

 

2.3.1 Spreadsheet의 복사 생성자

Shpreadsheet 클래스의 복사 생성자 선언은 다음과 같습니다.

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

이 복사 생성자는 다음과 같이 정의합니다.

Spreadsheet::Spreadsheet(const Spreadsheet& src)
    : Spreadsheet(src.m_width, src.m_height)
{
    for (size_t i = 0; i < m_width; i++) {
        for (size_t j = 0; j < m_height; j++) {
            m_cells[i][j] = src.m_cells[i][j];
        }
    }
}

이 코드에서는 위임 생성자(delegating constructor)를 사용했습니다. 이 복사 생성자의 생성자 이니셜라이저(ctor-initializer)는 처음 non-copy 생성자에게 적절한 메모리를 할당하는 작업을 위임합니다. 그리고 나서 복사 생성자의 바디에서 실제 값을 복사하는 작업을 수행합니다. 이러한 과정을 통해 동적으로 할당된 2차원 배열에 m_cells의 깊은 복사를 처리합니다.

복사 생성자이기 때문에 기존에 생성된 m_cells이 존재하지 않습니다. 따라서 기존의 m_cells를 삭제하는 작업은 할 필요가 없습니다.

 

2.3.2 Spreadsheet의 대입 연산자

이번에는 Spreadsheet 클래스의 대입 연산자를 선언하고 정의해보겠습니다.

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

구현 코드는 다음과 같습니다.

Spreadsheet& Spreadsheet::operator=(const Spreadsheet& rhs)
{
    // Check for self-assignment
    if (this == &rhs)
        return *this;

    // Free the old memory
    for (size_t i = 0; i < m_width; i++)
        delete[] m_cells[i];
    delete[] m_cells;
    m_cells = nullptr;

    // Allocate new memory
    m_width = rhs.m_width;
    m_height = rhs.m_height;

    m_cells = new SpreadsheetCell*[m_width];
    for (size_t i = 0; i < m_width; i++)
        m_cells[i] = new SpreadsheetCell[m_height];

    // Copy the data
    for (size_t i = 0; i < m_width; i++) {
        for (size_t j = 0; j < m_height; j++) {
            m_cells[i][j] = rhs.m_cells[i][j];
        }
    }

    return *this;
}

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

 

예를 들면, 메모리가 정상적으로 해제해서 m_width와 m_height는 제대로 설정되었지만 메모리를 할당하는 루프에서 예외(exception)가 발생했다고 가정해봅시다. 그러면 이 메소드의 나머지 코드를 건너뛰고 리턴해버립니다. 이렇게 Spreadsheet 인스턴스가 손상되었기 때문에 여기에 있는 m_width와 m_height 데이터 멤버는 특정 사이즈를 갖는다고 선언했지만, 실제 m_cells는 올바른 크기의 메모리를 가리키고 있지 않습니다. 즉, 이 코드는 예외가 발생하면 문제가 생깁니다.

 

이때는 모두 정상적으로 처리하거나, 그렇지 못하면 this 객체를 건드리지 않아야 합니다(all-or-nothing mechanism). 이렇게 예외에 안전한 대입 연산자를 구현하려면, copy-and-swap(복사 후 바꾸기) 패턴을 적용하는 것이 좋습니다. 이를 위해 swap() 메소드를 Spreadsheet 클래스에 추가합니다. 덧붙이자면 non-member swap() 함수를 만드는 것이 더 좋은데, 그래야 다양한 표준 라이브러리 알고리즘에서 활용할 수 있기 때문입니다.

다음 코드는 Spreadsheet 클래스의 대입 연산자와 swap() 메소드/non-member 함수를 정의하고 있습니다.

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

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

예외에 안전한 copy-and-swap을 구현하려면 swap() 함수는 절대 예외를 던지면 안되기 때문에 noexcept으로 지정합니다.

함수에 예외가 발생하지 않도록 지정하기 위해서 noexcept를 함수에 지정할 수 있습니다.
   void myNonThrowingFunction() noexcept { /* ... */ }
만약 noexcept 함수가 예외를 던지면, 프로그램은 종료됩니다.

swap() 메소드의 구현은 다음과 같습니다. 여기서 표준 라이브러리인 <utility>에서 제공되는 std::swap() 함수를 사용하여 각 데이터 멤버를 교환합니다.

void Spreadsheet::swap(Spreadsheet& other) noexcept
{
    std::swap(m_width, other.m_width);
    std::swap(m_height, other.m_height);
    std::swap(m_cells, other.m_cells);
}

non-member swap() 함수는 간단하게 swap() 메소드를 포워드합니다.

void swap(Spreadsheet& first, Spreadsheet& second) noexcept
{
    first.swap(second);
}

예외에 안전한 swap 함수를 가지고, 대입 연산자는 이제 다음과 같이 구현할 수 있습니다.

Spreadsheet& Spreadsheet::operator=(const Spreadsheet& rhs)
{
    // Copy-and-swap idiom
    Spreadsheet temp{ rhs }; // do all the work in a temporary instance
    swap(temp);              // Commit the work with only non-throwing operations
    return *this;
}

위 코드는 copy-and-swap 패턴을 사용합니다. 먼저 우항에 대한 복사본 temp가 생성됩니다. 그 다음 현재 객체가 temp 복사본과 스왑됩니다. 이 패턴은 strong exception safety를 보장하기 때문에 대입 연산자를 구현하는데 권장되는 방법입니다. 즉, 예외가 발생되는 경우 현재 Spreadsheet 객체의 상태가 변경되지 않습니다.

이 패턴은 다음의 3단계로 구현됩니다.

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

대입 연산자를 구현하는데 copy-and-swap 패턴을 사용하지 않을 때에는 효율성과 정확성을 위해 보통 대입 연산자의 첫 부분에 자기 자신을 대입하는지 확인합니다.

Spreadsheet& Spreadsheet::operator=(const Spreadsheet& rhs)
{
    // Check for self-assignment
    if (this == &rhs)
    	return *this;
    // ...
    return *this;
}

copy-and-swap 패턴을 사용하면, 이러한 self-assignment 테스트는 할 필요가 없습니다.

 

2.3.3 Assignment와 Pass-by-Value 금지

때로는 클래스에서 메모리를 동적으로 할당할 때, 아무도 이 객체에 복사나 대입을 할 수 없게 만드는 것이 간편합니다. 이렇게 하려면 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로 지정한 메소드는 구현할 필요가 없습니다. 컴파일러는 이러한 메소드를 호출하는 것을 허용하지 않기 때문에 이렇게 지정된 메소드를 전혀 참조하지 않습니다. 만약 이렇게 작성한 Spreadsheet 객체를 복사하거나 어떤 값을 대입하면 다음과 같은 에러 메세지를 출력합니다.

 

2.4 Move Constructor / Move Assignment Operator

객체에 Move Semantics(이동 의미론)을 적용하려면 이동 생성자(move constructor)와 이동 대입 연산자(move assignment operator)를 정의해야 합니다. 그러면 컴파일러는 원본 객체를 임시 객체로 만들어서 대입 연산을 수행한 뒤 임시 객체를 제거하는데, 곧 보겠지만 이 때 명시적으로 std::move() 를 사용합니다. 이렇게 함으로써 메모리를 비롯한 리소스의 소유권(ownership)을 다른 객체로 이동시킵니다. 이 과정은 멤버 변수에 대한 얕은 복사(shallow copy)와 비슷합니다. 또한 할당된 메모리나 다른 리소스에 대한 소유권을 바꾸면서 댕글링 포인터나 메모리 누수를 방지합니다.

 

이동 생성자와 이동 대입 연산자는 모두 데이터 멤버를 소스 객체에서 새로운 객체로 이동하는데, 소슷 객체는 유효한 상태이거나 확인되지 않은 상태로 남겨둡니다. 종종 소스 객체의 데이터 멤버는 null 값으로 리셋되지만, 이것이 필요조건은 아닙니다. 안전을 위해서 이동에 사용된 객체는 사용하지 않는 것이 좋으며, 사용하면 정의되지 않은 동작을 트리거합니다. 예외로 std::unique_ptr과 std::shared_ptr이 있습니다. 표준 라이브러리는 이러한 스마트 포인터들이 이동할 때 반드시 그들의 내부 포인터를 nullptr로 리셋한다고 명시되어 있으며, 이는 스마트 포인터들을 안전하게 재사용할 수 있도록 해줍니다.

 

Move Semantics를 구현하기 전에, 먼저 우측값(rvalue)우측값 레퍼런스(rvalue reference)에 대해서 먼저 알아보겠습니다.

이에 대해서 예전에 한 번 다루었던 포스팅이 있습니다. 필요하시다면 아래 게시글도 참조하시면 좋을 것 같습니다 !

[C++] 우측값 참조(rvalue reference)

 

[C++] 우측값 참조(rvalue reference)

References 씹어먹는 C++ (https://modoocode.com/227) http://thbecker.net/articles/rvalue_references/section_07.html Contents 복사 생략(Copy Elision) 좌측값(lvalue)와 우측값(rvalue) 우측값 레퍼런스(rv..

junstar92.tistory.com

 

2.4.1 우측값 레퍼런스(rvalue references)

C++에서 좌측값(lvalue)은 주소와 이름을 가진 변수입니다. 좌측값이라는 이름에서 알 수 있듯이 대입문의 왼쪽에 나타나는 것입니다. 반면에 우측값(rvalue)은 리터럴(literal), 임시 객체, 값(value)과 같은 좌측값이 아닌 모든 대상을 가리킵니다. 일반적으로 우측값은 대입 연산자 오른쪽에 있습니다.

예를 들면, 다음과 같습니다.

int a{ 4 * 2 };

이 문장에서 a는 좌측값이며 이름을 갖고 있으며 &a로 주소를 알아낼 수 있습니다. 반면 4 * 2라는 표현식의 결과는 우측값입니다. 우측값은 임시값이기 때문에 이 문장을 실행하고 나면 제거됩니다. 여기서는 임시 변수에 있는 값의 복사본을 a란 이름의 변수에 저장합니다.

 

우측값 레퍼런스(rvalue reference)은 말그대로 우측값에 대한 레퍼런스입니다. 구체적으로 우측값이 임시 객체거나 명시적으로 std::move()를 통해 이동되는 객체일 때 적용되는 개념입니다. 우측값 레퍼런스는 우측값이 관련될 때 특정 함수 오버로드를 선택하기 위해 사용됩니다. 이는 일반적으로 크기가 큰 값(객체)을 복사하는 연산을 그 값의 포인터를 복사하도록 할 수 있습니다.

 

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

예를 들면 다음 코드와 같습니다. 여기서는 두 버전의 handleMessage() 함수를 정의하는데, 하나는 좌측값 레퍼런스를 파라미터로 전달받고, 다른 하나는 우측값 레퍼런스로 파라미터를 전달받습니다.

void handleMessage(std::string& message) // lvalue reference parameter
{
    std::cout << "handleMessage with lvalue reference: " << message << std::endl;
}

void handleMessage(std::string&& message) // rvalue reference parameter
{
    std::cout << "handleMessage with rvalue reference: " << message << std::endl;
}

 

먼저 handleMessage()를 다음과 같이 이름이 있는 변수를 인수로 전달해보도록 하겠습니다.

std::string a{ "Hello " };
std::string b{ "World" };
handleMessage(a); // Calls handleMessage(std::string& message)

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

 

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

handleMessage(a + b); // Calls handleMessage(std::string&& message)

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

 

handleMessage() 함수의 인수로 리터럴을 전달해도 됩니다. 이때도 우측값 레퍼런스 버전이 호출됩니다. 리터럴은 좌측값이 될 수 없습니다(물론 리터럴을 const 레퍼런스(reference-to-const) 매개변수에 대한 인수로 전달할 수는 있습니다.).

handleMessage("Hello World"); // Calls handleMessage(std::string&& message)

여기서 좌측값 레퍼런스를 받는 handleMessage() 함수를 삭제한 뒤에 handleMessage(b)처럼 이름이 있는 변수를 전달해서 호출하면 컴파일 에러가 발생합니다. 우측값 레퍼런스 타입의 매개변수(std::string&& message)를 좌측값(b) 인수에 바인딩할 수 없기 때문입니다. 이때 좌측값을 우측값으로 캐스팅하는 std::move()를 사용하면 컴파일러가 우측값 레퍼런스 버전의 handleMessage()를 호출하게 만들 수 있습니다.

handleMessage(std::move(b)); // Calls handleMessage(std::string&& message)

다시 말하자면 이름을 가진 변수는 좌측값입니다. 따라서 handleMessage() 함수 안에서 우측값 레퍼런스 타입인 message 매개변수 자체는 이름이 있기 때문에 좌측값입니다. 이처럼 타입이 우측값 레퍼런스인 매개변수를 다른 함수에 우측값으로 전달하려면 std::move()를 이용하여 좌측값을 우측값으로 캐스팅해야 합니다.

예를 들어, 앞에서 본 코드에 다음과 같이 우측값 레퍼런스 매개변수를 받는 함수를 추가해보도록 하겠습니다.

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

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

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

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

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

 

우측값 레퍼런스는 함수의 매개변수에만 사용되지 않습니다. 변수를 우측값 레퍼런스 타입으로 선언해서 값을 할당할 수도 있습니다만, 흔한 경우는 아닙니다.

예를 들어, C++에서는 다음과 같이 작성하는 것을 허용하지 않습니다.

int& i{ 2 };     // Invalid: reference to a constant
int a{ 2 }, b{ 3 };
int& j{ a + b }; // Invalid: reference to a temporary

우측값 레퍼런스를 사용하면, 아래처럼 작성할 수 있습니다.

int&& i{ 2 };
int a{ 2 }, b{ 3 };
int&& j{ a + b };

하지만, 이렇게 우측값 레퍼런스를 단독으로 사용하는 경우는 거의 없습니다.

 

2.4.2 Move Semantics 구현

이동 의믜론은 우측값 레퍼런스로 구현합니다. 클래스에 이동 의미론을 추가하려면 이동 생성자(move constructor)이동 대입 연산자(move assignment operator)를 구현해야 합니다. 이때 이동 생성자와 이동 대입 연산자를 noexcept로 지정해서 두 메소드의 예외가 절대로 발생하지 않는다고 컴파일러에게 알려주어야 합니다. 특히 표준 라이브러리와 호환성을 유지하려면 반드시 이렇게 해주어야 합니다. 예를 들어 표준 라이브러리 컨테이너와 완벽히 호환되도록 구현할 때 이동 의미론을 적용했다면 저장된 객체를 이동시키기만 합니다. 또한 이 과정에서 예외도 던지지 않습니다.

 

아래의 코드에서 Spreadsheet 클래스에 이동 생성자와 이동 대입 연산자를 추가한 코드를 살펴보겠습니다. 여기서 cleanup()과 moveFrom() 이라는 헬퍼 메소드도 추가했습니다. cleanup()은 소멸자와 이동 대입 연산자에서 사용하고, moveFrom()은 원본 객체의 멤버 변수를 대상 객체로 이동시킨 뒤 원본 객체를 리셋합니다.

class Spreadsheet
{
public:
    Spreadsheet(Spreadsheet&& src) noexcept; // Move constructor
    Spreadsheet& operator=(Spreadsheet&& rhs) noexcept; // Move assignment
    // .. 나머지 코드 생략
private:
    void cleanup() noexcept;
    void moveFrom(Spreadsheet& src) noexcept;
    // .. 나머지 코드 생략
};

 

구현 코드는 다음과 같습니다.

// Move constructor
Spreadsheet::Spreadsheet(Spreadsheet&& src) noexcept
{
    std::cout << "Move constructor" << std::endl;
    moveFrom(src);
}

// Move assignment operator
Spreadsheet& Spreadsheet::operator=(Spreadsheet&& rhs) noexcept
{
    std::cout << "Move assignment operator" << std::endl;

    // chekc for self-assignment
    if (this == &rhs)
        return *this;

    // free the old memory
    cleanup();

    moveFrom(rhs);

    return *this;
}

void Spreadsheet::cleanup() noexcept
{
    for (size_t i = 0; i < m_width; i++)
        delete[] m_cells[i];
    delete[] m_cells;
    m_cells = nullptr;
    m_width = m_height = 0;
}

void Spreadsheet::moveFrom(Spreadsheet& src) noexcept
{
    // Shallow copy of data
    m_width = src.m_width;
    m_height = src.m_height;
    m_cells = src.m_cells;

    // Reset the source object, because ownership has been moved!
    src.m_width = 0;
    src.m_height = 0;
    src.m_cells = nullptr;
}

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

 

당연하지만, 이동 의미론은 원본 객체가 더이상 필요하지 않을 때만 유용합니다.

 

이동 대입 연산자에는 자기 대입 검사가 구현되어 있습니다. 물론 어떻게 구현했느냐에 따라 이 테스트가 항상 필요한 것은 아니지만, 다음과 같은 코드가 런타임에 문제가 되지 않도록 자기 대입 검사를 항상 포함하는 것이 좋습니다.

sheet1 = std::move(sheet1);

 

이동 생성자와 이동 대입 연산자도 일반 생성자나 복사 대입 연산자처럼 명시적으로 삭제하거나 디폴트로 만들 수 있습니다.

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

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

이 5개의 특별한 함수(소멸자, 복사 생성자, 이동 생성자, 복사 대입 연산자, 이동 대입 연산자) 중의 하나 이상을 선언했다면, 일반적으로 이들 전부를 선언해주어야 합니다. 이를 rule of five라고 합니다. 

 

2.4.3 std::exchange

<utility>에 정의된 std::exchange()는 값을 새로운 값으로 대체하고 이전 값을 리턴합니다.

다음 예제를 살펴봅시다.

int a{ 11 };
int b{ 22 };
std::cout << "Before exchange(): a = " << a << ", b = " << b << std::endl;
int returnedValue{ std::exchange(a, b) };
std::cout << "After exchange(): a = " << a << ", b = " << b << std::endl;
std::cout << "exchange() returned: " << returnedValue << std::endl;

실행 결과는 다음과 같습니다.

std::exchange()는 이동 대입 연산자를 구현할 때 유용합니다. 이동 대입 연산자는 소스 객체의 데이터를 목표 객체로 이동하는데 필요한데, 그 후에 소스 객체의 데이터는 무효화됩니다. 위에서 moveFrom() 메소드는 다음과 같이 구현되어 있습니다.

void Spreadsheet::moveFrom(Spreadsheet& src) noexcept
{
    // Shallow copy of data
    m_width = src.m_width;
    m_height = src.m_height;
    m_cells = src.m_cells;

    // Reset the source object, because ownership has been moved!
    src.m_width = 0;
    src.m_height = 0;
    src.m_cells = nullptr;
}

이 메소드는 m_width, m_height, 그리고 m_cells 데이터 멤버를 소스 객체으로부터 복사합니다. 그리고 소유권이 이동되었기 때문에 소스 객체의 데이터 멤버 값을 0이나 nullptr로 설정해줍니다. std::exchange()를 여기에 사용하면 더욱 간결하게 작성할 수 있습니다.

void Spreadsheet::moveFrom(Spreadsheet& src) noexcept
{
    m_width = std::exchange(src.m_width, 0);
    m_height = std::exchange(src.m_height, 0);
    m_cells = std::exchange(src.m_cells, nullptr);
}

 

2.4.4 객체 데이터 멤버 이동

위에서 구현한 이동 생성자와 이동 대입 연산자는 모두 moveFrom()이라는 핼퍼 메소드를 사용하는데, 이 메소드는 얕은 복사로 데이터 멤버를 모두 이동시킵니다. 하지만 이렇게 구현하면 만약 Spreadsheet 클래스에 새로운 데이터 멤버가 추가되었을 때 swap() 함수와 moveFrom() 메소드를 모두 수정해주어야 합니다. 만약 둘 중 하나라도 깜박하고 수정하지 않으면 버그가 발생합니다. 이런 버그가 발생하지 않게 하려면 이동 생성자와 이동 대입 연산자를 swap() 함수로 구현합니다.

 

먼저, cleanup()과 moveFrom() 헬퍼 메소드를 모수 제거합니다. 그리고 cleanup() 메소드에 있떤 코드를 소멸자로 옮깁니다(아마 수정이 필요없을 것입니다). 그러면 이동 생성자와 이동 대입 연산자는 다음과 같이 구현할 수 있습니다.

// Move constructor
Spreadsheet::Spreadsheet(Spreadsheet&& src) noexcept
{
    std::cout << "Move constructor" << std::endl;
    ::swap(*this, src);
}

// Move assignment operator
Spreadsheet& Spreadsheet::operator=(Spreadsheet&& rhs) noexcept
{
    std::cout << "Move assignment operator" << std::endl;

    ::swap(*this, rhs);
    return *this;
}

이렇게 구현하면 코드가 줄고, 클래스에 데이터 멤버를 새로 추가할 때 swap()만 수정하면 되기 때문에 버그 발생 확률을 낮출 수 있다는 장점이 있습니다.

 

2.4.5 Spreadsheet의 이동 연산 테스트

앞서 작성한 Spreadsheet의 이동 생성자와 복사 이동 생성자를 다음의 코드로 테스트해보겠습니다.

Spreadsheet createObject()
{
    return Spreadsheet{ 3,2 };
}

int main()
{
    std::vector<Spreadsheet> vec;
    for (size_t i = 0; i < 2; i++) {
        std::cout << "Iteration " << i << std::endl;
        vec.push_back(Spreadsheet{ 100,100 });
        std::cout << std::endl;
    }
    
    Spreadsheet s{ 2, 3 };
    s = createObject();

    Spreadsheet s2{ 5, 6 };
    s2 = s;

    return 0;
}

여기서 vector를 사용했습니다. vector는 객체를 추가할 때마다 동적으로 커집니다. 이렇게 할 수 있는 이유는 필요에 따라 더 큰 덩어리의 메모리를 할당해서 여기에 기존 vector에 있던 객체를 복사하거나 이동하기 때문입니다. 이때 noexcept 이동 생성자가 정의되어 있으면 컴파일러는 해당 객체를 복사하지 않고 이동합니다. 이처럼 이동 방식으로 옮기기 때문에 깊은 복사(deep copy)를 수행할 필요가 없어서 훨씬 효율적입니다.

 

Spreadsheet 클래스의 모든 생성자와 대입 연산자에 출력문을 추가하여, 위 코드를 실행하면 결과는 다음과 같습니다.

MSVS 2019 컴파일러로 실행한 결과인데, C++ 표준은 vector의 초기 용량이나 확장 방식을 따로 지정하지 않기 때문에 사용하는 컴파일러마다 출력 결과가 달라질 수 있습니다.

반복문을 처음 실행할 때는 vector가 비어 있습니다. 반복문에서 나오는 다음 코드를 살펴봅시다.

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

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

 

2번째 반복을 실행할 때, 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(100x100)개 입니다. Spreadsheet의 이동 생성자와 이동 대입 연산자를 구현할 때는 메모리를 할당할 필요가 없지만, 복사 생성자와 복사 대입 연산자를 구현할 때는 각각 101개를 할당합니다. 이처럼 이동 의미론을 적용하면 특정한 상황에서 성능을 크게 높일 수 있습니다.

 

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

이동 의미론으로 성능을 높이는 또 다른 예제로 두 객체를 맞바꾸는 swap() 함수를 살펴보겠습니다. 다음에 나온 swapCopy() 함수는 이동 의미론을 적용하지 않았습니다.

template<typename T>
void swapCopy(T& a, T& b)
{
    T temp{ a };
    a = b;
    b = temp;
}

먼저 a를 temp에 복사한 뒤, b를 a에 복사하고, 마지막으로 temp를 b에 복사합니다. 그런데 만약 T가 복사하기에 상당히 크다면 성능이 크게 떨어집니다.

이럴 때는 다음과 같이 이동 의미론을 적용해서 복사가 발생하지 않도록 구현합니다.

template<typename T>
void swapMove(T& a, T& b)
{
    T temp{ std::move(a) };
    a = std::move(b);
    b = std::move(temp);
}

표준 라이브러리의 std::swap() 함수가 바로 이런 식으로 구현이 되어 있습니다.

 

2.4.7 Return문에서의 std::move()

return object; 형식의 문장은 만약 object가 로컬 변수, 함수의 파라미터, 또는 임시 값이라면 우측값 표현식으로 처리되며, 이들은 return value optimization(RVO, 리턴값 최적화)를 트리거합니다. 게다가 만약 object가 로컬 변수인 경우에는 named return value optimization(NRVO)가 실행될 수 있습니다. RVO와 NRVO는 모두 복사 생략(copy elision)의 한 형태이며 함수에서 object를 반환하는 것을 매우 효율적으로 만듭니다. 복사 생략을 사용하면, 컴파일러는 함수에서 반환되는 객체의 복사와 이동을 피할 수 있으며, 소위 zero-copy pass-by-value semantics라는 결과를 낳습니다.

 

이제 std::move()를 사용하여 객체를 리턴하면 어떻게 될까요? return object;나 return std::move(object); 두 경우 모두 컴파일러는 우측값 표현식처럼 처리합니다. 그러나 std::move()를 사용하면 컴파일러는 RVO나 NRVO를 더이상 적용하지 않습니다. RVO와 NRVO가 더이상 적용되지 않기 때문에 컴파일러의 다음 옵션은 객체가 지원한다면 이동 의미론을 사용하고, 만약 지원하지 않는다면 복사가 발생하며 큰 성능 문제가 발생할 수 있습니다. 

따라서, 함수로부터 로컬 변수나 파라미터를 반환할 때 단순히 return object;를 사용하고, std::move()는 사용하지 않는게 좋습니다.

 

(N)RVO는 오직 로컬 변수나 함수 파라미터에 대해 동작합니다. 객체의 데이터 멤버는 절대 (N)RVO를 트리거하지 않습니다. 

추가로, 다음과 같은 표현식을 사용할 때는 주의해야 합니다.

return condition ? object1 : object2;

위 문장은 return object; 형태가 아닙니다. 따라서 컴파일러는 (N)RVO를 적용하지 않으며, 복사 생성자를 사용합니다. 컴파일러가 (N)RVO를 사용하도록 하려면 다음과 같이 작성해주어야 합니다.

if (condition) {
    return object1;
} else {
    return object2;
}

만약 조건 연산자를 정말 사용하고 싶다면, 다음과 같이 작성할 수는 있지만 (N)RVO를 트리거하는 것이 아니라 move 또는 copy semantics를 강제로 사용한다는 것을 기억해야 합니다.

return condition ? std::move(object1) : std::move(object2);

 

2.4.8 함수 인수 전달에서 최적화 방법

지금까지 함수로 전달되는 인수의 큰 복사를 피하기 위해서 const 레퍼런스(reference-to-const)를 사용하라고 권장했습니다. 그러나 우측값이 섞여있으면 이야기는 조금 달라집니다.

전달받은 파라미터 중의 하나를 복사하는 함수를 상상해봅시다. 이 상황은 종종 다음과 같은 클래스 메소드에서 발생합니다.

class DataHolder
{
public:
    void setData(const std::vector<int>& data) { m_data = data; }
private:
    std::vector<int> m_data;
};

setData() 메소드는 전달받은 데이터의 복사본을 생성합니다.

이제 우측값과 우측값 레퍼런스에 대해 알았으니, 복사를 피하기 위해서 setData() 메소드를 최적화하기 위해 오버로드된 버전을 하나 추가하면 다음과 같습니다.

class DataHolder
{
public:
    void setData(const std::vector<int>& data) { m_data = data; }
    void setData(std::vector<int>&& data) { m_data = std::move(data); }
private:
    std::vector<int> m_data;
};

임시값(객체)에 의해 setData()가 호출될 때, 복사본이 생성되지 않고 대신 데이터가 이동합니다.

 

다음의 코드는 const 레퍼런스를 전달받는 버전의 setData()를 호출하고, 데이터의 복사본이 생성됩니다.

DataHolder wrapper;
std::vector myData{ 11, 22, 33 };
wrapper.setData(myData);

반면, 다음 코드는 임시값에 의해 setData()를 호출하며, 우측값 레퍼런스를 전달받는 setData()를 호출합니다. 따라서 데이터는 복사되지 않고 이동합니다.

wrapper.setData({ 22, 33, 44 });

 

이 방법은 좌측값과 우측값을 위한 setData()를 최적화하기 위해서 2개의 오버로드 함수를 구현해야합니다. 하지만 pass-by-value를 사용하여 하나의 메소드로 구현할 수 있습니다.

지금까지는 불필요한 복사를 피하기 위해서 항상 const 레퍼런스 매개변수를 사용하여 객체를 전달하는 것이 좋았지만, 이제는 pass-by-value를 사용하는 것이 좋습니다.

 

확실히 하자면, 복사되지 않는 파라미터의 경우에는 여전히 const 레퍼런스로 전달하는 것이 좋습니다.

pass-by-value는 함수가 복사해야하는 매개 변수에만 적합합니다. 이 경우 pass-by-value를 사용하면 코드는 좌측값과 우측값 모두에 최적화됩니다. 좌측값이 전달되면 const 레퍼런스 파라미터와 마찬가지로 정확히 한 번 복사되고, 우측값이 전달되면 우측값 레퍼런스 매개변수와 마찬가지로 복사되지 않습니다.

이를 표현한 코드는 다음과 같습니다.

class DataHolder
{
public:
    void setData(const std::vector<int> data) { m_data = std::move(data); }
private:
    std::vector<int> m_data;
};

만약 setData()에 좌측값이 전달되면, 이 값은 data 파라미터는 복사되고 m_data로 이동합니다. 만약 우측값이 setData()로 전달되면 이 값은 data 파라미터로 이동하고, 다시 m_data로 이동합니다.

 

2.5 Rule of Zero

앞에서 rule of five를 소개했습니다. 지금까지 설명한 내용은 이러한 다섯 가지 특수 멤버 함수(소멸자, 복사/이동 생성자, 복사/이동 대입 연산자)를 구현하는 방법에 대한 것이었습니다. 

그러나, 모던 C++에서는 rule of zero(0의 규칙)이라는 것을 채택해야 합니다.

 

rule of zero는 앞서 언급한 다섯 가지 특수 멤버 함수를 구현할 필요가 없도록 클래스를 디자인해야 한다는 것입니다. 이렇게 하려면 먼저 예전 방식대로 메모리를 동적으로 할당하지 말고 표준 라이브러리 컨테이너와 같은 최신 구문을 활용해야 합니다. 예를 들어 Spreadsheet 클래스에서 SpreadsheetCell**이란 데이터 멤버 대신 vector<vector<SpreadsheetCell>>을 사용합니다. vector는 메모리를 자동으로 관리하기 때문에 앞서 언급한 다섯 가지 특수 멤버 함수가 필요없습니다.

 

rule of five는 RAII(Resource Acquisition is Initialization) 클래스를 제한합니다. RAII 클래스와 관련된 테크닉은 추후에 한 번 더 제대로 다루어 보도록 하겠습니다.

 

 


이번 포스팅은 여기서 마무리하고, 다음 포스팅에서 이어서 C++ 클래스에 대한 내용을 더 알아보도록 하겠습니다.

 

이번 포스팅에서 구현한 Spreadsheet 클래스의 최종 구현은 다음과 같습니다.

Spreadsheet.h

#pragma once
/*** Spreadsheet.h ***/
#include <cstddef>
#include "SpreadsheetCell.h"

class Spreadsheet
{
public:
    Spreadsheet(size_t width, size_t height);
    Spreadsheet(const Spreadsheet& src);
    Spreadsheet(Spreadsheet&& src) noexcept; // Move constructor
    ~Spreadsheet();

    Spreadsheet& operator=(const Spreadsheet& rhs);
    Spreadsheet& operator=(Spreadsheet&& rhs) noexcept; // Move assignment

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

    void swap(Spreadsheet& other) noexcept;

private:
    void verifyCoordinate(size_t x, size_t y) const;

    size_t m_width{ 0 };
    size_t m_height{ 0 };
    SpreadsheetCell** m_cells{ nullptr };
};

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

 

Spreadsheet.cpp

 

#include <iostream>
#include <utility>
#include "Spreadsheet.h"

Spreadsheet::Spreadsheet(size_t width, size_t height)
    : m_width{ width }, m_height{ height }
{
    std::cout << "Normal constructor" << std::endl;
    m_cells = new SpreadsheetCell*[m_width];
    for (size_t i = 0; i < m_width; i++)
        m_cells[i] = new SpreadsheetCell[m_height];
}

Spreadsheet::Spreadsheet(const Spreadsheet& src)
    : Spreadsheet(src.m_width, src.m_height)
{
    std::cout << "Copy constructor" << std::endl;
    for (size_t i = 0; i < m_width; i++) {
        for (size_t j = 0; j < m_height; j++) {
            m_cells[i][j] = src.m_cells[i][j];
        }
    }
}

// Move constructor
Spreadsheet::Spreadsheet(Spreadsheet&& src) noexcept
{
    std::cout << "Move constructor" << std::endl;
    ::swap(*this, src);
}

Spreadsheet::~Spreadsheet()
{
    //std::cout << "Destructor" << std::endl;
    for (size_t i = 0; i < m_width; i++) {
        delete[] m_cells[i];
    }
    delete[] m_cells;
    m_cells = nullptr;
}

Spreadsheet& Spreadsheet::operator=(const Spreadsheet& rhs)
{
    std::cout << "Copy assignment operator" << std::endl;
    // Copy-and-swap idiom
    Spreadsheet temp{ rhs }; // do all the work in a temporary instance
    swap(temp);              // Commit the work with only non-throwing operations
    return *this;
}

// Move assignment operator
Spreadsheet& Spreadsheet::operator=(Spreadsheet&& rhs) noexcept
{
    std::cout << "Move assignment operator" << std::endl;

    ::swap(*this, rhs);
    return *this;
}

void Spreadsheet::setCellAt(size_t x, size_t y, const SpreadsheetCell& cell)
{
    verifyCoordinate(x, y);
    m_cells[x][y] = cell;
}

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

void Spreadsheet::swap(Spreadsheet& other) noexcept
{
    std::swap(m_width, other.m_width);
    std::swap(m_height, other.m_height);
    std::swap(m_cells, other.m_cells);
}

void Spreadsheet::verifyCoordinate(size_t x, size_t y) const
{
    if (x >= m_width) {
        throw std::out_of_range{ std::to_string(x) + " must be less than " + std::to_string(m_width) };
    }
    if (y >= m_height) {
        throw std::out_of_range{ std::to_string(y) + " must be less than " + std::to_string(m_height) };
    }
}

void swap(Spreadsheet& first, Spreadsheet& second) noexcept
{
    first.swap(second);
}

댓글