C++ : 9 클래스와 객체

seunghyun·2023년 4월 1일
0

전문가를 위한 C++

목록 보기
2/4

목차


소개

  • 이번 장에서는 클래스와 객체를 최대한 활용하는 방법에 대해서 알아보자.

배울 내용

  • 객에체 동적 메모리 할당하는 법
  • 복제 후 맞바꾸기 패턴
  • 우측값과 우측값 레퍼런스
  • 이동 의미론을 적용하면 성능을 높일 수 있는 이유
  • 영의 규칙
  • 데이터 멤버의 종류
  • 메서드의 종류
  • 메서드 오버로딩의 세부사항
  • 디폴트 인수 활용법
  • 중첩 클래스 사용법
  • friend로 클래스 연결
  • 연산자 오버로딩
  • 인터페이스와 구현 클래스 구분법

9.1 friend

  • friend로 지정한 대상은 이 클래스의 protected나 private 데이터 멤버와 메서드에 접근할 수 있음.
class Foo {

    friend class Bar;
    // ...
};
  • Foo는 Bar와 친구이다.
  • Bar는 Foo의 protected와 private 데이터 멤버에 접근 가능
  • 특정 메서드만 friend로 만들 수도 있다.
  • 하지만 이 기능을 너무 많이 사용하면 클래스의 내부가 외부 클래스나 함수에 드러나서 캡슐화의 원칙이 깨진다.
  • 꼭 필요할 때만 사용하자.

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

  • 객체에 메모리를 동적으로 할당할 때는 메모리 해제, 객체 복제 처리, 객체 대입 연산 처리 등을 비롯한 까다로운 문제 발생

9.2.1 Spreadsheet 클래스

  • Spreadsheet 클래스를 단계별로 업그레이드 하면서 소개
  • Spreadsheet를 SpreadsheetCell 타입의 2차원 배열로 만든다.
  • 아래는 Spreadsheet의 첫 번째 버전
#include <cstddef>
#include "SpreadsheetCell.h"

class Spreadsheet
{
    public:
        Spreadsheet(size_t width, size_t height);
        void setCellAt(size_t x, size_ 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;
};
  • 다음은 2차원 배열을 동적으로 할당하는 코드이다.
  • 이는 자바와 달리 간단하지 않음에 주의
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];
    }
}
  • 셀 하나를 읽고 쓰는 메서드 구현
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) {
    iif (!inRange(x, mWidth) || !inRange(y, mHeight)) {
        throw std::out_of_range("");
    }
    return mCells[x][y];
}
  • inRange 라는 헬퍼 메서드를 통해 좌표가 유효한지 확인함.
  • 하지만 위의 두 함수는 중복되어지는 코드가 있다.
  • 아래와 같은 메서드를 이 클래스에 따로 정의
void verifyCoordinate(size_t x, size_ y) const;
  • 정상 범위가 아니라면 예외를 던지도록 구현!
void Spreadsheetcell::verifyCoordinate(size_t x, size_t y) const {
    if (x >= mWidth || y >= mHeight) {
        throw std::out_of_range("");
    }
}

// 수정 후 getCellAt, setCellAt 메서드
void Spreadsheet::setCellAt(size_t x, size_t y, const SpreadsheetCell& cell) {
    verifyCoordinate(x, y);
    mCells[x][y] = cell;
}

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

9.2.2 소멸자로 메모리 해제하기

  • 객체의 소멸자에서 해제하는 것이 바람직
// Spreadsheet에 소멸자 선언
class Spreadsheet {
    public:
        Spreadsheet(size_t width, size_t height);
        ~Spreadsheet();
        // 생략
};
  • 아무런 익셉션이 발생하지 않으므로 소멸자는 기본적으로 noexcept 적용
// Spreadsheet 클래스의 소멸자 구현
Spreadsheet::~Spreadsheet() {
    for (size_t i = 0; i < mWidth; i++) {
        delete [] mCells[i];
    }
    delete [] mCells;
    mCells = nullptr;
}

