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

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

by 별준 2022. 2. 12.

References

Contents

  • 메소드의 종류 (static, const, overloading, inline)
  • 데이터 멤버의 종류
  • 중첩 클래스, 클래스 내부의 열거 타입

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

이전 포스팅에서는 주로 클래스에서의 move semantic(이동 의미론)과 관련된 내용들을 살펴봤습니다.

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

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

 

이번 포스팅에서는 클래스의 메소드, 데이터 멤버, 연산자 오버로딩과 관련하여 자세하게 알아보도록 하겠습니다 !


3. 메소드의 종류

3.1 static 메소드

메소드도 데이터 멤버처럼 특정 객체 단위가 아닌 클래스 단위로 적용되는 것들이 있습니다. 이를 static(정적) 메소드라 부르며 데이터 멤버를 정의하는 단계에 함께 작성합니다. 예를 들어, 클래스(Class) 기본편에서 정의한 SpreadsheetCell  클래스를 살펴보도록 하겠습니다. 여기서는 stringToDouble()과 doubleToString()이란 헬퍼 메소드를 정의했습니다. 두 메소드는 객체 정보에 접근하지 않기 때문에 다음과 같이 static으로 정의할 수 있습니다.

class SpreadsheetCell
{
public:
    // .. 이전 코드 생략
private:
    static std::string doubleToString(double value);
    static double stringToDouble(std::string_view value);
    // .. 나머지 코드 생략
};

두 메소드의 구현은 이전에 작성했던 것과 동일하며, 구현 코드에는 static 키워드를 생략해도 됩니다. 하지만 static 키워드는 특정 객체에 대해 호출되지 않기 때문에 this 포인터를 가질 수 없으며 어떤 객체의 non-static 멤버에 접근하는 용도로 호출할 수 없습니다. 사실 static 메소드는 근본적으로 일반 함수와 비슷하지만 클래스의 private static이나 protected static 멤버만 접근할 수 있다는 점이 다릅니다. 또한, 같은 타입의 객체를 포인터나 레퍼런스로 전달하는 방법 등을 사용해서 그 객체를 static 메소드에서 visible하게 만들었다면 클래스에서 non-static private 또는 protected 멤버에 접근할 수 있습니다.

 

같은 클래스 안에서는 static 메소드를 일반 함수처럼 호출할 수 있습니다. 따라서, SpreadsheetCell에 있는 다른 메소드의 구현 코드를 수정할 필요가 없습니다.

클래스 밖에서 호출할 때는 메소드 이름 앞에 스코프 지정 연산자(::, scope resolution operator)를 이용하여 클래스 이름을 붙여주어야 합니다. 접근 제한 방식은 일반 메소드와 같습니다.

예를 들어, Foo 클래스에 public static 메소드 bar()를 정의하면, bar()는 다음과 같이 어디에서나 호출할 수 있습니다.

Foo::bar();

 

3.2 const Method

const 객체는 값이 바뀌지 않는 객체를 말합니다. const 객체나 이에 대한 레퍼런스 또는 포인터를 사용할 때는 그 객체의 데이터 멤버를 절대로 변경하지 않는 메소드만 호출할 수 있습니다. 그렇지 않으면 컴파일 에러가 발생합니다. 이처럼 어떤 메소드가 데이터 멤버를 변경하지 않는다고 보장하고 싶을 때 const 키워드를 붙여줍니다.

예를 들어, 데이터 멤버를 변경하지 않는 메소드를 SpreadsheetCell 클래스에 추가하려면 다음과 같이 const로 선언합니다.

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

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

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

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

메소드를 const로 선언하면 이 메소드 안에서 객체의 내부 값을 변경하지 않는다는 계약을 맺는 것과 같습니다. 데이터 멤버를 수정하는 메소드를 const로 선언하면 컴파일 에러가 발생합니다. 또한 위에서 본 doubleToString()이나 stringToDouble()과 같은 static 메소드를 const로 선언해서도 안됩니다. static 메소드는 근본적으로 클래스의 인스턴스에 속하지 않아 객체 내부의 값을 변경할 수 없기 때문에 static 메소드에 const 키워드를 붙이는 것은 의미가 없습니다.

