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

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

by 별준 2022. 2. 13.

References

Contents

  • 연산자 오버로딩
  • Pimpl Idiom or Bridge Pattern

클래스 심화편 세 번째 포스팅입니다 !

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

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

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

 

이번 포스팅에서는 연산자 오버로딩과 C++ 디자인을 고려하여 안정적인 인터페이스를 만드는 방법에 대해서 알아보도록 하겠습니다 !


7. 연산자 오버로딩

객체끼리 더하거나, 비교하거나, 파일에 객체를 스트림으로 전달하거나 반대로 가져오는 등의 객체에 대한 연산을 수행할 때가 많습니다. 예를 들면, 스프레드시트 어플리케이션을 유용하게 사용할 수 있도록 행 전체를 합산하는 산술 연산 기능도 지원해야 합니다.

 

7.1 SpreadsheetCells의 덧셈 구현

SpreadsheetCell에 덧셈 기능을 객체지향 방식으로 구현하려면 SpreadsheetCell 객체에 다른 SpreadsheetCell 객체를 더할 수 있도록 해야합니다. 어떤 셀에 다른 셀을 더하면 제3의 셀이 결과로 나옵니다. 이렇게 더해도 기존 셀은 변하지 않습니다. 여기서 SpreadsheetCell을 더한다는 의미는 셀에 담긴 값을 더한다는 것을 의미합니다.

 

7.1.1 첫 번째 버전 : add 메소드

SpreadsheetCell 클래스에 아래처럼 add() 메소드를 선언하고 정의합니다.

class SpreadsheetCell
{
public:
    SpreadsheetCell add(const SpreadsheetCell& cell) const;
    // .. 나머지 코드 생략
};

이 메소드는 두 셀을 더해서 그 결과를 새로 생성한 제3의 셀에 담아서 리턴합니다. 원본 셀이 변경되지 않도록 add() 메소드를 const로 선언하고 인수를 const SpreadsheetCell에 대한 레퍼런스로 받도록 선언합니다.

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

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

이 add() 메소드는 다음과 같이 사용할 수 있습니다.

SpreadsheetCell myCell{ 4 }, anotherCell{ 5 };
SpreadsheetCell aThirdCell{ myCell.add(antherCell) };
auto aFourthCell{ aThirdCell.add(anotherCell) };

동작에는 문제가 없지만 조금 지저분하며, 개선의 여지가 있습니다.

 

7.1.2 두 번째 버전 : operator+ 오버로딩

두 셀의 덧셈도 int나 double처럼 + 기호로 표현하면 편리합니다. 예를 들면 다음과 같습니다.

SpreadsheetCell myCell{ 4 }, anotherCell{ 5 };
SpreadsheetCell aThirdCell{ myCell + anotherCell };
SpreadsheetCell aFourthCell{ aThirdCell + anotherCell };

C++은 덧셈 기호(+)를 자신이 정의한 클래스 안에서 원하는 형태로 정의하는 덧셈 연산자(addition operator)라는 기능을 지원합니다. 구체적인 방법은 다음과 같이 operator+란 이름으로 메소드를 정의하면 됩니다.

class SpreadsheetCell
{
public:
    SpreadsheetCell operator+(const SpreadsheetCell& cell) const;
    // .. 나머지 코드 생략
};
operator와 + 사이에 공백을 넣어도 됩니다. 이 포스팅에서는 공백이 없는 스타일을 사용합니다.

 

이 메소드의 구현 코드는 add() 메소드와 같습니다.

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

이렇게 구현하면 두 셀을 더할 때 + 기호로 표현할 수 있습니다.

 

이 방식에 익숙해질 필요가 있는데, operator+라는 메소드가 내부적으로 어떻게 작동하는지 과정을 살펴보도록 하겠습니다. 