9.2.3 복제와 대입 처리하기

  • 8장에서 복제 생성자 or 대입 연산자를 작성하지 않으면 컴파일러가 대신 생성해준다고 함.
  • 이렇게 생성된 메서드는 복제 생성자 or 대입 연산자를 재귀적으로 호출함.
  • 여기서 기본 타입에 대새허는 비트 단위 복제, 얕은 복제 또는 대입이 적용

댕글링 포인터

#include "Spreadsheet.h"

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

int main() {
    Spreadsheet s1(4, 3);
    printSpreadsheet(s1);
    return 0;
}
  • 위의 예에서 Spreadsheet 객체 s1을 전달하면
  • 이 함수의 s를 초기화 하는 과정에서 s1을 복제
  • 이렇게 전달한 Spreadsheet는 mCells 라는 포인터변수 하나만 가지고 있음
  • 따라서 s와 s1이 같은 데이터를 가리키는 포인터가 발생!
  • 이 상태에서 s가 변경되면 s1에도 그 결과가 반영
  • 더 심각한 문제는 s의 소멸자가 호출되어지면서 mCells가 가리키던 메모리까지 해제!
  • 이를 댕글링 포인터라고 부름.

메모리 누수

  • 대입 연산을 수행할 때는 이보다 더 심각한 문제 발생
Spreadsheet s1(2,2), s2(4,3);
s1 = s2;
  • s1과 s2에 있는 mCells 포인터가 가리키는 방향이 똑같을 뿐 아니라,
  • s1에서 mCells가 가리키던 메모리는 미아가 됨!
  • 이를 메모리 누수 라고 한다.
  • 따라서... 컴파일러가 자동으로 생성하는 복제 생성자 or 대입 연산자를 그대로 사용하지말 것!

1. 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를 삭제하는 작업 필요 없음.

2. Spreadsheet 대입 연산자

// 대입 연산자 선언
class Spreadsheet
{
    public:
        Spreadsheet& operator=(const Spreadsheet& rhs);
        // 생략
};

// 대입 연산자 단순 구현
Spreadsheet& Spreadsheet::operator=(const Spreadsheet& rhs)
{
    // 자기 자신인지 확인
    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 SpreadsheetCell[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;
}
  • 만약 위의 코드에서 익셉션이 발생했다고 하면 문제가 생기게 된다.

  • 데이터 멤버 mWidth, mHeight는 일정크기를 갖고 있지만 실제로는 mCells 데이터 멤버에 필요한 만큼의 메모리를 갖고있지 않는다.

  • 문제가 생기지 않도록 복제 후 맞바꾸기 패턴을 적용하여 해결한다.

  • Spreadsheet 클래스에 대입 연산자와 swap() 함수를 추가함.

class Spreadsheet
{
    public:
        Spreadsheet& operator=(const Spreadsheet& rhs);

        // 비 멤버 함수로, 예외가 일어나지 않도록 noexcept
        friend void swap(Spreadsheet& first, Spreadsheet& second) noexcept; 
}

// 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;
}
  • 익셉션에 대한 안정성 높일 수 있음.

정리하면

    1. 임시 복제본을 만든다.
    1. swap() 함수를 이용하여 현 객체를 임시 복제본으로 교체
    1. 임시 객체 제거. 원본 객체가 남음

3. 대입과 값 전달 방식 금지

  • 대입이나 값 전달 방식을 금지하기 위해 아래와 같이 정의
class Spreadsheet
{
    public:
        Spreadsheet(size_t width, size_t height);
        Spreadsheet(const Spreadsheet& src) = delete;
        ~Spreadsheet();
        Spreadsheet& operator=(const Spreadsheet&  rhs) = delete;
    // 생략
};
  • 위와 같이 작성한 뒤에, Spreadsheet 객체를 복사 or 어떤 값 대입시 에러 메시지를 출력함

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

  • 객체에 이동 의미론을 적용하기 위해서는 이동 생성자와 이동 대입 연산자를 정의해야 함
  • 원본 객체에 대한 임시 객체를 생성한 뒤, 임시 객체 대입 연산을 수행한 뒤에 제거함
  • 이 과정에서 이동 생성자와 이동 대입 연산자를 활용함
  • 이는 할당된 메모리나 다른 리소스에 대한 소유권을 전환함으로 댕글링 포인터나 메모리 누수를 방지함
  • 이동 의미론을 구현하기 전에 우측값과 우측값 레퍼런스부터 알아보자.