메소드에 const를 붙이면 그 메소드 안에서 각 데이터 멤버에 대한 const 레퍼런스를 가진 것처럼 동작합니다. 따라서 데이터 멤버를 변경하는 코드가 나오면 컴파일 에러가 발생합니다.

 

객체를 const로 선언하지 않았다면 그 객체의 const 메소드와 non-const 메소드를 모두 호출할 수 있습니다. 반면 const로 선언하였다면 그 객체의 const 메소드만 호출할 수 있습니다.

SpreadsheetCell myCell{ 5 };
std::cout << myCell.getValue() << std::endl;  // OK
myCell.setString("6");                        // OK

const SpreadsheetCell& myCellConstRef{ myCell };
std::cout << myCell.getValue() << std::endl;  // OK
myCellConstRef.setString("6");                // Compile Error!

프로그램에서 const 객체에 대한 레퍼런스를 사용할 수 있도록 객체를 수정하지 않는 메소드는 모두 const로 선언하는 습관을 가지는 것이 좋습니다.

참고로 const 객체도 소멸될 수 있고, 따라서 언제든지 소멸자가 호출될 수 있습니다. 그렇지만 소멸자를 const로 선언할 수는 없습니다.

 

3.3 mutable Data Members

때로는 의미상으로는 const인 메소드에서 객체의 데이터 멤버를 변경하는 경우가 있습니다. 이렇게 해도 사용자 데이터에 아무런 영향을 미치지 않지만 엄연히 수정하는 것이기 때문에 이런 메소드를 const로 선언하면 컴파일 에러가 발생할 수 있습니다. 예를 들어, 스프레드시트 프로그램을 프로파일링해서 여기에 담긴 데이터를 얼마나 자주 읽는지 확인한다고 가정해봅시다. 가장 간단한 방법은 SpreadsheetCell 클래스에 카운터를 두고 getValue()나 getString()이 호출될 때마다 카운터를 업데이트하는 식으로 호출 횟수를 기록하는 것입니다. 하지만 이렇게 하면 컴파일러 입장에서 볼 때 non-const 메소드가 되어버리기 때문에 의도에 맞지 않는 방법입니다. 이럴 때는 횟수를 세는 카운터 변수를 mutable로 선언해서 컴파일러에 이 변수를 const 메소드에서 변경할 수 있다고 알려주면 됩니다.

방금 설명한 것을 SpreadsheetCell 클래스에 적용하면 다음과 같습니다.

class SpreadsheetCell
{
    // .. 나머지 코드 생략
private:
    double m_value{ 0 };
    mutable size_t m_numAccesses{ 0 };
};

그리고 getValue()와 getString()을 다음과 같이 정의합니다.

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

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

 

3.4 메소드 오버로딩

이미 한 클래스에서 생성자를 여러 개 정의할 수 있다고 언급했습니다. 이렇게 정의한 생성자는 모두 이름은 갖고 매개변수의 타입이나 개수만 다릅니다. 메소드나 함수도 이와 마찬가지로 매개변수의 타입이나 개수만 다르게 지정해서 이름이 같은 함수나 메소드를 여러 개 정의할 수 있습니다. 이를 오버로딩(overloading)이라고 부릅니다.

예를 들어, 다음과 같이 SpreadsheetCell 클래스에서 setString()과 setValue()를 모두 set()으로 통일할 수 있습니다.

class SpreadsheetCell
{
public:
    void set(double value);
    void set(std::string_view value);
    // .. 나머지 코드 생략
}

set() 메소드의 구현 코드는 그대로 두고 메소드 이름만 변경해주면 됩니다. 컴파일러가 set()을 호출하는 코드를 발견하면 매개변수 정보를 보고 어느 버전의 set()을 호출할 지 결정합니다. 매개변수가 string_view 타입이면 string 버전의 set()을 호출하고, 매개변수가 double 타입이면 double  버전의 set()을 호출합니다. 이를 overload resolution이라고 합니다.

 

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

 