C++ 컴파일러가 프로그램을 파싱할 때, +, -, =, <<과 같은 연산자를 발견하면 여기 나온 것과 매개변수가 일치하는 operator+, operator-, operator=, operator<<라는 이름의 함수나 메소드가 있는지 확인합니다. 예를 들어 컴파일러가 다음과 같이 작성된 문장을 발견하면 다른 SpreadsheetCell 객체를 인수로 받는 operator+란 메소드가 있는지 아니면 SpreadsheetCell 객체 두 개를 인수로 받는 operator+란 이름의 전역 함수가 있는지 찾습니다.

SpreadsheetCell aThirdCell{ myCell + anotherCell };

SpreadsheetCell 클래스에 operator+ 메소드가 있다면 위 문장은 다음과 같이 변환합니다.

SpreadsheetCell aThirdCell{ myCell.operator+(anotherCell) };

단, 여기서 operator+의 매개변수가 반드시 이 메소드가 속한 클래스와 같은 타입의 객체만 받을 필요는 없습니다. SpreadsheetCell에서 operator+를 정의할 때 Spreadsheet 매개변수를 받아서 SpreadsheetCell에 더하도록 작성해도 됩니다. 프로그래머가 볼 때는 조금 이상할 수 있지만 컴파일러는 문제없이 처리합니다.

 

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

 

- 묵시적 변환

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

SpreadsheetCell myCell{ 4 }, aThridCell;
std::string str{ "hello" };
aThirdCell = myCell + std::string_view{ str };
aThirdCell = myCell + 5.6;
aThirdCell = myCell + 4;

이렇게 동작하는 이유는 컴파일러가 단순히 operator+를 찾는 데 그치지 않고 타입을 정확히 변환할 수 있는 방법도 찾기 때문입니다. 또한 지정된 타입을 변환할 방법도 찾습니다. SpreasheetCell 클래스는 double 또는 string_view를 Spreadsheet 객체로 변환하는 변환 생성자를 가지고 있습니다. 위 예제 코드에서 컴파일러가 자신을 double 타입과 더하려는 SpreadsheetCell을 볼 때, double을 받는 SpreadsheetCell 생성자를 찾고 임시 SpreadsheetCell 객체를 생성하여 operator+에 전달합니다. 마찬가지로 SpreadsheetCell에 string_view를 더하려고 할 때, string_view를 전달받는 생성자를 호출하여 임시 객체를 생성하고 operator+에 전달합니다.

 

그렇지만 이렇게 묵시적 변환 생성자를 사용하면 임시 객체가 반드시 생성되기 때문에 비효율적일 수 있습니다. 다음 코드에서는 double을 더할 때, 묵시적 변환을 피하기 위해서 두 번째 operator+를 작성합니다.

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

 

7.3 세 번째 버전 : Global operator+

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

aThirdCell = myCell + 4;   // works fine
aThirdCell = myCell + 5.6; // works fine
aThirdCell = 4 + myCell;   // compile error
aThirdCell = 5.6 + myCell; // compile error

묵시적 변환은 SpreadsheetCell 객체가 연산자의 좌변에 있을 때만 적용됩니다. 우변에 있을 때는 적용할 수 없습니다. 하지만 덧셈은 원래 교환법칙이 성립해야 하기 때문에 이렇게 하면 무언가 잘못되었습니다. 문제는 operator+가 반드시 SpreadsheetCell 객체에 의해서 호출되어야 하기 때문이고, 그래서 그 객체는 반드시 operator+의 좌항에 있어야 합니다. C++의 문법이 원래 이러기 때문에 어쩔 수가 없는 부분입니다. 따라서 이 상태로는 operator+를 일반 덧셈처럼 교환법칙이 성립하게 만들 방법이 없습니다.

 

하지만 클래스에 정의했던 operator+를 전역 함수로 만들면 가능합니다. 전역 함수는 특정 객체에 종속되지 않습니다.

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

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

그리고 헤더 파일에 프로토타입을 선언합니다.