1. 우측값 레퍼런스

  • 좌측값은 변수처럼 이름과 주소를 가진 대상임.
  • 우측값은 리터럴, 임시 객체, 값처럼 좌측값이 아닌 모든 대상을 가리킴
int a = 4 * 2; // a : 좌측값, 4 * 2 : 우측값(임시값)
  • 우측값 레퍼런스는 우측값이 임시 객체일 때 적용되는 개념
  • 함수의 매개변수에 &&를 붙여서 우측값 레퍼런스로 만들 수 있음
// 좌측값 레퍼런스 매개변수
void handleMessage(std::string& message) {
    cout << "handleMessage with lvalue reference: " << message << endl;
}

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

// 이름 있는 변수를 인수로 전달하여 호출
std::string a = "Hello ";
std::string b = "World";
handleMessage(a); // handleMessage(string& value) 호출
handleMessage(a + b); // handleMessage(string&& value) 호출
  • 위의 예에서 a + b는 임시 변수가 생성되는데 임시 변수는 좌측값이 아니므로 우측값 레퍼런스 버전이 호출
handleMessage("Hello World"); // handleMessage(string&& value) 호출

// 좌측값을 우측값으로 캐스팅하는 std::move()를 사용하여 컴파일러가 우측값 레퍼런스 버전의 handleMessage를 호출함.
handleMessage(std::move(b)); // handleMessage(string&& value) 호출
  • 즉, 이름 있는 변수는 좌측값이다!

2. 이동 의미론 구현 방법

  • 이동 의미론은 우측값 레퍼런스로 구현
  • 이동 생성자와 이동 대입 연산자를 noexcept로 지정하여 예외가 발생하지 않도록 함
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;
}
  • 이동 의미론은 원본 객체를 삭제할 때만 유용

객체 데이터 멤버 이동하기

  • 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() 함수로 구현한 이동 생성자와 이동 대입 연산자

  • 이동 생성자와 이동 대입 연산자를 디폴트 생성자와 swap() 함수로 구현
// Spreadsheet의 클래스에 디폴트 생성자 추가
class Spreadsheet
{
    private:
        Spreadsheet() = default;
        // 생략
};

// cleanup(), moveForm() 메서드 삭제
// 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;
}
  • moveForm() 으로 구현했던 것보다는 효율성이 떨어질 수 있지만,
  • 코드가 줄고 클래스에 데이터 멤버를 새로 추가할 때, swap() 함수만 수정하면 되므로
  • 버그를 낮출 수 있는 장점이 존재

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

// 실행 결과
// 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) 

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

  • 성능을 높이는 또 다른 예제로 스왑이 있다.
// 이동 의미론 적용 x
void swapCopy(T& a, T& b){
    T temp(a);
    a = b;
    b = temp;
}
  • 만약 T의 크기가 크다면 성능이 크게 떨어짐.
// 이동 의미론 적용 o
void swapMove(T& a, T& b){
    T temp(std::move(a));
    a = std::move(b);
    b = std::move(temp);
}
  • 표준 라이브러리 std::swap()의 구현과 동일

9.2.5 영의 규칙

  • 언급한 다섯 가지 특수 멤버 함수(소멸자, 복제 생성자, 이동 생성자, 복제 대입 연산자, 이동 대입 연산자)를 구현할 필요가 없도록 클래스를 디자인 하는 것!
  • 메모리를 동적으로 할당히지 말고, 표준 라이브러리 컨테이너 또는 최신 구문을 활용하자!

9.3 메서드의 종류

9.3.1 static 메서드

  • 데이터 멤버처럼 특정 객체 단위가 아닌 클래스 단위로 적용되어지는 메서드를 static 메서드라고 부름
class SpreadsheetCell
{
    // 생략
    private:
        static std::string doubleToString(double inValue);
        static double stringToDouble(std::string_view inString);
        // 생략
};
  • 같은 클래스 안에서는 static 메서드를 일반 함수처럼 호출 가능
// stringToDouble()과 doubleToString()을 public으로 선언하면
// 클래스 외부에서 호출 가능
string str = SpreadsheetCell::doubleToString(5.0);