3.4.1 Overloading Based on 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 버전을 적절히 캐스팅해서 호출하는 방식으로 구현합니다. 

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

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

구체적으로 설명하자면, <utility>에 정의된 std::as_const()로 *this를 const Spreadsheet&로 캐스팅하고, const 버전의 getCellAt()을 호출한 다음 const_cast()를 적용해 리턴된 결과에서 const를 제거하는 방식으로 처리합니다.

 

이렇게 정의한 두 getCellAt() 메소드로 이제 const와 non-const Spreadsheet 객체에서 getCellAt()을 호출할 수 있습니다.

Spreadsheet sheet1{ 5, 6 };
SpreadsheetCell& cell1{ sheet1.getCellAt(1, 1) };

const Spreadsheet sheet2{ 5, 6 };
const SpreadsheetCell& cell2{ sheet2.getCellAt(1, 1) };

위 예제에서는 const 버전의 getCellAt()이 실질적으로 하는 일이 없기 때문에 const_cast() 패턴을 적용한다고 크게 나아지는 것은 없습니다. 하지만 const 버전의 getCellAt()이 여기 나온 코드보다 더 많은 일을 할 때는 const 버전을 호출하는 방식으로 non-const 버전을 구현하면 코드 중복을 크게 줄일 수 있습니다.

 

std::as_const() 함수는 C++17부터 추가되었습니다. 만약 컴파일러에서 이를 지원하지 않는다면, 다음과 같이 static_cast를 사용하면 됩니다.

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

 

3.4.2 Explicitly Deleting Overloads

오버로딩된 메소드를 명시적으로 삭제할 수 있는데, 이를 통해 특정한 인수에 대해서는 메소드를 호출하지 못하도록 할 수 있습니다. 예를 들어, SpreadsheetCell 클래스는 setValue(double) 메소드를 가지고 있고, 다음과 같이 호출할 수 있습니다.

SpreadsheetCell cell;
cell.setValue(1.23);
cell.setValue(123);

3번째 줄에서 컴파일러는 정수값 (123)을 double로 변환해서 setValue(double)을 호출합니다. 만약 어떠한 이유로 정수형으로 호출되는 setValue()를 원하지 않는다면, int 버전의 setValue()를 명시적으로 삭제할 수 있습니다.

class SpreadsheetCell
{
public:
    void setValue(double value);
    void setValue(int) = delete;
    // .. 나머지 코드 생략
};

이렇게 변경하면 int 값으로 setValue()를 호출할 때 컴파일러에 의해서 에러가 발생합니다.

 

3.5 inline 메소드

C++은 메소드(또는 함수)를 별도의 코드 블록에 구현해서 호출하지 않고 메소드를 호출하는 부분에서 곧바로 구현 코드를 작성하는 방법을 제공합니다. 이를 inlining(인라이닝)이라고 하며 이렇게 구현한 메소드를 inline methods(인라인 메소드)라고 합니다.

 

인라인 메소드를 정의하려면 메소드 정의(구현) 코드에서 이름 앞에 inline 키워드를 지정합니다.

예를 들어, SpreadsheetCell 클래스의 접근자 메소드를 인라인 메소드로 만들고 싶으면 다음과 같이 정의합니다.

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

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

이렇게 하면 컴파일러는 getValue()와 getString()을 호출하는 부분을 함수 호출로 처리하지 않고, 그 함수의 본문을 곧바로 집어넣습니다. 여기서 inline 키워드는 단지 컴파일러를 위한 hint이며, 성능을 크게 해친다고 생각된다면 컴파일러는 이 키워드를 그냥 무시할 수 있습니다.

 

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