class SpreadsheetCell
{
public:
    // .. 나머지 코드 생략
};

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

 

다음의 코드는 잘 동작할까요 ?

aThirdCell = 4.5 + 5.5;

컴파일 에러 없이 제대로 동작하지만, operator+가 호출되지 않고 double 타입에 대한 덧셈이 적용되어 다음의 코드처럼 변환됩니다.

aThirdCell = 10;

이 대입문을 셀에 대입하려면, 우변에는 SpreadsheetCell 객체가 있어야 합니다. 따라서 컴파일러는 non-explicit인 double을 받는 생성자를 찾아서 묵시적으로 double 값을 임시 SpreadsheetCell 객체로 변환하고 대입 연산자를 호출합니다.

 

7.2 산술 연산자 오버로딩

다른 산술 연산자를 오버로딩하는 방법도 operator+와 비슷합니다. 연산자를 다음과 같이 정의하면 <op>자리에 +, -, *, / 연산자를 대입해서 각각의 연산을 수행할 수 있습니다. %도 오버로딩할 수 있지만 SpreadsheetCell에 저장된 double 값에 적용하기에는 상식적으로 맞지 않습니다.

class SpreadsheetCell
{
public:
    // .. 나머지 코드 생략
};

SpreadsheetCell operator<op>(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);

이들을 구현하면 다음과 같습니다.

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

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

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

SpreadsheetCell operator/(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs)
{
    if (rhs.getValue() == 0) {
        throw std::invalid_argument{ "Divide by zero." };
    }
    return SpreadsheetCell{ lhs.getValue() / rhs.getValue() };
}

operator/를 처리할 때는 0으로 나누지 않도록 주의해야하며, 위 코드에서는 0으로 나누면 예외를 던지도록 작성했습니다.

 

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

C++에는 기본 연산자뿐만 아니라 축약형 연산자(+=, -= 등)도 제공합니다. 클래스에 operator+를 제공하면 당연히 operator+=를 제공한다고 생각할 수 있지만, 반드시 그런 것은 아닙니다. 축약형 산술 연산자(arithmetic shorthand operator)에 대한 오버로딩은 별도로 구현해야 합니다. 축약형 연산자는 좌변의 객체를 새로 생성하지 않고 기존 객체를 변경한다는 점에서 기본 연산자 오버로딩과는 다릅니다. 또한 대입 연산자처럼 수정된 객체에 대한 레퍼런스를 생성한다는 미묘한 차이가 있습니다.

 

축약형 산술 연산자는 좌변에 반드시 객체가 나와야 합니다. 따라서 전역 함수가 아닌 메소드로 구현합니다. SpreadsheetCell 클래스에 축약형 산술 연산자의 선언은 다음과 같습니다.

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

그리고 위 메소드에 대한 구현은 다음과 같습니다.

SpreadsheetCell& SpreadsheetCell::operator+=(const SpreadsheetCell& rhs)
{
    set(getValue() + rhs.getValue());
    return *this;
}

SpreadsheetCell& SpreadsheetCell::operator-=(const SpreadsheetCell& rhs)
{
    set(getValue() - rhs.getValue());
    return *this;
}

SpreadsheetCell& SpreadsheetCell::operator*=(const SpreadsheetCell& rhs)
{
    set(getValue() * rhs.getValue());
    return *this;
}

SpreadsheetCell& SpreadsheetCell::operator/=(const SpreadsheetCell& rhs)
{
    if (rhs.getValue() == 0) {
        throw std::invalid_argument{ "Divide by zero." };
    }
    set(getValue() / rhs.getValue());
    return *this;
}

 

이렇게 정의하면 이제 축약형 산술 연산자를 다음과 같이 사용할 수 있습니다.

SpreadsheetCell myCell{ 4 }, aThridCell{ 2 };
aThirdCell -= myCell;
aThridCell += 5.4;