9.3.2 const 메서드

  • const 객체 : 값이 바뀌지 않는 객체
  • 그 객체의 데이터 멤버를 절대로 변경하지 않는 메서드만 호출 가능
  • 아닐 시, 컴파일 에러
class Spreadsheet
{
    public:
    // 생략
    double getValue() const;
    std::string getString() const;
    // 생략
};
  • const는 메서드를 구현하는 부분에서도 꼭 적어줘야 함!
  • 주의할 점으로 static 메서드를 const로 선언해서는 안 된다.
  • 객체를 const로 선언하지 않았다면 그 객체의 const 메서드와 non-const 메서드를 모두 호출 가능
SpreadsheetCell myCell(5);
cout << myCell.getValue() << endl; // ok
myCell.setString("6"); // ok

const SpreadsheetCell& myCellConstRef = myCell;
cout << myCellConstRef.getValue() << endl; // ok
myCellConstRef.setString("6"); // 컴파일 에러
  • 프로그램에서 const 객체에 대한 레퍼런스를 사용할 수 있도록 객체를 수정하지 않는 메서드는 모두 const로 선언하도록 하자!

1. mutable 데이터 멤버

  • 횟수를 세는 카운터 변수를 mutable로 선언해서 컴파일러에 이 변수를 const 메서드에서 변경할 수 있다고 알려주자!
class Spreadsheet
{
    // 생략
    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);
}

9.3.3 메서드 오버로딩

  • 매개변수의 타입이나 개수만 다르게 지정해서 이름이 같은 함수나 메서드를 여러 개 정의 가능.
class Spreadsheet
{
    public:
        // 생략
        void set(double inValue);
        void set(std::string_view inString);
        // 생략
};
  • 매개 변수 정보를 보고 어느 버전의 set()을 호출할 지 결정
  • 이를 오버로딩 결정 이라고 함
  • C++는 메서드의 리턴 타입에 대한 오버로딩은 지원하지 않음!

1. const 기반 오버로딩

  • const를 기준으로 오버로딩 가능
  • 같은 메서드이지만 하나는 const로 선언
  • 만약 호출한 객체가 const이면 const로 선언된 메서드가 호출되어짐!
class Spreadsheet
{
    public:
        SpreadsheetCell& getCellAt(size_t x, size_t y);
        const SpreadsheetCell& getCellAt(size_t x, size_t y) const;
        // 생략
};

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

  • 오버로딩된 메서드를 명시적으로 삭제 가능
  • 특정한 인수에 대해서는 메서드를 호출하지 못하게 됨
class MyClass
{
    public:
        void foo(int i);
        void foo(double i) = delete;


};

// 호출
MyClass c;
c.foo(123);
c.foo(1.23); // 컴파일 에러 발생!
  • 컴파일러가 foo 메서드의 double 버전을 명시적으로 삭제 가능!

9.3.4 인라인 메서드

  • 별도의 코드 블록에 구현해서 호출하지 않고 메서드를 호출하는 부분에서 바로 구현 코드를 작성하는 방법도 제공
  • 이를 인라이닝이라고 부름
  • 일반적으로 #define 구문보다 안정적이라고 함.
inline double SpreadsheetCell::getValue() const{
    mNumAccesses++;
    return mValue;
}

인라인 베서드는 반드시 프로토타입과 구현 코드를 헤더 파일에 작성해야 함!

9.3.5 디폴트 인수

  • 메서드 오버로딩과 비슷한 기능으로 디폴트 인수 라는 것도 존재함.
  • 오른쪽 끝의 매개변수부터 시작해서 중간에 건너뛰지 않고 연속적으로 나열해야 한다.
class Spreadsheet
{
    public:
        Spreadsheet(size_t width = 100, size_t height = 100);
        // 생략
};

// 호출
Spreadsheet s1;
Spreadsheet s2(5);
Spreadsheet s3(5, 6);
  • 매개변수 개수만 다르게 지정해서 생성자를 세 가지 버전으로 정의할 수 있다.

9.4 데이터 멤버의 종류

  • 다양한 데이터 멤버의 종류에 대해서 알아보자.