고급 C++ 컴파일러는 인라인 메소드를 클래스 정의와 같은 파일에 정의하지 않아도 됩니다. 예를 들어, Microsoft Visual C++은 Link-Time Code Generation(LTCG)를 지원하는데, inline으로 선언하지 않거나 헤더 파일에 정의하지 않아도 함수나 메소드의 크기가 작으면 자동으로 인라인으로 처리합니다. GCC와 Clang도 비슷한 기능을 제공합니다. 

 

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

class SpreadsheetCell
{
public:
    double getValue() const { m_numAccesses++; return m_value; }
    
    std::string getString() const
    {
        m_numAccesses++;
        return doubleToString(m_value);
    }
    // .. 나머지 코드 생략
};

 

메소드를 inline으로 선언할 때 발생하는 효과를 제대로 이해하지 않은 채 무작정 인라인 메소드로 구현하는 경우가 상당히 많습니다. 컴파일러는 메소드나 함수에 선언한 inline 키워드를 단지 참고만 할 뿐입니다. 실제로는 간단한 메소드나 함수만 인라인으로 처리합니다. 만약 컴파일러가 볼 때 인라인으로 처리하면 안 될 메소드를 inline으로 선언하면 그냥 무시합니다. 최신 컴파일러는 code bloat과 같은 몇 가지 기준에 따라 메소드나 함수를 인라인으로 처리할지 판단해서 큰 효과가 없다면 인라인으로 처리하지 않습니다.

 

3.6 Default Arguments

메소드 오버로딩과 비슷한 기능으로 Default Arguments(디폴트 인수)라는 것이 있습니다. 이 기능을 사용하면 함수나 메소드의 프로토타입(선언)에 매개변수의 기본값(디폴트값)을 지정할 수 있습니다. 사용자가 다른 값으로 지정한 인수를 전달하면 디폴트값을 무시합니다. 반면 사용자가 인수를 지정하지 않으면 디폴트값을 적용합니다. 하지만 여기에 한 가지 제약사항이 있는데, 매개변수에 디폴트값을 지정할 때는 반드시 오른쪽 끝 매개변수부터 시작해서 중간에 건너뛰지 않고 연속적으로 나열해주어야 합니다. 그렇지 않으면 컴파일러는 중간에 빠진 인수에 디폴트값을 매칭할 수 없습니다.

 

디폴트 인수는 함수, 메소드, 생성자에서 지정할 수 있습니다.

예를 들어, Spreadsheet 생성자에 높이와 너비에 대한 디폴트값을 다음과 같이 지정할 수 있습니다.

class Spreadsheet
{
public:
    Spreadsheet(size_t width = 100, size_t height = 100);
    // .. 나머지 코드 생략
};

Spreadsheet 생성자의 구현 코드는 변경할 필요가 없습니다. 주의할 점은 디폴트 인수는 메소드를 정의하는 코드가 아닌 메소드를 선언하는 코드에서만 지정할 수 있습니다.

이렇게 선언하면 Spreadsheet에 non-copy 생성자가 하나만 있어도 다음과 같이 인수를 0개, 1개, 2개 지정하여 호출할 수 있습니다.

Spreadsheet s1;
Spreadsheet s2{ 5 };
Spreadsheet s3{ 5, 6 };

모든 매개변수에 대해 기본값이 지정된 생성자는 디폴트 생성자처럼 사용할 수 있습니다. 다시 말해 어떤 클래스에 대해 객체를 생성할 때 인수를 지정하지 않아도 됩니다. 그런데 디폴트 생성자가 있을 때 모든 매개변수에 기본값이 지정된 생성자를 함께 작성하면 컴파일 에러가 발생합니다. 이는 생성자에 인수를 지정하지 않을 때 어느 생성자를 호출해야할 지 컴파일러가 알 수 없기 때문입니다.

 

여기서 주목할 점은 디폴트 인수로 할 수 있는 기능을 메소드 오버로딩으로도 구현할 수 있다는 것입니다. 다시 말해 매개변수 개수만 다르게 지정해서 생성자를 세 가지 버전으로 정의할 수 있습니다. 다만 디폴트 인수를 지정하는 방식을 사용하면 3가지 버전에 대해 생성자를 하나만 작성해도 됩니다.

 