하지만 다음과 같이 작성할 수는 없습니다.

5.4 += aThirdCell;

 

마지막으로 일반 연산자와 축약 버전을 모두 정의할 때는 코드 중복을 피하도록 다음과 같이 축약형 버전을 기준으로 일반 버전을 다음과 같이 구현하는 것이 좋습니다.

SpreadsheetCell operator+(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs)
{
    auto result{ lhs };
    result += rhs;
    return result;
    //return SpreadsheetCell{ lhs.getValue() + rhs.getValue() };
}

 

7.3 비교 연산자 오버로딩

>, <, <=, >=, ==, !=와 같은 비교 연산자도 클래스에서 직접 정의하면 편리합니다. 기본 산술 연산자와 마찬가지로 비교 연산자도 전역 함수로 구현해야 연산자의 좌변과 우변을 모두 묵시적으로 변환할 수 있습니다. 비교 연산자는 모두 bool 타입 값을 리턴합니다. 물론 리턴 타입을 다르게 바꿀 수는 있지만 바람직한 방법은 아닙니다.

C++20 표준에서는 이러한 연산자들에게 많은 변화가 있었고, spaceship 연산자인 <=>라는 three-way 비교 연산자가 추가되었습니다만, 이번 포스팅에서 다루지는 않겠습니다.

 

비교 연산자는 다음과 같이 정의하며 <op>에는 ==, <, >, !=, <=, >=를 적용하면 됩니다.

class SpreadsheetCell { /* 코드 생략 */ };

bool operator<op>(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);

 

비교 연산자 오버로딩 구현은 다음과 같습니다.

bool operator==(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs)
{
    return (lhs.getValue() == rhs.getValue());
}

bool operator<(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs)
{
    return (lhs.getValue() < rhs.getValue());
}

bool operator>(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs)
{
    return (lhs.getValue() > rhs.getValue());
}

bool operator!=(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs)
{
    return (lhs.getValue() != rhs.getValue());
}

bool operator<=(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs)
{
    return (lhs.getValue() <= rhs.getValue());
}

bool operator>=(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs)
{
    return (lhs.getValue() >= rhs.getValue());
}
비교 연산자를 오버로딩할 때, getValue()는 double 타입의 값을 리턴합니다. 사실 부동소수점끼리 서로 같은지 판단하거나 크기를 비교할 때는 정밀도에 따른 오차 범위를 고려해야 하기 때문에 이렇게 구현하기보다는 입실론 테스트(epsilon test)를 적용하는 것이 바람직합니다.

 

클래스에 데이터 멤버가 많을 때는 데이터 멤버마다 비교 연산자를 구현하기가 번거로울 수 있습니다. 하지만 ==과 <부터 구현한 뒤 이를 바탕으로 나머지 비교 연산자를 구현하면 간편합니다.

예를 들어, 다음과 같이 operator>=를 operator<를 사용해서 구현할 수 있습니다.

bool operator>=(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs)
{
    return !(lhs < rhs);
}

 

이렇게 구현한 연산자로 다른 SpreadsheetCell뿐만 아니라 double이나 int 값도 비교할 수 있습니다.

if (myCell > aThirdCell || myCell < 10) {
    std::cout << myCell.getValue() << std::endl;
}

 

 

이때까지 살펴본 것 중에 몇 가지 빠진 부분은 있지만, 아래에서 인터페이스 관련하여 살펴볼 때 사용되는 최종 구현된 SpreadsheetCell 클래스와 Spreadsheet 클래스는 다음과 같습니다.

더보기

SpreadsheetCell.h

#pragma once
/*** SpreadsheetCell.h ***/
#include <string>
#include <string_view>

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

    ~SpreadsheetCell();

    SpreadsheetCell& operator+=(const SpreadsheetCell& rhs);
    SpreadsheetCell& operator-=(const SpreadsheetCell& rhs);
    SpreadsheetCell& operator*=(const SpreadsheetCell& rhs);
    SpreadsheetCell& operator/=(const SpreadsheetCell& rhs);

    void set(double value);
    void set(std::string_view value);

    double getValue() const;
    std::string getString() const;