9.4.1 static 데이터 멤버

  • static 데이터 멤버를 통해서 카운터를 정의하는 예제를 살펴보자.
class Spreadsheet
{
    // 생략
    private:
        static size_t sCounter; // 기본적으로 0 으로 초기화 되어짐 static 포인터는 nullptr로 초기화
};

1. 인라인 변수

  • C++17 부터는 static 데이터 멤버를 inline으로 선언 가능
  • 소스 파일에 공간을 따로 할당하지 않아도 된다.
class Spreadsheet
{
    // 생략
    private:
        static inline size_t sCounter = 0;
};
// 위와 같이 작성하면 소스 파일에 아래 부분을 적지 않아도 된다.
size_t Spreadsheet::sCounter;

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

  • 클래스 메서드에서 static 데이터 멤버를 일반 데이터 멤버인 것처럼 사용
// mId라는 데이터 멤버를 만듦
// mId를 Spreadsheet 생성자에서 SCounter의 값으로 초기화하는 경우
class Spreadsheet
{
public:
    // 생략
	size_t getId() const;

private:
    // 생략

	static size_t sCounter;
    size_t mId = 0;
};
  • ID의 초기값을 할당하기 위해 Spreadsheet 생성자를 다음과 같이 구현
Spreadsheet::Spreadsheet(size_t width, size_t height)
	: mId(sCounter++), mWidth(width), mHeight(height)
{
	mCells = new SpreadsheetCell*[mWidth];
	for (size_t i = 0; i < mWidth; i++) {
		mCells[i] = new SpreadsheetCell[mHeight];
	}
}
  • sCounter를 마치 일반멤버인 것처럼 접근
  • 이 ID를 복제 대입 연산자에서 복제하면 안 된다.
  • 따라서 mId는 const 데이터 멤버로 만드는 것이 바람직

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

  • static 데이터 멤버에 대해서도 접근 제한자를 적용할 수 있음.
  • sCounter를 private로 선언시 클래스 메서드 밖에서 접근 불가
  • 대체데이터 멤버는 public으로 선언하는 것은 바람직하지 않음.
  • 따라서, static 데이터 멤버를 외부에서 접근하려면 getter와 setter를 두자.

9.4.2 const static 데이터 멤버

  • 스프레드시트의 최대 높이와 폭을 지정한다고 가정.
  • 사용자가 최대 높이, 폭보다 큰 스프레드시트를 생성하면 사용자의 입력값 무시 후, 미리 지정된 최댓값 적용
class Spreadsheet
{
    public:
        // 생략
        static const size_t kMaxHeight = 100;
        static const size_t kMaxWidth = 100;
}
  • 선언한 상수를 다음과 같이 활용 가능
Spreadsheet::Spreadsheet(size_t width, size_t height,
	const SpreadsheetApplication& theApp)
	: mId(sCounter++)
	, mWidth(std::min(width, kMaxWidth)) // min()은 <algorithm> 에 있음!
	, mHeight(std::min(height, kMaxHeight))
{
	mCells = new SpreadsheetCell*[mWidth];
	for (size_t i = 0; i < mWidth; i++) {
		mCells[i] = new SpreadsheetCell[mHeight];
	}
}
  • mWidth & mHeight는 public 이다.
  • 하지만 접근하려면 반드시 Spreadsheet:: 처럼 클래스 이름과 스코프 지정 연산자를 붙여야 함.

9.4.3 레퍼런스 데이터 멤버

  • 스프레드시트 app 전체를 제어하는 기능 구현 필요
  • 이를 SpreadsheetApplication 클래스에 작성
class SpreadsheetApplication; // forward declaration

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

private:
    // 생략
	SpreadsheetApplication& mTheApp;

};
  • 한 app에서 여럴 스프레드 시트를 관리해야 하기 때문에 스프레드시트와 통신할 수 있어야 함
  • Spreadsheet 클래스와 SpreadsheetApplication 가 서로 알고 있어야 함.
  • 이때, 포워드 선언을 통해서 컴파일과 링크 속도를 높일 수 있다.
  • 포인터보다 레퍼런스를 사용하는 것이 바람직함.
  • why? Spreadsheet는 항상 SpreadsheetApplication를 참조하기 때문임.
  • 선언한 app 레퍼런스는 Spreadsheet 생성자로 전달되어진다.
  • 생성자 이니셜라이저에서 mTheApp의 값을 아래와 같이 반드시 지정!