4. 데이터 멤버의 종류

C++은 데이터 멤버의 종류도 다양하게 지원합니다. 단순히 클래스에서 간단한 데이터 멤버를 선언할 수 있을 뿐만 아니라 같은 클래스의 모든 객체가 데이터 멤버를 공유할 수 있도록 static으로 지정할 수도 있고, const 멤버, 레퍼런스 멤버, const 레퍼런트 멤버 등도 지정할 수 있습니다.

 

4.1 static Data Member

클래스의 모든 객체마다 똑같은 변수를 가지는 것이 비효율적이거나 바람직하지 않을 수 있고, 데이터 멤버의 성격이 객체보다는 클래스에 가깝다면 객체마다 그 멤버의 복사본을 가지지 않는 것이 좋습니다. 예를 들어, 각각의 스프레드시트마다 숫자로 된 고유한 ID를 부여한다고 하면, 객체를 새로 생성할 때마다 0부터 시작해서 차례대로 ID 값을 할당하게 됩니다. 여기서 스프레드시트 수를 저장하는 카운터는 사실 Spreadsheet 클래스에 속해야 합니다. 이 값을 Spreadsheet 객체마다 가지면 각 객체마다 저장된 값을 동기화해주어야 하기 때문에 각 객체마다 복사본을 가지고 있는 것은 타당하지 않습니다.

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

static 데이터 멤버로 카운터를 구현하도록 Spreadsheet 클래스를 정의하면 다음과 같습니다.

class Spreadsheet
{
    // .. 나머지 코드 생략
private:
    static size_t ms_counter;
};

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

아래 코드는 ms_counter의 공간을 할당하고 값을 0으로 초기화합니다. (Spreadsheet.cpp에 작성)

size_t Spreadsheet::ms_counter;

static 데이터 멤버는 기본적으로 0으로 초기화되지만, 명시적으로 0으로 초기화해도 됩니다.

size_t Spreadsheet::ms_counter{ 0 };

이 코드는 함수나 메소드에 속하지 않습니다. 따라서 Spreadsheet 클래스에 속하도록 스코프 지정 연산자(scope resolution specifier)로 Spreadsheet::를 붙인 점을 제외하면 전역 변수를 선언할 때와 같습니다.

 

4.1.1 Inline 변수

C++17부터 static 데이터 멤버를 inline으로 선언할 수 있습니다. 그러면 소스 파일에 공간을 따로 할당하지 않아도 됩니다.

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

class Spreadsheet
{
    // .. 나머지 코드 생략
private:
    static inline size_t ms_counter{ 0 };
};

inline 키워드로 선언되고 바로 초기화되었기 때문에, 위에서 Spreadsheet.cpp에 작성한 아래 문장은 작성하지 않아도 됩니다.

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

 

4.1.2 클래스 메소드에서 static 데이터 멤버에 접근

클래스 메소드 안에서 static 데이터 멤버는 마치 일반 데이터 멤버인 것처럼 사용합니다. 예를 들어, Spreadsheet에 m_id라는 데이터 멤버를 만들고, 이 값을 Spreadsheet 생성자에서 ms_counter의 값으로 초기화하는 경우를 살펴보겠습니다.

Spreadsheet 클래스에 m_id 멤버를 다음과 같이 정의합니다.

class Spreadsheet
{
public:
    // .. 나머지 코드 생략
    size_t getId() const;
private:
    // .. 나머지 코드 생략
    size_t m_id{ 0 };
    static inline size_t ms_counter{ 0 };
};

ID의 초기값을 할당하도록 Spreadsheet 생성자를 구현하면 다음과 같습니다.

Spreadsheet::Spreadsheet(size_t width, size_t height)
    : m_id{ ms_counter++ }, 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];
}