private:
    static std::string doubleToString(double value);
    static double stringToDouble(std::string_view value);

    double m_value{ 0 };
};

SpreadsheetCell operator+(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);
SpreadsheetCell operator-(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);
SpreadsheetCell operator*(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);
SpreadsheetCell operator/(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);

bool operator==(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);
bool operator<(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);
bool operator>(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);
bool operator!=(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);
bool operator<=(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);
bool operator>=(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);

 

SpreadsheetCell.cpp

/*** SpreadsheetCell.cpp ***/
#include "SpreadsheetCell.h"

#include <iostream>

SpreadsheetCell::SpreadsheetCell(double initialValue)
    : m_value{ initialValue }
{
}

SpreadsheetCell::SpreadsheetCell(std::string_view initialValue)
    : m_value{ stringToDouble(initialValue) }
{
    //setString(initialValue);
}

SpreadsheetCell::~SpreadsheetCell()
{
}

SpreadsheetCell& SpreadsheetCell::operator+=(const SpreadsheetCell& rhs)
{
    set(getValue() + rhs.getValue());
    return *this;
}

SpreadsheetCell& SpreadsheetCell::operator-=(const SpreadsheetCell& rhs)
{
    set(getValue() - rhs.getValue());
    return *this;
}

SpreadsheetCell& SpreadsheetCell::operator*=(const SpreadsheetCell& rhs)
{
    set(getValue() * rhs.getValue());
    return *this;
}

SpreadsheetCell& SpreadsheetCell::operator/=(const SpreadsheetCell& rhs)
{
    if (rhs.getValue() == 0) {
        throw std::invalid_argument{ "Divide by zero." };
    }
    set(getValue() / rhs.getValue());
    return *this;
}

void SpreadsheetCell::set(double value)
{
    m_value = value;
}

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

void SpreadsheetCell::set(std::string_view value)
{
    m_value = stringToDouble(value);
}

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

std::string SpreadsheetCell::doubleToString(double value)
{
    return std::to_string(value);
}

double SpreadsheetCell::stringToDouble(std::string_view value)
{
    return strtod(value.data(), nullptr);
}

SpreadsheetCell operator+(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs)
{
    auto result{ lhs };
    result += rhs;
    return result;
    //return SpreadsheetCell{ lhs.getValue() + rhs.getValue() };
}

SpreadsheetCell operator-(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs)
{
    auto result{ lhs };
    result -= rhs;
    return result;
    //return SpreadsheetCell{ lhs.getValue() - rhs.getValue() };
}

SpreadsheetCell operator*(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs)
{
    auto result{ lhs };
    result *= rhs;
    return result;
    //return SpreadsheetCell{ lhs.getValue() * rhs.getValue() };
}

SpreadsheetCell operator/(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs)
{
    auto result{ lhs };
    result /= rhs;
    return result;
    //return SpreadsheetCell{ lhs.getValue() / rhs.getValue() };
}

bool operator==(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs)
{
    return (lhs.getValue() == rhs.getValue());
}

bool operator<(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs)
{
    return (lhs.getValue() < rhs.getValue());
}

bool operator>(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs)
{
    return (lhs.getValue() > rhs.getValue());
}

bool operator!=(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs)
{
    return (lhs.getValue() != rhs.getValue());
}

bool operator<=(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs)
{
    return (lhs.getValue() <= rhs.getValue());
}

bool operator>=(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs)
{
    return (lhs.getValue() >= rhs.getValue());
}

 

Spreadsheet.h

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

class SpreadsheetApplication {};