Spreadsheet::Spreadsheet(size_t width, size_t height,
	const SpreadsheetApplication& theApp) // 추가된 부분
	: mId(sCounter++)
	, mWidth(std::min(width, kMaxWidth))
	, mHeight(std::min(height, kMaxHeight))
	, mTheApp(theApp) // 추가된 부분
{
	// 생략
}

9.4.4 const 레퍼런스 데이터 멤버

  • 레퍼런스 멤버도 const 객체를 가리킬 수 있음.
  • 다음과 같이 mTheApp을 const 레퍼런스로 선언하도록 변경
class Spreadsheet
{
public:
	Spreadsheet(size_t width, size_t height,
		const SpreadsheetApplication& theApp); // 변경 부분
	// 생략

private:
    // 생략
	const SpreadsheetApplication& mTheApp; // 변경 부분
};
  • 따라서 const 레퍼런스 SpreadsheetApplication 데이터 멤버는 SpreadsheetApplication 객체의 const 메서드만 호출이 가능하다.
  • 만약 non-const 메서드를 호출하면 컴파일 에러 발생

9.5 중첩 클래스

  • 클래스 정의에 중첩 클래스와 구조체, 타입 앨리어스, 열거 타입도 선언 가능
  • 클래스 안에서 다른 클래스 정의 가능
class Spreadsheet
{
public:

	class Cell
	{
	public:
		Cell() = default;
		Cell(double initialValue);
        // 생략
    };
    Spreadsheet(size_t width, size_t height,
        const SpreadsheetApplication& theApp);
    // 생략
}
  • 중첩 클래스도 일반 클래스와 똑같은 접근 제어 규칙이 적용
  • 중첩 클래스를 private or protected로 선언하면 중첩 클래스를 담고 있는 클래스 내에서만 접근 가능

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

  • 상수 여러개를 정의할 때 각각 #define 보다는 열거 타입을 활용하자.
class SpreadsheetCell
{
public:
	// 생략
	enum class Color { Red = 1, Green, Blue, Yellow };
	void setColor(Color color);
	Color getColor() const;

private:
	// 생략
	Color mColor = Color::Red;
};
  • 위와 같이 한다면, setColor()와 getColor() 메서드 구현을 간단히 정의 가능
// 메서드 구현 정의
double getValue() const { mNumAccesses++; return mValue; }
std::string getString() const { mNumAccesses++; return doubleToString(mValue); }

// 메서드 사용
SpreadsheetCell myCell(5);
myCell.setColor(SpreadsheetCell::Color::Blue);
auto color = myCell.getColor();

9.7 연산자 오버로딩

  • 객체에 대해 연산을 수행할 때가 있음.
  • 객체의 덧셈, 비교, 전달, 가져오기 등이 있다.

9.7.1 예제: SpreadsheetCell에 대한 덧셈 구현

  • 이번 절에서는 SpreadsheetCell 객체에 다른 SpreadsheetCell 객체를 더하는 예제를 살펴보자.

1. add 메서드

  • add() 메서드 정의하기
class SpreadsheetCell
{
public:
	// 생략
    // 원본 셀을 변경하지 않도록 const로 선언
	SpreadsheetCell add(const SpreadsheetCell& cell) const; 
    // 생략
};
  • add() 메서드 구현하기
SpreadsheetCell SpreadsheetCell::add(const SpreadsheetCell& cell) const
{
	return SpreadsheetCell(getValue() + cell.getValue());
}
  • add() 메서드 사용하기
SpreadsheetCell myCell(4), anotherCell(5);
SpreadsheetCell aThirdCell = myCell.add(anotherCell);

2. operator+ 오버로딩으로 구현

  • int, double 처럼 + 기호를 사용하여 구현해보자.
  • add () 메서드 정의하기
class SpreadsheetCell{
    public:
        // 생략
        SpreadsheetCell operator+(const SpreadsheetCell& cell) const;
        // 생략
};
  • add () 메서드 구현하기