생성자 코드에서 볼 수 있듯이 ms_counter를 마치 일반 멤버인 것처럼 접근합니다. 복사 생성자도 새로운 ID를 할당하는데, Spreadsheet 복사 생성자는 non-copy 생성자에 새 ID를 생성하는 작업을 위임하므로 이 과정은 자동으로 처리됩니다.

 

이 예제에서 ID는 객체에 한 번만 대입되고 절대로 변경되지 않는다고 가정해봅시다. 그렇다면 이 ID는 복사 대입 연산자에서 복사되면 안됩니다. 그러므로, m_id는 const 데이터 멤버로 만드는 것이 좋습니다.

class Spreadsheet
{
private:
    // .. 나머지 코드 생략
    const size_t m_id{ 0 };
};

const 데이터 멤버는 한 번 생성되고 나면 변경되지 않기 때문에 생성자 본문에서 초기화하는 것이 불가능합니다. 이러한 데이터 멤버는 함수 정의 내부나 이니셜라이저 생성자(ctor-initializer)에서 초기화되어야만 합니다. 이는 대입 연산자로 새로운 값을 이 데이터 멤버에 대입할 수 없다는 것을 의미합니다. 여기서 m_id는 한 번 Spreadsheet가 ID를 가지면 절대로 변경되지 않기 때문에 문제가 되지 않습니다. 그러나, 경우에 따라서 만약 클래스를 대입할 수 없는 경우에는 일반적으로 대입 연산자가 명시적으로 삭제됩니다.

 

4.1.3 메소드 밖에서 static 데이터 멤버 접근

static 데이터 멤버에도 접근 제어 제한자(access controll specifier)를 적용할 수 있습니다. ms_counter를 private로 지정하면 클래스 메소드 밖에서 접근할 수 없습니다. 만약 public으로 지정하면 변수 앞에 Spreadsheet::를 붙여서 클래스 메소드 밖에서 접근할 수 있습니다.

int c{ Spreadsheet::ms_counter };

하지만 데이터 멤버를 public으로 선언하는 것은 바람직하지 않습니다(const static은 예외). 데이터 멤버는 반드시 public getter/setter 메소드로 접근해야 합니다. 만약 static 데이터 멤버를 외부에서 접근하려면, static get/set 메소드를 사용하는 방식으로 구현합니다.

 

4.2 const static Data Member

위에서 잠깐 살펴봤지만 클래스에 정의된 데이터 멤버를 const로 선언하면 데이터 멤버가 생성되고 초기화된 후에는 변경할 없도록 만들 수 있습니다. 특정 클래스에서만 적용되는 상수(클래스 상수, class constant)를 정의할 때는 전역 상수로 선언하지 말고 반드시 static const(또는 const static) 데이터 멤버로 선언합니다. 정수 및 열거 타입의 static const 데이터 멤버는 별도로 인라인 변수로 지정할 필요없이 정의 코드에서 선언과 동시에 초기화할 수 있습니다.

예를 들어, 스프레드시트의 최대 높이와 폭을 지정한다고 가정해봅시다. 만약 사용자가 그보다 높거나 넓은 스프레드시트를 생성하면 사용자가 입력한 값을 무시하고 미리 지정된 최댓값을 적용합니다. 이럴 때는 최대 높이 및 폭을 Spreadsheet 클래스의 static const 멤버로 정의하면 됩니다.

class Spreadsheet
{
public:
    static const size_t MaxHeight{ 100 };
    static const size_t MaxWidth{ 100 };
    // .. 나머지 코드 생략
};

이렇게 선언한 상수는 생성자에서 다음과 같이 활용할 수 있습니다.

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

std::min 함수는 <algorithm>에 정의되어 있습니다.

높이와 폭을 강제로 최댓값에 맞추는 대신 범위를 벗어난 값이 들어오면 예외를 던지도록 구현해도 됩니다. 하지만 생성자에서 예외를 던지면 소멸자가 호출되지 않습니다.

이렇게 정의한 상수를 생성자 매개변수의 기본값으로 사용해도 됩니다.