class Spreadsheet
{
public:
    Spreadsheet(const SpreadsheetApplication& theApp, 
        size_t width = MaxWidth, size_t height = MaxHeight);
    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);

    size_t getId() const;

    void swap(Spreadsheet& other) noexcept;

    static const size_t MaxHeight{ 100 };
    static const size_t MaxWidth{ 100 };

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

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

    const SpreadsheetApplication& m_theApp;

    static inline size_t ms_counter{ 0 };
};

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

 

Spreadsheet.cpp

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

//size_t Spreadsheet::ms_counter; // Pre C++17

Spreadsheet::Spreadsheet(const SpreadsheetApplication& theApp,
    size_t width, size_t height)
    : m_id{ ms_counter++ }
    , m_width{ std::min(width, MaxWidth) }
    , m_height{ std::min(height, MaxHeight) }
    , m_theApp{ theApp }
{
    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_theApp, 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) };
    }
}

size_t Spreadsheet::getId() const
{
    return m_id;
}

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

8. Building Stable Interface

지금까지 C++로 클래스를 작성하는데 관련된 문법들을 자세히 살펴봤습니다. 이 시점에서 디자인을 조금 고려해보도록 하겠습니다. 클래스는 C++의 기본 추상화 단위입니다. 클래스를 작성할 때는 추상화 원칙을 적용하여 인터페이스와 구현을 최대한 분리하는 것이 좋습니다. 특히 데이터 멤버를 모두 private로 지정하고 getter와 setter 메소드를 제공하는 것이 좋습니다. 앞서 살펴본 SpreadsheetCell 클래스를 바로 이런식으로 구현했습니다. m_value를 private으로 지정하고, public set() 메소드로 이 값을 설정하고, getValue()와 getString()으로 이 값을 읽었습니다.

 

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

앞서 설명한 기준과 바람직한 디자인 원칙을 적용하더라도 C++언어가 추상화 원칙과 잘 맞지 않는 부분이 있습니다. C++에서는 public 인터페이스와 private(or protected) 데이터 멤버 및 메소드를 모두 클래스 정의에 작성하기 때문에 클래스의 내부 구현사항이 클라이언트에 어느 정도 노출될 수 밖에 없습니다. 이러한 단점으로 인해 non-public 메소드나 데이터 멤버를 클래스에 추가할 때마다 이 클래스를 사용하는 클라이언트는 매번 다시 컴파일해야 합니다. 프로젝트가 크다면 이 작업은 큰 부담입니다.

 

다행히 인터페이스를 보다 간결하게 구성하고 구현 세부사항을 모두 숨겨서 인터페이스를 안정적으로 유지하는 방법이 있습니다. 대신 작성할 코드가 조금 늘어납니다.

기본 원칙은 작성할 클래스마다 인터페이스 클래스(interface class)와 구현 클래스(implementation class)를 따로 정의하는 것입니다. 구현 클래스는 여기서 설명하는 원칙을 적용하지 않을 때 흔히 작성하는 클래스를 말하며, 인터페이스 클래스는 구현 클래스와 똑같이 public 메소드를 제공하되 구현 클래스 객체에 대한 포인터를 갖는 데이터 멤버 하나만 정의합니다. 이를 pimpl(private implementation) idiom 또는 bridge 패턴이라고 합니다. 

 

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

 

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

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

class SpreadsheetApplication {};

class Spreadsheet
{
public:
    Spreadsheet(const SpreadsheetApplication& theApp, 
        size_t width = MaxWidth, size_t height = MaxHeight);
    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);

    size_t getId() const;

    void swap(Spreadsheet& other) noexcept;

    static const size_t MaxHeight{ 100 };
    static const size_t MaxWidth{ 100 };

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

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

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

 

중첩 클래스인 Spreadsheet::Impl 클래스는 Spreadsheet 구현 파일에 정의됩니다.

// Spreadsheet::Imple class definition
class Spreadsheet::Impl
{
public:
    Impl(const SpreadsheetApplication& theApp,
        size_t width, size_t height);
    Impl(const Impl& src);
    Impl(Impl&&) noexcept = default;
    ~Impl();
    