SpreadsheetCell SpreadsheetCell::operator+(const SpreadsheetCell& cell) const
{
	return SpreadsheetCell(getValue() + cell.getValue());
}
  • 연산자 오버로딩도 일종의 함수 오버로딩이다.

묵시적 변환

  • operator+를 정의하면 셀끼리 더할 수 있을 뿐 아니라, 셀에 string_view, duoble, int와 같은 값도 더할 수 있음.
  • 이는 컴파일러가 operator+만 찾는 데 그치지 않고 타입을 변활할 수 있는 방법도 찾기 때문.
  • 묵시적으로 변환하지 않게 하려면 explicite 키워드를 붙임.
 class SpreadsheetCell{
    public:
        // 생략
        explicit SpreadsheetCell(std::string_view initialValue);
        // 생략
};
  • 이 키워드는 클래스를 정의하는 코드에서만 지정할 수 있다.

3. operator+ 전역 함수로 구현

  • 묵시적 변환은 SpreadsheetCell 객체가 연산자의 좌번에 있을 때만 적용
  • 일반 덧셈처럼 교환법칙이 성립하게 하려면 전역 함수로 만들면 가능
  • 전역 함수는 특정 객체에 종속되지 않기 때문.
class SpreadsheetCell
{
    // 생략
}
// 전역 함수로 정의하려면 연산자를 헤더파일에 선언
SpreadsheetCell operator+(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);
  • 아래와 같이 덧셈 연산 적용 가능
aThirdCell = myCell + 5.6;
aThirdCell = myCell + 4;
aThirdCell = 4 + myCell;   // 문제 없이 실행된다.
aThirdCell = 5.6 + myCell; // 문제 없이 실행된다.

aThirdCell = 4.5 + 5.5; // 컴파일 에러는 없지만, 정의한 operator+를 호출하지 않음

9.7.2 산술 연산자 오버로딩

  • operator+와 비슷함
  • 가능하면 기존 연산자의 의미와 최대한 일치하도록 하자.

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

  • 축약형연산자(+=, -= 등)도 제공함.
  • 이 연산자는 좌변에 반드시 객체가 나와야 함.
  • 따라서 전역 함수가 아닌 메서드로 구현해야 함.
// 축약형 산술 연산자의 선언
class SpreadsheetCell
{
public:
	// 생략
	SpreadsheetCell& operator+=(const SpreadsheetCell& rhs);
	SpreadsheetCell& operator-=(const SpreadsheetCell& rhs);
	SpreadsheetCell& operator*=(const SpreadsheetCell& rhs);
	SpreadsheetCell& operator/=(const SpreadsheetCell& rhs);
    // 생략
};

// operator+=의 구현 (다른 연산자도 비슷)
SpreadsheetCell& SpreadsheetCell::operator+=(const SpreadsheetCell& rhs)
{
	set(getValue() + rhs.getValue());
	return *this;
}

// operator+=의 사용
SpreadsheetCell myCell(4), aThirdCell(2);
aThirdCell -= myCell;
aThirdCell += 5.4;
5.4 += aThirdCell; // 작성 불가!

9.7.3 비교 연산자 오버로딩

  • <, >, <= 와 같은 비교 연산자도 클래스에 직접 정의하면 편함.
  • <op> 자리에 비교 연산자 여섯 개가 적용되도록 선언.
class SpreadsheetCell
{
    // 생략
};

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);
  • operator== 의 정의는 다음과 같다. ( 나머지 비교연산자도 비슷 )
bool operator==(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs)
{
	return (lhs.getValue() == rhs.getValue());
}

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

  • 연산자 오버로딩

    • 복잡한 일을 쉽게 해주기 위한 기능
    • 즉, 클래스 사용을 쉽게 해주는 기능
  • 핵심은 클래스를 최대한 int or double과 같은 기본 타입에 가깝게 정의!

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

  • 클래스를 작성할 때는 추상화 원칙을 적용하여 인터페이스와 구현을 최대한 분리하자!
    • 데이터 멤버를 모두 private로 지정하기
    • 게터와 세터 제공하기

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

  • 기본 원칙
    • 클래스마다 인터페이스 클래스구현 클래스를 따로 정의
    • 구현 클래스 : 이 원칙을 따르지 않고, 흔히 작성하는 클래스
    • 인터페이스 클래스 : 구현 클래스와 똑같이 public 메서드를 제공하되 구현 클래스 객체에 대한 포인터를 갖는 데이터 멤버 하나만 정의!