class Spreadsheet
{
public:
    Spreadsheet(size_t width = MaxWidth, size_t height = MaxHeight);
    // .. 나머지 코드 생략
};

 

4.3 Reference Data Members

지금까지 구현한 Spreadsheet와 SpreadsheetCell의 기능이 풍부해졌지만, 아직까지는 스프레드시트 프로그램을 제대로 쓰기에는 부족합니다. 스프레드시트 프로그램 전체를 제어하는 기능도 구현해야 하는데, 이를 SpreadsheetApplication 클래스에 작성하도록 하겠습니다. 또한, 각 Spreadsheet에서 어플리케이션 객체에 대한 레퍼런스를 저장합니다. 지금은 SpreadsheetApplication 클래스에 대한 자세한 정의는 중요하지 않기 때문에 다음 코드처럼 빈 클래스로만 정의합니다.

아래 코드에서 Spreadsheet 클래스는 m_theApp이라는 새로운 레퍼런스 데이터 멤버를 포함하도록 수정되었습니다.

class SpreadsheetApplication {};

class Spreadsheet
{
public:
    Spreadsheet(size_t width, size_t height,
        SpreadsheetApplication& theApp);
    // .. 나머지 코드 생략
private:
    SpreadsheetApplication& m_theApp;
    // .. 나머지 코드 생략
};

이 정의에서 SpreadsheetApplication 레퍼런스가 데이터 멤버에 추가되었습니다. Spreadsheet는 항상 SpreadsheetApplication을 참조하기 때문에 포인터보다 레퍼런스를 사용하는 것이 바람직합니다. 포인터를 사용하면 이런 관계를 보장할 수 없습니다.

 

여기서 어플리케이션에 대한 레퍼런스를 지정한 이유는 레퍼런스를 데이터 멤버로 사용할 수 있다는 것을 보여주기 위해서 입니다. 사실 Spreadsheet와 SpreadsheetApplication 클래스를 이렇게 묶기보다는 MVC 패턴에 따라 구현하는 것이 바람직하긴 합니다.

 

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

Spreadsheet::Spreadsheet(size_t width, size_t height,
    SpreadsheetApplication& theApp)
    : m_id{ ms_counter++ }
    , m_width{ std::min(width, MaxWidth) }
    , m_height{ std::min(height, MaxHeight) }
    , m_theApp{ theApp }
{
    // .. 코드 생략
}

이 레퍼런스 멤버를 반드시 복사 생성자에서도 초기화해야 하는데, 이 작업은 생성자 위임을 통해 자동으로 처리됩니다.

(복사 생성자의 일반 생성자 호출에 src.m_theApp을 인수로 전달해주기만 하면 됩니다.)

 

레퍼런스를 초기화한 후에는 참조하는 객체를 변경할 수 없습니다. 대입 연산자에서 레퍼런스를 대입할 수도 없습니다. 구현에 따라서 레퍼런스 데이터 멤버에 대해 대입 연산자를 제공할 수 없을 수도 있고, 이때는 대입 연산자가 delete로 지정되어야 합니다.

 

마지막으로 레퍼런스 데이터 멤버 또한 const로 지정될 수 있습니다. 예를 들어, Spreadsheet가 오직 어플리케이션 객체에 대한 const 레퍼런스를 가지도록 할 수 있습니다. 이는 간단하게 m_theApp을 const 레퍼런스로 선언하면 됩니다.

class Spreadsheet
{
public:
    Spreadsheet(size_t width, size_t height,
        const SpreadsheetApplication& theApp);
    // .. 나머지 코드 생략
private:
    const SpreadsheetApplication& m_theApp;
    // .. 나머지 코드 생략
};

(이렇게 하면 이동 생성자에서 컴파일 에러가 발생합니다. 임시로 이동 생성자는 주석처리하면 됩니다.)

 


5. 중첩 클래스

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

 