    Impl& operator=(const Impl& rhs);
    Impl& operator=(Impl&&) noexcept = default;

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

    size_t getId() const;

    void swap(Impl& other) noexcept;

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

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

    const SpreadsheetApplication& m_theApp;

    static inline size_t ms_counter{ 0 };
};

// Spreadsheet::Impl method definition
Spreadsheet::Impl::Impl(const SpreadsheetApplication& theApp,
    size_t width, size_t height)
    : m_id{ ms_counter++ }
, m_width{ std::min(width, Spreadsheet::MaxWidth) }
, m_height{ std::min(height, Spreadsheet::MaxHeight) }
, m_theApp{ theApp }
{
    m_cells = new SpreadsheetCell * [m_width];
    for (size_t i = 0; i < m_width; i++)
        m_cells[i] = new SpreadsheetCell[m_height];
}
// .. 다른 메소드 생략

Impl 클래스는 원래 Spreadsheet 클래스와 거의 동일한 인터페이스를 갖습니다. 메소드 구현에서 Imple은 중첩 클래스이기 때문에 Spreadsheet::Impl로 스코프를 지정해주어야 합니다. 따라서 생성자는 Spreadsheet::Impl::Impl(...)이 됩니다.

 

Spreadsheet 클래스는 구현 클래스에 대해서 unique_ptr을 가지고, Spreadsheet 클래스는 사용자 선언 소멸자가 있어야 합니다. 하지만 이 소멸자는 딱히 할 일이 없기 때문에 다음과 같이 디폴트로 지정합니다.

Spreadsheet::~Spreadsheet() = default;

사실 디폴트 지정은 클래스 정의에서 바로 지정되는 것이 아니라 구현파일에서 디폴트로 지정되어야만 합니다. 이유는 Impl 클래스가 Spreadsheet 클래스 정의에서는 단지 포워드로 선언되었기 때문입니다. 즉, 컴파일러는 Spreadsheet::Impl 클래스가 어딘가에 있다는 것만 알고 정의는 아직 알지 못하기 때문입니다. 따라서 클래스 정의에서 소멸자를 디폴트로 지정할 수 없는데, 이는 컴파일러가 아직 정의되지 않은 Impl 클래스의 소멸자를 사용하려고 하기 때문입니다. 이동 생성자와 이동 대입 연산자와 같은 다른 메소드를 디폴트로 설정할 때도 마찬가지 입니다.

 

setCellAt()과 getCellAt()과 같은 Spreadsheet의 구현은 단지 들어온 요청을 내부 Impl 객체로 그냥 전달하면 됩니다.

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

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

 

Spreadsheet의 생성자는 반드시 Impl 객체를 생성하도록 구현해야 합니다.

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

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

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

 

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

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

대입 연산자의 첫 번째 줄이 조금 이상하게 보일 수 있습니다. Spreadsheet 대입 연산자는 현재 호출을 Impl의 대입 연산자로 포워딩해야 하는데, 이 연산자는 객체를 직접 복사할 때만 구동됩니다. m_impl 포인터를 역참조하면, 강제로 직접 객체 대입 방식을 적용하기 때문에 Impl의 대입 연산자를 호출할 수 있습니다.

 

swap() 메소드는 간단하게 단일 데이터 멤버에 대한 스왑만 수행합니다.

void Spreadsheet::swap(Spreadsheet& other) noexcept
{
    std::swap(m_impl, other.m_impl);
}

 

이렇게 인터페이스와 구현을 확실하게 나누면 엄청난 효과를 얻을 수 있습니다. 처음에는 좀 번거로울 수는 있습니다.