이를 핌플 이디엄(브릿지 패턴) 이라고 한다.

  • Spreadsheet 클래스를 위와 같은 패턴을 적용해보자.
// Spreadsheet 클래스에 브릿지 패턴 적용
#include "SpreadsheetCell.h"
#include <memory>

// 전방 선언
class SpreadsheetApplication;
// public interface class로 정의
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 size_t kMaxWidth = 100;

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

	private:
		// 구현코드는 Impl이라는 이름의 private 중첩 클래스로 정의
		// => Spreadsheet class는 Impl 인스턴스에 대한 표인터 데이터 멤버 하나만 갖게 됨.
		class Impl; 
		std::unique_ptr<Impl> mImpl;
};
  • 중첩 클래스인 Impl의 인터페이스는 기존 Spreadsheet 클래스와 거의 같지만,
  • Impl은 Spreadsheet의 private 중첩 클래스이므로 객체를 맞바꾸는 swap() 을 전역으로 가질 수 없다.
  • 따라서, swap을 private 메서드로 정의해야 함. 아래의 코드는 SpreadsheetImpl.h 이다.
#include <cstddef>
#include "Spreadsheet.h"
#include "SpreadsheetCell.h"

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

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

	size_t getId() const;

private:
	void verifyCoordinate(size_t x, size_t y) const;
	// Impl class는 Spreadsheet의 중첩 클래스이므로 Impl 객체를 맞바꾸는 전역 friend swap() 함수를 private 메서드로 정의
	void swap(Impl& other) noexcept; 

	size_t mId = 0;
	size_t mWidth = 0;
	size_t mHeight = 0;
	SpreadsheetCell** mCells = nullptr;

	const SpreadsheetApplication& mTheApp;

	static size_t sCounter;
};
  • 다음으로는 Impl이 중첩 클래스임을 주의하고 구현 코드중 일부를 보자.
// 중첩 클래스 이므로, Spreadsheep::Impl::swap() 으로 지정해야 함. 다른 멤버도 마찬가지
void Spreadsheet::Impl::swap(Impl& other) noexcept
{
	using std::swap;

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

Spreadsheet::Impl& Spreadsheet::Impl::operator=(const Impl& rhs)
{
	// 자신을 대입하는지 확인한다.
	if (this == &rhs) {
		return *this;
	}

	// 복제 후 맞바꾸기(copy-and-swap) 패턴 적용
	Impl temp(rhs); // 모든 작업을 임시 인스턴스에서 처리한다.
	swap(temp); // 예외를 발생하지 않는 연산으로만 처리한다.
	return *this;
}
  • 이렇게 인터페이스와 구현을 확실히 나누면(브릿지 패턴 적용) 좋은 장점이 있다.
  • 구현 클래스를 변경할 일이 많아져도 빌드 시간을 절약할 수 있음.
  • 인터페이스 클래스를 건드리지 않는한 구현 클래스의 코드를 변경해도 빌드시간에 영향을 받지 않음

추상 인터페이스 즉, 가상 메서드로만 구성된 인터페이스를 정의한 뒤 이를 구현하는 클래스를 따로 작성해도 가능.

내가 생각했을땐, 브릿지 패턴보다는 추상 인터페이스와 가상 메서드로 분리하는 방법이 더 간단해 보인다..

9.9 요약

  • 객체에서 동적 메모리를 사용하면 여러 문제점이 발생한다.
  • 다양한 종류의 데이터 멤버 소개
  • 메서드 오버로딩, 디폴트 인수 관련 내용
  • 중첩 클래스 정의 및 friend 클래스, 함수, 메서드
  • 인터페이스 클래스와 구현 클래스를 분리하여 추상화를 극대화

다음 장에서는 상속에 대한 내용을 알아보자.

profile
game client programmer

0개의 댓글