또한 클래스 안에서 다른 클래스를 정의할 수도 있습니다. 예를 들어, SpreadsheetCell 클래스를 Spreadsheet 클래스 안에서 정의할 수 있습니다. 그러면 Spreadsheet 클래스의 일부분이 되기 때문에 이름을 간단히 Cell이라고 붙여도 된다.

코드로 표현하면 다음과 같습니다.

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)
	: m_value{ initialValue }
{
}

이렇게 스코프를 지정해주는 규칙은 Spreadsheet 클래스 안에 있는 메소드의 리턴 타입에도 적용됩니다. 단, 매개변수에는 적용되지 않습니다.

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

 

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

class Spreadsheet
{
public:
    class Cell;
    Spreadsheet(size_t width, size_t height,
        const SpreadsheetApplication& theApp);
    // .. 나머지 코드 생략
};
class Spreadsheet::Cell
{
public:
    Cell() = default;
    Cell(double initialValue);
    // .. 나머지 코드 생략
};

중첩 클래스도 일반 클래스와 똑같은 접근 제어 규칙이 적용됩니다. 중첩 클래스를 private나 protected로 선언하면 중첩 클래스를 담고 있는 클래스 내에서만 접근할 수 있습니다. 

 

중첩 클래스로 Cell을 정의한 버전의 Spreadsheet 코드 전체는 다음과 같습니다.

더보기

헤더 파일

class Spreadsheet
{
public:

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

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

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

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

		double m_value{ 0 };
	};

	Spreadsheet(size_t width, size_t height,
		const SpreadsheetApplication& theApp);
	Spreadsheet(const Spreadsheet& src);
	~Spreadsheet();
	Spreadsheet& operator=(const Spreadsheet& rhs);

	void setCellAt(size_t x, size_t y, const Cell& cell);
	Cell& 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;

	size_t m_id{ 0 };
	size_t m_width{ 0 };
	size_t m_height{ 0 };
	Cell** m_cells{ nullptr };

	const SpreadsheetApplication& m_theApp;

	static inline size_t ms_counter{ 0 };
};

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

구현 파일

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

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

Spreadsheet::Spreadsheet(const Spreadsheet& src)
	: Spreadsheet{ src.m_width, src.m_height, src.m_theApp }
{
	// 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::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::setCellAt(size_t x, size_t y, const Cell& cell)
{
	verifyCoordinate(x, y);
	m_cells[x][y] = cell;
}

Spreadsheet::Cell& 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 swap(Spreadsheet& first, Spreadsheet& second) noexcept
{
	first.swap(second);
}

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;
}

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


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

Spreadsheet::Cell::Cell(std::string_view initialValue)
	: m_value{ stringToDouble(initialValue) }
{
}

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

double Spreadsheet::Cell::getValue() const
{
	return m_value;
}

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

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

std::string Spreadsheet::Cell::doubleToString(double value) const
{
	return std::to_string(value);
}

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

 


6. Enumerated Type inside Classes

열거 타입 또한 클래스의 데이터 멤버가 될 수 있습니다. 예를 들어, SpreadsheetCell 클래스에 셀의 색상에 대한 서포트를 추가한다고 가정해봅시다.

class SpreadsheetCell
{
public:
    // .. 나머지 코드 생략
    enum class Color { Red = 1, Green, Blue, Yellow };
    void setColor(Color color);
    Color getColor() const;
private:
    // .. 나머지 코드 생략
    Color m_color{ Color::Red };
};

setColor()와 getColor()의 메소드 구현 코드는 다음과 같이 간단하게 정의할 수 있습니다.

void SpreadsheetCell::setColor(Color color) { m_color = color; }
SpreadsheetCell::Color SpreadsheetCell::getColor() const { return m_color; }

 

이렇게 추가한 메소드의 사용법은 다음과 같습니다.

SpreadsheetCell myCell(5);
myCell.setColor(SpreadsheetCell::Color::Blue);
auto color = myCell.getColor();

 


 

다음 심화편 (3)에서는 연산자 오버로딩과 안정적인 인터페이스를 만드는 방법에 대해서 알아보겠습니다.

댓글