여기서 소개한 기법의 가장 큰 장점은 인터페이스와 구현을 분리하면 단순히 스타일 측면만 좋아지는 것이 아니라 구현 클래스를 변경할 일이 많아져도 빌드 시간을 절약할 수 있다는 점입니다. 이 패턴을 적용하지 않고 클래스를 작성하면 구현 코드가 조금이라도 변경되면 빌드 시간이 길어질 수 있습니다. 예를 들어 클래스 정의에 데이터 멤버를 새로 추가하면 이 클래스 정의를 include하는 모든 소스 파일을 다시 빌드해야 합니다. 반면 이 패턴을 적용하면 인터페이스 클래스를 건드리지 않는 한 구현 클래스의 코드를 변경해도 빌드 시간에 영향을 받지 않습니다.

 

이렇게 인터페이스와 구현을 분리하는 대신, 추상 인터페이스(abstract interface), 즉, 가상 메소드로만 구성된 인터페이스를 정의한 뒤 이를 구현하는 클래스를 따로 작성해도 됩니다. 추상 인터페이스는 클래스 상속에 관련한 다음 포스팅에서 자세하게 다루어보도록 하겠습니다.

 

 


 

Impl 클래스를 사용한 Spreadsheet 클래스의 전체 코드는 다음과 같습니다.

Spreadsheet.h

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

class SpreadsheetApplication {};

class Spreadsheet
{
public:
    Spreadsheet(const SpreadsheetApplication& theApp, 
        size_t width = MaxWidth, size_t height = MaxHeight);
    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);

    size_t getId() const;

    void swap(Spreadsheet& other) noexcept;

    static const size_t MaxHeight{ 100 };
    static const size_t MaxWidth{ 100 };

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

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

 

Spreadsheet.cpp

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

// Spreadsheet::Imple class definition
class Spreadsheet::Impl
{
public:
    Impl(const SpreadsheetApplication& theApp,
        size_t width, size_t height);
    Impl(const Impl& src);
    Impl(Impl&&) noexcept = default;
    ~Impl();
    
    Impl& operator=(const Impl& rhs);
    Impl& operator=(Impl&&) noexcept = default;

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

    size_t getId() const;

    void swap(Impl& other) noexcept;

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

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

    const SpreadsheetApplication& m_theApp;

    static inline size_t ms_counter{ 0 };
};

// Spreadsheet::Impl method definition
Spreadsheet::Impl::Impl(const SpreadsheetApplication& theApp,
    size_t width, size_t height)
    : m_id{ ms_counter++ }
, m_width{ std::min(width, Spreadsheet::MaxWidth) }
, m_height{ std::min(height, Spreadsheet::MaxHeight) }
, m_theApp{ theApp }
{
    m_cells = new SpreadsheetCell * [m_width];
    for (size_t i = 0; i < m_width; i++)
        m_cells[i] = new SpreadsheetCell[m_height];
}

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

Spreadsheet::Impl::Impl(const Impl& src)
    : Impl{ src.m_theApp, src.m_width, src.m_height }
{
    // The ctor-initializer of this constructor delegates first to the
    // non-copy constructor to allocate the proper amount of memory.

    // The next step is to 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] = src.m_cells[i][j];
        }
    }
}

void Spreadsheet::Impl::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 Spreadsheet::Impl::setCellAt(size_t x, size_t y, const SpreadsheetCell& cell)
{
    verifyCoordinate(x, y);
    m_cells[x][y] = cell;
}

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

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

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

size_t Spreadsheet::Impl::getId() const
{
    return m_id;
}


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

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

Spreadsheet::~Spreadsheet() = default;
Spreadsheet::Spreadsheet(Spreadsheet&&) noexcept = default;
Spreadsheet& Spreadsheet::operator=(Spreadsheet&&) noexcept = default;

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

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

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

size_t Spreadsheet::getId() const
{
    return m_impl->getId();
}

void Spreadsheet::swap(Spreadsheet& other) noexcept
{
    std::swap(m_impl, other.m_impl);
}

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

댓글