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

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

by 별준 2022. 2. 10.

References

Contents

  • 클래스 작성 방법
  • 생성자
  • 소멸자
  • 대입 연산자, 복사 대입 연산자

객체지향 언어인 C++은 객체를 정의하거나 사용할 수 있도록 클래스라는 기능을 제공합니다. 클래스나 객체를 사용하지 않고도 C++ 프로그램을 얼마든지 작성할 수 있지만 C++에서 가장 핵심적이면서 뛰어난 기능을 활용하지 않는 것입니다. 클래스가 하나도 없이 C++프로그램을 작성하는 것은 마치 파리로 여행을 떠나서 맥도날드에서 햄버거를 먹는 것과 같습니다. 이번 포스팅에서는 클래스의 기본 문법과 기능부터 확실하게 이해해보는 시간을 가지려고 합니다.

 

아마도 클래스를 정의하는 기본 문법 정도는 아실거라고 생각됩니다. 이번 포스팅에서 다루는 내용은 클래스 정의, 메소드 정의, 스택과 free store에 객체를 생성하고 사용하는 방법, 생성자(constructor) 작성 방법, 디폴트 생성자(default constructor), 컴파일러가 자동으로 생성하는 생성자, 생성자 이니셜라이저(constructor initializer; ctor-initializer), 복사 생성자(copy constructor), initializer-list 생성자, 소멸자(destructors), 대입 연산자(assignment operator) 등을 비롯하여 클래스와 객체에 관련된 핵심 개념들에 대해 알아볼 것입니다. 

C++ 공식 문서에는 동적 생성할 때 메모리가 할당되는 공간을 힙(heap)이 아닌 free store로 표현하고 있습니다. free store와 heap은 개념적으로 다른 공간이라고 하지만, 동일 선상으로 생각해도 큰 차이는 없을 것으로 생각됩니다.

 

이번 포스팅부터 시작해서 클래스에 대해 알아볼텐데, 실제 실행할 수 있는 간단한 스프레드시트 예제를 이용하여 여러가지 개념들을 소개하려고 합니다. 만들어 볼 스프레드시트는 셀(cell)이라는 단위로 구성된 2차원 격자로서, 각 셀은 숫자나 문자열을 담을 수 있습니다. 예제로 생성하는 어플리케이션은 Spreadsheet와 SpreadsheetCell이라는 기본 클래스를 사용하며, Spreadsheet  객체마다 SpreadsheetCell 객체를 가집니다. 그리고 이러한 Spreadsheet 객체들을 관리하는 SpreadsheetApplication이라는 클래스도 정의합니다. 

 

이번 포스팅은 SpreadsheetCell을 중심으로 소개해보도록 하겠습니다. SpreadsheetCell 코드는 아래에서 확인하실 수 있습니다.

 


1. 클래스 작성 방법

클래스를 작성하려면, 그 클래스의 모든 객체에 적용할 동작(메소드; methods)과 각 객체마다 가질 속성(데이터 멤버; properties)를 지정합니다.

 

클래스를 작성하는 과정은 클래스를 정의(.h)하는 단계와 클래스의 메소드를 정의(클래스 구현)(.cpp)하는 단계로 구성됩니다.

 

1.1 클래스 정의

SpreadsheetCell 클래스의 첫 번째 버전을 작성해보도록 하겠습니다. 간단하게 각 셀은 하나의 숫자만을 저장하도록 합니다.

class SpreadsheetCell
{
public:
    void setValue(double value);
    double getValue() const;

private:
    double m_value;
};

클래스의 정의는 항상 class 키워드와 클래스의 이름으로 시작합니다. C++에서 클래스 정의(definition)는 선언(declaration)이며, 항상 세미콜론(;)으로 끝나야 합니다.

 

클래스의 정의가 작성되는 파일 이름은 주로 클래스 이름과 같습니다. 예를 들어, SpreadsheetCell 클래스 정의는 SpreadsheetCell.h 파일에 저장합니다. 반드시 지켜야할 규칙은 아니므로 원하는 이름으로 얼마든지 지정할 수 있습니다.

 

1.1.1 클래스 멤버 Class Members

클래스는 여러 개의 멤버를 갖습니다. 멤버는 멤버 함수(메소드, 생성자, 소멸자)일 수도 있고, 멤버 변수(also called 데이터 멤버), 멤버 열거형(enumerations), 타입 앨리어스(type aliases), 중첩 클래스(nested classes) 등이 될 수도 있습니다.

 

앞서 본 코드에서 SpreadsheetCell 클래스에서 지원하는 메소드를 다음과 같이 함수 프로토 타입 선언처럼 선언했습니다.

void setValue(double value
double getValue() const;

여기서 getValue()는 객체를 변경하지 않는 메소드이기 때문에 const로 선언되었습니다.

 

클래스의 데이터 멤버는 변수 선언과 같이 선언했습니다.

double m_value;

 

클래스는 멤버 함수와 이들이 사용할 데이터 멤버를 정의합니다. 이러한 멤버들은 그 클래스에 대한 인스턴스인 객체 단위로 적용됩니다. 다만, 다음에 설명할 static member(정적 멤버)는 예외적으로 클래스 단위로 적용됩니다.

 

클래스는 개념을 정의하고 객체는 실체를 정의합니다. 따라서 객체마다 m_value 데이터 멤버를 각각 가집니다.

클래스가 가질 수 있는 멤버 함수와 데이터 멤버의 수에는 제한이 없으며, 데이터 멤버 이름은 멤버 함수의 이름과 같을 수 없습니다.

 

1.1.2 접근 제어 Access Control

클래스의 각 멤버는 3가지 접근 지정자(access specifiers)인 public, protected, private 중의 하나로 지정합니다. 한 번 지정된 접근 지정자는 다른 지정자로 변경하기 전까지 모든 멤버에 적용됩니다. SpreadsheetCell 클래스에서 setValue()와 getValue() 메소드는 public으로 지정한 반면 데이터 멤버인 m_value는 private로 지정되었습니다.

 

클래스에 접근 지정자를 따로 명시하지 않으면 private가 지정됩니다. 즉, 접근 지정자를 따로 지정하지 않고 선언한 모든 멤버의 접근 범위는 private가 적용됩니다. 예를 들어, public 접근 지정자를 setValue() 메소드 선언 뒤로 옮기면 setValue() 메소드의 접근 범위는 private로 바뀝니다.

class SpreadsheetCell
{
    void setValue(double value);  // now has private access
public:
    double getValue() const;

private:
    double m_value;
};

 

C++에서는 struct도 class처럼 메소드를 가질 수 있습니다. 사실 struct의 디폴트 접근 지정자가 public이라는 점을 제외하면 struct는 class와 같습니다. 예를 들어, 앞서 작성한 SpreadsheetCell 클래스를 다음와 같이 struct로 작성할 수 있습니다.

struct SpreadsheetCell
{
    void setValue(double value);
    double getValue() const;

private:
    double m_value;
};

데이터 멤버에 누구나 접근할 수 있으며, 메소드가 거의 없다면 관례상 주로 class 대신 struct로 정의합니다. 예를 들어, 다음과 같이 2차원 좌표를 표현하는 구조체를 정의할 때는 struct를 사용합니다.

struct Point
{
    double x;
    double y;
};

 

1.1.3 선언 순서 Order of Declarations

C++에서는 멤버와 접근 지정자를 선언하는 순서를 따로 정해두지 않았습니다. 데이터 멤버 앞에 멤버 함수를 선언해도 되고, private 뒤에 public을 선언해도 됩니다. 게다가 접근 지정자를 반복해서 지정해도 됩니다.

예를 들어, SpreadsheetCell을 다음과 같이 정의해도 됩니다.

class SpreadsheetCell
{
public:
    void setValue(double value);
private:
    double m_value;
public:
    double getValue() const;
};

하지만, 가독성을 위해 접근 지정자를 기반으로 그룹지어 선언하는 것이 좋습니다.

 

1.1.4 In-Class Member Initializers

클래스를 정의할 때, 직접 데이터 멤버를 초기화할 수 있습니다. 예를 들어, SpreadsheetCell 클래스는 m_value 멤버의 기본값을 0으로 초기화할 수 있습니다. (uniform initialization으로 초기화하고 있습니다.)

class SpreadsheetCell
{
public:
    void setValue(double value);
    double getValue() const;
private:
    double m_value{ 0 };
};

 

1.2 메소드 정의

앞서 정의한 SpreadsheetCell 클래스만으로 이 클래스의 객체를 충분히 생성할 수 있습니다. 하지만, setValue()나 getValue() 메소드를 호출한다면, 이 메소드가 정의되지 않았기 때문에 링커 에러가 발생합니다. 클래스 정의에서 메소드의 프로토타입만 선언했을 뿐 구현 코드를 작성하지 않았기 때문입니다. 함수를 만들 때는 프로토타입뿐만 아니라 함수를 구현하는 정의 코드를 함께 작성하듯이 메소드도 프로토타입뿐만 아니라 메소드를 구현하는 정의 코드도 반드시 작성해야 합니다. 보통 클래스 정의는 주로 헤더에 작성하고, 메소드 정의는 소스 파일에 작성하고 #include로 헤더 파일을 불러옵니다.

.cpp 파일에 SpreadsheetCell 클래스의 두 메소드를 다음과 같이 정의하겠습니다.

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

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

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

코드를 살펴보면 각 메소드 이름 앞에 콜론 두 개가 붙어있습니다.

void SpreadsheetCell::setValue(double value)

'::'은 scope resolution operator(범위 지정 연산자)라고 부릅니다. 문맥에서 이 문법은 컴파일러에게 setValue()의 정의가 SpreadsheetCell 클래스의 일부라고 알려줍니다.

 

1.2.1 Accessing Data Members

setValue()와 getValue()와 같은 클래스의 non-static 메소드는 항상 그 클래스의 특정 객체에 대해 실행됩니다. 메소드 바디 내에서 그 객체의 모든 클래스 데이터 멤버에 액세스합니다. 위에서 정의한 setValue()에서, 다음 문장은 메소드를 호출한 객체 내의 m_value 변수를 변경합니다.

m_value = value;

만약 서로 다른 객체에서 setValue()가 호출되었다면, 각 객체에서 한 번씩 동일한 코드가 실행되지만 각자 속한 객체의 변수값을 변경합니다.

 

1.2.2 Calling Other Methods

메소드는 같은 클래스에 정의된 다른 메소드도 호출할 수 있습니다.

예를 들어, 셀에 숫자뿐만 아니라 텍스트도 넣을 수 있도록 SpreadsheetCell 클래스를 확장한다고 해봅시다. 텍스트 값을 가진 셀을 숫자로 해석하고 싶다면 스프레드시트는 텍스트를 숫자로 변환해줍니다. 만약 텍스트가 유효한 숫자로 표현되지 않는다면, 셀의 값은 무시됩니다. 이 프로그램에서 숫자가 아닌 문자열은 셀의 값을 0으로 변환합니다.

텍스트 데이터를 지원하는 SpreadsheetCell 클래스의 첫 번째 버전은 다음과 같습니다.

#include <string>
#include <string_view>

class SpreadsheetCell
{
public:
    void setValue(double value);
    double getValue() const;

    void setString(std::string_view value);
    std::string getString() const;

private:
    std::string doubleToString(double value) const;
    double stringToDouble(std::string_view value) const;
    double m_value{ 0 };
};
위 코드는 C++17부터 지원하는 std::string_view 클래스를 사용합니다. 만약 컴파일러가 C++17을 지원하지 않는다면 std::string_view를 const std::string&으로 바꾸면 됩니다

위 버전의 클래스는 데이터를 오직 double로 저장합니다. 만약 클라이언트가 문자열로 데이터를 설정하면, 이는 double로 변환됩니다. 만약 텍스트가 유효한 숫자가 아니라면 double 값은 0으로 설정됩니다.

위 코드에서 셀에 텍스트 표현을 설정하고 가져오는 메소드 2개, double과 string 값을 상호 변환하는 private 헬퍼 메소드(helper methods) 2개를 추가로 정의했습니다. 추가된 4개의 메소드 구현은 다음과 같습니다.

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

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

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

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

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

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

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

doubleToString() 메소드는 6.1과 같은 값을 6.100000이라는 문자열로 변환합니다. 다만, private인 헬퍼 메소드로 구현했기 때문에 doubleToString() 메소드의 내부 변환 방식이 달라져서 클라이언트 코드를 수정할 필요가 전혀 없습니다.

 

1.2.3 this 포인터

모든 일반 메소드의 호출은 항상 메소드가 속한 객체의 포인터인 this를 'hidden' 파라미터 형태로 전달합니다. this 포인터를 사용해서 해당 객체의 데이터 멤버나 메소드에 접근할 수 있으며, 다른 메소드나 함수의 매개변수로 전달할 수도 있습니다. 때로는 이름을 명확히 구분하는 용도로 사용됩니다.

예를 들어, SpreadsheetCell 클래스의 m_value가 value라는 이름으로 데이터를 정의했다고 가정해봅시다. 그러면 코드는 다음과 같습니다.

void SpreadsheetCell::setValue(double value)
{
    value = value; // Confusing!
}

이렇게 작성하면 value가 클래스 멤버인지, 파라미터로 전달된 것인지 구분할 수 없습니다.

컴파일러의 종류나 설정에 따라서 위의 코드처럼 작성해도 아무런 경고나 에러 메세지가 출력되지 않을 수 있지만, 결과는 의도와 달라질 수 있습니다.

위 코드에서 value를 정확히 구분하려면, 다음과 같이 this 포인터를 사용하면 됩니다.

void SpreadsheetCell::setValue(double value)
{
    this->value = value; // Confusing!
}

 

어떤 객체의 메소드 안에서 다른 메소드나 함수를 호출하는 과정에서 그 객체의 포인터를 전달할 때도 this 포인터를 사용합니다. 예를 들어, 아래와 같이 printCell() 이라는 함수를 별도로 만든 경우를 보겠습니다.

void printCell(const SpreadsheetCell& cell)
{
    std::cout << cell.getString() << std::endl;
}

printCell() 함수를 setValue() 메소드 내에서 호출하려면 반드시 *this를 인수로 전달해야 합니다. 그래야 printCell() 안에서 cell이 호출할 메소드는 자신의 호출한 setValue()가 속한 것임을 알 수 있습니다.

void SpreadsheetCell::setValue(double value)
{
    this->value = value;
    printCell(*this);
}

 

1.3 객체 사용법

앞서 SpreadsheetCell을 정의할 때 데이터 멤버 하나, public 메소드 4개, private 메소드 2개를 정의했습니다. 당연히 이렇게 클래스를 정의한다고 해서 곧바로 SpreadsheetCell 객체가 생성되는 것은 아닙니다. 단지 형태와 동작만 표현한 것입니다.

 

C++에서 SpreadsheetCell 클래스 정의에 따라 SpreadsheetCell 객체를 생성하려면 SpreadsheetCell 타입의 변수를 따로 선언해주어야 합니다. 설계도가 있으면 집을 여러 채 지을 수 있듯이 SpreadsheetCell 클래스 하나로 여러 객체를 생성할 수 있습니다. 잘 아시겠지만, 스택에 생성하는 방법과 free store(힙)에 생성하는 방법이 있습니다.

 

1.3.1 스택에 생성한 객체

SpreadsheetCell 객체를 스택에 생성해서 사용하는 예제 코드를 살펴보겠습니다.

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

int main()
{
    SpreadsheetCell myCell, anotherCell;
    myCell.setValue(6);
    anotherCell.setString("3.2");
    std::cout << "cell 1: " << myCell.getValue() << std::endl;
    std::cout << "cell 2: " << anotherCell.getValue() << std::endl;

    return 0;
}

객체를 생성하는 방법은, 변수 타입이 클래스 이름이라는 것을 제외하면 변수를 선언하는 방법과 같습니다. myCell.setValue(6);에 나오는 점(.)을 dot operator(닷 연산자)라고 부릅니다. 이 연산자로 객체에 속한 메소드를 호출합니다. 객체의 public 데이터 멤버도 이 연산자로 접근할 수 있습니다. (참고로 데이터 멤버를 public으로 선언하는 것은 바람직한 방법은 아닙니다.)

위 코드를 실행한 결과는 다음과 같습니다.

 

1.3.2 Free Store에 생성한 객체

다음과 같이 new를 사용해서 객체를 동적으로 생성할 수 있습니다.

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

int main()
{
    SpreadsheetCell* myCellp{ new SpreadsheetCell{} };
    myCellp->setValue(3.7);
    std::cout << "cell 1 : " << myCellp->getValue() <<
        " " << myCellp->getString() << std::endl;
    delete myCellp;
    myCellp = nullptr;

    return 0;
}

free store에 객체를 생성할 때, arrow operator(화살표 연산자)를 통해 객체의 멤버에 접근할 수 있습니다. 화살표 연산자는 dereferencing(역참조) 연산자(*)member access(멤버 접근) 연산자(.)를 합친 것입니다. 화살표 대신 두 연산자를 조합해서 사용해도 되지만, 코드가 조금 복잡해질 수 있습니다.

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

int main()
{
    SpreadsheetCell* myCellp{ new SpreadsheetCell{} };
    (*myCellp).setValue(3.7);
    std::cout << "cell 1 : " << (*myCellp).getValue() <<
        " " << (*myCellp).getString() << std::endl;
    delete myCellp;
    myCellp = nullptr;

    return 0;
}

 

free store에 할당한 메모리를 항상 해제해야 하듯이, 객체 메모리도 반드시 delete로 해제해주어야 합니다. 만약 메모리와 관련된 문제를 발생하지 않게 하려면, 다음과 같이 스마트 포인터를 사용하는 것이 좋습니다.

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

int main()
{
    auto myCellp = std::make_unique<SpreadsheetCell>();
    // or std::unique_ptr<SpreadsheetCell> myCellp { new SpreadsheetCell{} };
    myCellp->setValue(3.7);
    std::cout << "cell 1 : " << myCellp->getValue() <<
        " " << myCellp->getString() << std::endl;

    return 0;
}

스마트 포인터를 사용하면 메모리를 자동으로 해제하기 때문에 직접 해제하는 문장을 작성할 필요가 없습니다.

 


 

객체의 라이프 사이클(life cycles)은 생성(creation), 소멸(destruction), 대입(assignment)의 세 단계로 구성됩니다. 객체가 생성되고 소멸되고 대입되는 시점과 방법뿐만 아니라 이러한 동작을 원하는 방식으로 변경하는 방법에 대해 알아보겠습니다.

 

2. 객체 생성 (생성자)

스택에 생성되는 객체는 선언하는 시점에 생성되고, 스마트 포인터나 new, new[]를 사용할 때는 직접 공간을 할당해야 생성이 됩니다. 객체가 생성되면 그 안에 담긴 객체도 함께 생성됩니다. 예를 들면 다음과 같습니다.

#include <string>

class MyClass
{
    private:
        std::string m_name;
};

int main()
{
    MyClass obj;
}

MyClass 안에 있는 string 객체(m_name)은 main() 함수에서 MyClass 객체가 생성될 때 함께 생성되고, MyClass 객체가 소멸될 때 함께 소멸됩니다.

 

변수를 선언할 때, 다음과 같이 초기값을 설정하는 것이 좋습니다.

int x{ 0 };

마찬가지로 객체도 선언과 동시에 초기값을 설정하는 것이 좋습니다. 이러한 작업은 생성자(constructor)라 부르는 특수한 메소드를 작성하여 객체의 초기화 작업을 수행하도록 할 수 있습니다. 그러면 객체가 생성될 때마다 생성자들 중의 하나가 실행됩니다.

constructor를 간단히 ctor 이라고 부르기도 합니다.

 

2.1 Writing Constructor

문법상, 생성자는 클래스의 이름과 똑같은 이름으로 지정됩니다. 생성자는 리턴 타입을 가지지 않으며, 필요에 따라 매개변수를 받을 수 있습니다. 아무런 인수없이 호출되는 생성자를 default constructor(디폴트 생성자)라고 부릅니다. 디폴트 생성자는 어떠한 파라미터도 받지 않거나 몯느 파라미터가 기본값으로 설정되는 생성자입니다. 특정 문맥에서는 디폴트 생성자를 작성하지 않으면 컴파일 에러가 발생할 수 있는데, 이는 뒷부분에서 조금 더 자세히 소개하겠습니다.

 

SpreadsheetCell 클래스에 생성자를 추가한 첫 버전을 작성해보겠습니다. 이전에 작성된 부분은 생략하였습니다.

class SpreadsheetCell
{
public:
    SpreadsheetCell(double initialValue);
    // ... 나머지 부분 생략
};

일반 메소드를 구현하는 것처럼, 생성자도 구현 코드를 작성해주어야 합니다. SpreadsheetCell.cpp에 위에서 정의한 생성자를 작성해보겠습니다.

SpreadsheetCell::SpreadsheetCell(double initialValue)
{
    setValue(initialValue);
}

SpreadsheetCell 생성자도 일종의 SpreadsheetCell 클래스 멤버입니다. 따라서 C++에서 생성자 이름 앞에 SpreadsheetCell:: 이라는 스코프 지정 연산자를 붙여주어야 합니다. 생성자 이름은 SpreadsheetCell 입니다. 따라서 규칙에 따라 작성하면 SpreadsheetCell::SpreadsheetCell과 같은 형태가 됩니다. 생성자 내부에서는 단순히 setValue()만 호출하도록 했습니다.

 

2.2 생성자 사용 방법

객체는 생성자를 통해 생성되고, 그 객체의 값을 초기화할 수 있습니다. 스택 객체와 free store 객체 모두 생성자를 사용할 수 있습니다.

 

먼저 스택에 할당한 SpreadsheetCell 객체의 생성자를 호출하는 방법은 다음과 같습니다.

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

int main()
{
    SpreadsheetCell myCell(5), anotherCell(4);
    std::cout << "cell 1 : " << myCell.getValue() << std::endl;
    std::cout << "cell 2 : " << anotherCell.getValue() << std::endl;

    return 0;
}

다음과 같이 uniform initialization 문법을 사용할 수도 있습니다.

SpreadsheetCell myCell{ 5 }, anotherCell{ 4 };

 

이때, SpreadsheetCell 생성자를 다음과 같이 선언과 동시에 명시적으로 호출하면 안됩니다.

SpreadsheetCell myCell.SpreadsheetCell(5); // will not compile!

마찬가지로 다음과 같이 생성자를 뒤에 호출할 수도 없습니다.

SpreadsheetCell myCell;
myCell.SpreadsheetCell(5); // will not compile!

 

SpreadsheetCell 객체를 동적으로 할당할 때 생성자는 다음과 같이 사용할 수 있습니다.

#include "SpreadsheetCell.h"

int main()
{
    auto smartCellp{ std::make_unique<SpreadsheetCell>(4) };
    // .. do something, no need to delete the smart pointer

    // or with raw pointers
    SpreadsheetCell* myCellp{ new SpreadsheetCell{5} };
    // or
    // SpreadsheetCell* myCellp{ new SpreadsheetCell(5) };
    SpreadsheetCell* anotherCellp{ nullptr };
    anotherCellp = new SpreadsheetCell{ 4 };
    // .. do something
    delete myCellp; myCellp = nullptr;
    delete anotherCellp; anotherCellp = nullptr;

    return 0;
}

스택 객체와 달리 SpreadsheetCell 객체를 포인터 방식으로 선언할 때 곧바로 생성자를 호출하지 않았습니다.

 

2.3 Multiple Constructors

클래스에 여러 개의 생성자를 만들 수 있습니다. 생성자가 여러 개더라도 이름은 모두 클래스의 이름과 똑같이 지정하고, 인수의 개수나 타입만 서로 다르게 정의합니다. C++에서 같은 이름의 함수가 둘 이상 존재할 때 컴파일러는 호출하는 시점에 매개변수 타입이 일치하는 함수를 선택합니다. 이를 오버로딩(overloading)이라고 합니다.

 

앞에서 본 SpreadsheetCell 클래스의 생성자를 하나 더 갖도록 해보겠습니다. 하나는 double 타입으로 초기값을 받고, 다른 하나는 string 타입의 초기값을 받습니다.

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

두 번째 생성자의 구현은 다음과 같습니다.

SpreadsheetCell::SpreadsheetCell(std::string_view initialValue)
{
    setString(initialValue);
}

이렇게 정의한 생성자는 다음과 같이 사용할 수 있습니다.

SpreadsheetCell aThirdCell{ "test" };  // Uses string-arg ctor
SpreadsheetCell aFourthCell{ 4.4 };    // Uses double-arg ctor
auto aFifthCellp{ std::make_unique<SpreadsheetCell>("5.5") }; // string-arg ctor
std::cout << "aThirdCell: " << aThirdCell.getValue() << std::endl;
std::cout << "aFourthCell: " << aFourthCell.getValue() << std::endl;
std::cout << "aFifthCell: " << aFifthCellp->getValue() << std::endl;

 

생성자가 여러 개라면, 한 생성자 내에서 다른 생성자를 호출할 수 있습니다. 예를 들어, 다음과 같이 string 타입 인수를 받는 생성자에서 double 타입 인수를 받는 생성자를 호출할 수 있습니다.

SpreadsheetCell::SpreadsheetCell(std::string_view initialValue)
{
    SpreadsheetCell(stringToDouble(initialValue));
}

이렇게 구현하면 문제가 없는 것처럼 보입니다. 결론부터 말하자면 일반 메소드 내에서는 다른 메소드를 호출해도 되지만, 생성자를 위와 같이 호출하면 컴파일, 링크, 실행에는 아무런 에러가 발생하지 않지만, 의도한 대로 실행되지는 않습니다. SpreadsheetCell 생성자를 이렇게 호출하면 내부적으로 이름이 없는 SpreadsheetCell 타입의 임시 객체가 생성되서 원래 초기화하려는 객체의 생성자가 호출되지 않습니다.

 

단, C++에서는 같은 클래스에서 생성자끼리 호출할 수 있도록 delegating constructor(위임 생성자)를 제공합니다. 조금 뒤에서 자세히 살펴보겠습니다.

 

2.4 Default Constructor 디폴트 생성자

디폴트 생성자는 아무런 인수도 받지 않는 생성자입니다. zero-argument constructor라고도 합니다.

 

2.4.1 디폴트 생성자가 필요한 경우

객체 배열을 생각해보겠습니다. 객체 배열을 생성하는 과정은 두 단계로 나뉩니다. 먼저 원하는 객체들을 배열에 모두 담을 정도로 충분한 공간을 연속된 메모리에 할당합니다. 그리고 나서 각 객체마다 디폴트 생성자를 호출합니다. C++은 배열 생성 코드에서 각 생성자를 직접 호출하는 기능을 제공하지 않습니다. 예를 들어, SpreadSheetCell 클래스에 디폴트 생성자를 정의하지 않으면, 다음과 같은 코드를 컴파일할 때 에러가 발생합니다.

SpreadsheetCell cells[3];  // FAILS comilation without default ctor
SpreadsheetCell* myCellp{ new SpreadsheetCell[10] }; // Also FAILS

다음과 같이 이니셜라이저를 사용하면 제약을 피할 순 있습니다.

SpreadsheetCell cells[3]{ SpreadsheetCell{ 0 }, SpreadsheetCell{ 23 },
    SpreadsheetCell{ 41 } };

그러나 일반적으로 객체 배열을 생성할 때는 클래스에 디폴트 생성자를 정의하는 것이 편합니다. 만약 생성자를 정의하지 않았다면, 컴파일러는 자동으로 디폴트 생성자를 생성해줍니다. 컴파일러가 생성해주는 생성자는 뒤에서 자세히 살펴보도록 하겠습니다.

 

2.4.2 디폴트 생성자 작성 방법

SpreadsheetCell 클래스에 디폴트 생성자 정의는 다음과 같습니다.

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

이렇게 정의한 디폴트 생성자의 구현의 첫 번째 버전은 다음과 같습니다.

SpreadsheetCell::SpreadsheetCell()
{
    m_value = 0;
}

만약 m_value에 in-class member initializer를 사용한다면, 디폴트 생성자 구현 코드에 유일한 문장마저 생략할 수 있습니다.

SpreadsheetCell::SpreadsheetCell()
{
}

 

스택에서 디폴트 생성자를 사용하는 방법은 다음과 같습니다.

SpreadsheetCell myCell;
myCell.setValue(6);
std::cout << "cell 1 : " << myCell.getValue() << std::endl;

여기서 myCell이란 이름으로 SpreadsheetCell 객체를 새로 생성한 뒤 원하는 값을 설정하고 그 값을 화면에 출력합니다. 스택 객체의 다른 생성자와 달리 디폴트 생성자는 함수 호출 형식을 따르지 않습니다. 따라서 다음과 같이 기존 함수 호출 형식으로 호출하는 실수를 저지르기 쉽습니다.

SpreadsheetCell myCell();  // WRONG, but will compile
myCell.setValue(6);        // However, this line will not compile.
std::cout << "cell 1 : " << myCell.getValue() << std::endl;

어이없지만, 컴파일 에러가 발생하는 지점은 디폴트 생성자를 호출하는 문장이 아닌 그 다음 문장입니다. 이런 문제는 일반적으로 most vexing parse라고 알려져 있으며, 컴파일러는 첫 번째 라인을 이름이 myCell이고 인수를 받지 않으며, SpreadsheetCell 객체를 리턴하는 함수 선언으로 생각합니다. 그런 다음 두 번째 문장을 보고 함수 이름을 객체처럼 사용하는 실수를 저질럿다고 착각합니다.

 

함수 호출 형식이 아닌 uniform initialization 문법을 사용하면 문제가 발생하지 않습니다.

SpreadsheetCell myCell{}; // Calls the default ctor

 

free store 객체 할당에서 디폴트 생성자를 호출하는 방법은 다음과 같습니다.

auto smartCellp{ make_unique<SpreadsheetCell>() };
// Or with a raw pointer (not recommended)
SpreadsheetCell* myCellp{ new SpreadsheetCell { } };
// Or
// SpreadsheetCell* myCellp{ new SpreadsheetCell };
// Or
// SpreadsheetCell* myCellp{ new SpreadsheetCell() };
// ... use myCellp
delete myCellp; myCellp = nullptr;

 

2.4.3 Compiler-Generated Default Constructor

포스팅 초반에 정의한 SpreadsheetCell 클래스의 첫 번째 버전은 다음과 같습니다.

class SpreadsheetCell
{
public:
    void setValue(double value);
    double getValue() const;
private:
    double m_value;
};

여기서 디폴트 생성자를 정의하지 않았지만, 다음과 같이 코드를 작성해도 컴파일 에러가 발생하지 않습니다.

SpreadsheetCell myCell;
myCell.setValue(6);

 

다음 코드는 명시적으로 생성자를 추가해준 것을 제외하고는 첫 번째 버전의 클래스 정의와 동일합니다. 여기서도 여전히 디폴트 생성자는 명시적으로 선언하지 않았습니다.

class SpreadsheetCell
{
public:
    SpreadsheetCell(double initialValue); // no default ctor
    // .. 나머지 코드 생략
};

이 클래스 정의에서, 다음의 코드는 더 이상 컴파일이 되지 않습니다.

SpreadsheetCell myCell;
myCell.setValue(6);

 

그 이유는 생성자를 하나도 지정하지 않으면 인수를 받지 않는 디폴트 생성자를 컴파일러가 대신 만들어주기 때문입니다. 이렇게 컴파일러가 생성한 디폴트 생성자는 해당 클래스의 객체 멤버에 대해서도 디폴트 생성자를 호출해줍니다. 하지만 int나 double과 같은 기본 타입에 대해서는 초기화하지 않습니다. 이렇게 해서 클래스의 객체를 생성할 수는 있지만 디폴트 생성자나 다른 생성자를 하나라도 선언하면 컴파일러는 디폴트 생성자를 자동으로 만들지 않습니다.

 

2.4.4 명시적 디폴트 생성자

C++11 이전에는 인수를 받는 생성자를 여러 개 정의할 때, 디폴트 생성자가 하는 일이 없더라도 빈 디폴트 생성자를 반드시 명시적으로 정의해주어야 했습니다.

 

이렇게 빈 디폴트 생성자를 수작업으로 작성해주는 수고를 덜기 위해 C++은 명시적 디폴트 생성자(explicitly defaulted default constructors)를 제공합니다. 이를 이용하면 다음과 같이 클래스 구현 코드에 디폴드 생성자를 작성하지 않아도 됩니다.

class SpreadsheetCell
{
public:
    SpreadsheetCell() = default;
    SpreadsheetCell(double initialValue);
    SpreadsheetCell(std::string_view initialValue);
    // .. 나머지 코드 생략
};

위의 SpreadsheetCell 클래스에는 두 개의 생성자를 직접 정의했습니다. 그런데 여기서 명시적으로 default 키워드를 사용했기 때문에 생성자를 따로 정의했음에도 불구하고 컴파일러는 디폴트 생성자를 자동으로 생성합니다.

 

2.4.5 명시적으로 삭제된 생성자

C++에는 명시적으로 삭제된 생성자(explicitly deleted default constructor)라는 개념도 지원합니다. 예를 들어, 오직 static 메소드로만 구성된 클래스를 정의하면 생성자를 작성할 필요가 없을 뿐만 아니라 컴파일러가 디폴트 생성자를 만들면 안됩니다. 이럴 때는 다음과 같이 디폴트 생성자를 명시적으로 삭제해야 합니다.

class MyClass
{
public:
    MyClass() = delete;
};

 

2.5 Contructor Initializers

지금까지 살펴본 코드는 다음과 같이 데이터 멤버를 생성자 안에서 초기화했습니다.

SpreadsheetCell::SpreadsheetCell(double initialValue)
{
    setValue(initialValue);
}

C++은 생성자에서 데이터 멤버를 초기하기 위한 또 다른 방법인 생성자 이니셜라이저(ctor-initializer 또는 member initializer list)를 제공합니다. 방금 살펴본 SpreadsheetCell 생성자를 이 방법을 사용하여 다음과 같이 다시 작성할 수 있습니다.

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

위 코드를 살펴보면 ctor-initializer는 문법적으로 생성자 인수 리스트와 생성자 본문을 시작하는 첫 중괄호 사이에 나옵니다. 이 구문은 콜론(:)으로 시작하며 각 항목을 쉼표(,)로 구분합니다. 리스트의 각 항목은 함수 호출 형식(function notation) 또는 uniform initialization 문법, 베이스 클래스 생성자 호출, 위임된 생성자 호출 등이 될 수 있습니다.

 

ctor-initializer로 데이터 멤버를 초기화하는 방법은 생성자 본문에서 초기화하는 것과 다릅니다. C++에서 객체를 생성하려면 생성자를 호출하기 전에 그 객체를 구성하는 모든 데이터 멤버부터 생성해야 합니다. 이렇게 데이터 멤버를 생성하는 과정에서 각 멤버가 다른 객체로 구성됬다면 해당 생성자를 호출합니다. 생성자 안에서 객체에 값을 할당하는 시점에는 객체가 이미 생성된 상태입니다. 여기서는 단지 값을 변경할 뿐입니다. ctor-initializer를 이용하면 데이터 멤버를 생성하는 과정에서 초기값을 설정할 수 있는데, 이렇게 하는 것이 나중에 따로 값을 대입하는 것보다 효율적입니다.

 

만약 클래스를 구성하는 데이터 멤버에 대해 디폴트 생성자가 정의되어 있다면, ctor-initializer에서 이 객체를 명시적으로 초기화하지 않아도 됩니다. 예를 들어, std::string 타입의 데이터 멤버가 있을 때, 이 멤버의 디폴트 생성자에 의해 이 멤버의 값을 공백으로 초기화하기 때문에 이니셜라이저에 ""을 지정하면 같은 코드를 2번 쓰는 것과 같습니다.

반면, 클래스에 있는 데이터 멤버에 대해 디폴트 생성자가 정의되어 있지 않다면 ctor-initializer를 사용해 그 객체를 적절히 초기화해주어야 합니다. 예를 들어, SpreadsheetCell 클래스를 다음과 같이 정의할 수 있습니다.

class SpreadsheetCell
{
public:
    SpreadsheetCell(double d);
};

이 클래스는 double 타입의 값을 받는 명시적 생성자만 있을 뿐 디폴트 생성자는 없습니다. 이 클래스의 객체를 다음과 같이 다른 클래스의 데이터 멤버로 정의하는 경우를 살펴보겠습니다.

class SomeClass
{
public:
    SomeClass();
private:
    SpreadsheetCell m_cell;
}

그러고 나서 SomeClass 생성자를 구현하는 코드를 다음과 같이 작성했다고 가정해봅시다.

SomeClass::SomeClass() { }

그러나 이렇게 작성하면 컴파일 에러가 발생합니다. m_cell 데이터 멤버는 디폴트 생성자가 없기 때문에 컴파일러는 m_cell을 초기화할 방법을 알 수 없습니다.

따라서, m_cell을 초기화하려면 다음과 같이 ctor-initializer를 작성해야 합니다.,

SomeClass::SomeClass() : m_cell{ 1.0 } { }
ctor-initializer를 사용하면 객체를 생성하는 시점에 데이터 멤버를 초기화할 수 있습니다.

 

생성자의 본문에서 초기값을 대입하는 방식을 더 선호할 수 있습니다. 물론 이렇게 하면 효율성은 조금 떨어집니다. 하지만, 어떤 데이터 타입은 반드시 ctor-initializer나 in-class initializer 구문으로 초기화해야 합니다. 이에 해당하는 타입은 다음과 같습니다.

  • const data members : const 변수는 생성된 후에는 정상적인 방식으로 값을 대입할 수 없습니다. 반드시 생성 시점에 값이 지정해주어야 합니다.
  • Reference data members : 가리키는 대상 없이 레퍼런스가 존재할 수 없습니다.
  • Object data members for which there is no default constructor : C++은 객체 멤버를 디폴트 생성자로 초기화합니다. 디폴트 생성자가 없다면 초기화할 수 없습니다.
  • Base classes without default constructor : 이는 다음 포스팅에서 다루도록 하겠습니다.

 

생성자 이니셜라이저를 사용할 때 한 가지 주의할 점이 있습니다. 생성자 이니셜라이저에 나열한 데이터 멤버는 나열 순서가 아닌 클래서에서 정의된 순서로 초기화된다는 것입니다.

예를 들어, 다음과 같이 정의된 Foo 클래스를 살펴보겠습니다. 여기에 정의된 생성자는 단순히 double 값을 저장한 뒤 콘솔에 출력합니다.

class Foo
{
public:
    Foo(double value);
private:
    double m_value{ 0 };
};

Foo::Foo(double value) : m_value{ value }
{
    std::cout << "Foo::m_value = " << m_value << std::endl;
}

그리고, Foo 객체를 데이터 멤버로 가지고 있는 MyClass라는 다른 클래스가 있다고 가정해봅시다.

class MyClass
{
public:
    MyClass(double value);
private:
    double m_value{ 0 };
    Foo m_foo;
};

MyClass::MyClass(double value) : m_value{ value }, m_foo{ m_value }
{
    std::cout << "MyClass::m_value = " << m_value << std::endl;
}

ctor-initializer는 처음 주어진 value 값을 m_value에 저장한 다음, m_value를 인수로 전달하여 Foo 생성자를 호출합니다. Myclass의 인스턴스는 다음과 같이 생성할 수 있으며, 출력 결과는 다음과 같습니다.

MyClass instance{ 1.2 };

지금까지는 문제가 없습니다. 이번에는 MyClass 클래스 정의에서 m_value와 m_foo 데이터 멤버의 순서를 반대로 바꿔보도록 하겠습니다.

class MyClass
{
public:
    MyClass(double value);
private:
    Foo m_foo;
    double m_value{ 0 };
};

그러면 실행 결과는 다음과 같이 나옵니다.

구체적인 값은 시스템마다 다르겠지만, 의도와 전혀 다른 결과가 나온다는 것은 명확합니다. 생성자 이니셜라이저를 보면 Foo 생성자에서 m_value를 사용하기 전에 m_value가 초기화될 것이라고 생각했지만, 실제로는 그렇지 않습니다. 데이터 멤버는 생성자 이니셜라이저에 나온 순서가 아니라 클래스 정의에 나온 순서대로 초기화되기 때문입니다. 따라서, Foo 생성자가 호출될 때 초기화되지 않은 m_value가 전달됩니다.

 

참고로 어떤 컴파일러는 클래스 정의에 나온 순서와 생성자 이니셜라이저에 나온 순서가 다르면 경고 메세지를 출력합니다.

 

위 예제 코드는 Foo 생성자에 m_value가 아닌 value 파라미터를 전달하여 쉽게 수정할 수 있습니다.

MyClass::MyClass(double value) : m_value{ value }, m_foo{ value }
{
    std::cout << "MyClass::m_value = " << m_value << std::endl;
}

 

2.6 Copy Constructor

C++에는 복사 생성자(copy constructor)라는 특수한 생성자를 제공합니다. 복사 생성자는 다른 객체와 정확히 동일한 복사본을 생성할 때 사용합니다. 

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

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

복사 생성자는 원본 객체에 대한 const 레퍼런스를 인스로 받습니다. 다른 생성자와 마찬가지로 리턴값은 없습니다. 복사 생성자는 원본 객체로부터 모든 데이터 멤버를 복사합니다. 물론 구체적인 동작은 원하는 대로 얼마든지 바꿔도 되지만, 관례를 벗어나지 않는 것이 바람직하므로 새로 만들 객체의 데이터 멤버를 모두 기존 객체의 데이터 멤버로 초기화합니다.

이렇게 작성한 SpreadsheetCell의 복사 생성자는 다음과 같습니다. 여기서 ctor-initializer에 주목합니다.

SpreadsheetCell::SpreadsheetCell(const SpreadsheetCell& src)
    : m_value{ src.m_value }
{
}

만약 복사 생성자를 직접 작성하지 않았다면, C++은 원본 객체의 데이터 멤버의 값과 동일한 값으로 새로운 객체의 데이터 멤버를 초기화하는 복사 생성자를 자동으로 생성해줍니다. 만약 데이터 멤버가 객체라면, 해당 데이터 멤버의 복사 생성자를 호출합니다.

데이터 멤버가 m1, m2, ..., mn과 같이 선언되어 있다면, 컴파일러는 다음과 같은 복사 생성자를 생성해줍니다.

classname::classname(const classname& src)
    : m1{ src.m1 }, m2{ src.m2 }, ..., mn{ src.mn } { }

 

2.6.1 복사 생성자가 호출되는 경우

C++에서 함수로 인수를 전달하는 기본 방식은 pass-by-value 입니다. 이는 함수나 메소드는 값 또는 객체의 복사본을 전달받는다는 것을 의미합니다. 따라서 함수나 메소드에 객체를 전달하면 컴파일러는 그 객체의 복사 생성자를 호출하는 방식으로 초기화합니다.

예를 들어, 다음과 같이 string 매개변수를 값으로 받는 printString() 함수가 있다고 가정해봅시다.

void printString(std::string value)
{
    std::cout << value << std::endl;
}

C++에서 제공하는 string 타입은 사실 기본 타입이 아닌 클래스입니다. 그래서 코드에서 printString()에 string 매개변수를 전달해서 호출하면 string 매개변수인 value는 이 클래스의 복사 생성자를 호출하는 방식으로 초기화됩니다. 이 복사 생성자의 인수가 바로 printString()에 전달한 string입니다.

다음과 같이 printString()에서 매개변수를 name이라는 이름으로 지정해서 호출하면 value 객체를 초기화할 때 string의 복사 생성자가 실행됩니다.

std::string name "heading one";
printString(name);  // copyies name

printString() 메소드가 실행을 마치면, value는 삭제됩니다. 이 값은 실제로 name의 복사본이므로 name은 원래 값 그대로 남아 있습니다. 물론 복사 생성자에 매개변수를 const 레퍼런스로 전달하면 복사 생성성자의 오버헤드를 줄일 수 있습니다.

함수에서 객체를 값으로 리턴할 때도 복사 생성자가 호출되는데, 이는 'Objects as Return Values'에서 조금 더 자세히 살펴보겠습니다.

 

2.6.2 명시적으로 복사 생성자 호출하기

복사 생성자를 명시적으로 호출할 수도 있습니다. 주로 다른 객체를 똑같이 복사하는 방식으로 객체를 만들 때 이 방식을 사용합니다. 다음의 코드는 SpreadsheetCell 객체의 복사본을 복사 생성자를 호출하여 만드는 방법을 보여줍니다.

SpreadsheetCell myCell1{ 4 };
SpreadsheetCell myCell2{ myCell1 }; // myCell2 has the same values as myCell1

 

2.6.3 Passing Objects by Reference

함수나 메소드에 객체를 레퍼런스(reference)로 전달하면 복사 연산으로 인한 오버헤드를 줄일 수 있습니다. 객체에 있는 내용 전체가 아닌 객체의 주소만을 복사하기 때문에, 객체를 레퍼런스로 전달하는 방식이 값으로 전달하는 것보다 대체로 효율적입니다. 또한, pass-by-reference를 사용하면 객체의 동적 메모리 할당에 관련된 문제도 피할 수 있습니다.

 

객체를 레퍼런스로 전달할 때는 그 값을 사용하는 함수나 메소드가 원본 객체를 변경할 수 있습니다. 단지 성능상의 이유로 레퍼런스 전달 방식을 사용한다면 객체가 변경되지 않다록 객체 앞에 const를 붙여주어야 합니다. 이를 reference-to-const라고 합니다.

 

참고로 SpreadsheetCell 클래스를 보면 std::string_view를 매개변수로 받는 메소드가 있습니다. string_view는 포인터와 길이만을 가지고 있어 복사 오버헤드가 적기 때문에 주로 pass-by-value 방식으로 작성합니다.

int, double 등과 같은 기본 타입은 값으로 전달하는 것이 좋습니다. reference-to-const 방식을 사용하더라도 크게 얻을 수 있는 것은 없습니다.

 

SpreadsheetCell 클래스의 doubleToString() 메소드는 항상 string 객체를 값으로 리턴하는데, 이 메소드의 마지막에서 로컬 string 객체를 생성하여 리턴합니다. 이 string을 레퍼런스로 리턴하면 제대로 동작하지 않으며, 이는 그 레퍼런스가 참조하는 string은 함수가 끝나면서 삭제되기 때문입니다.

 

2.6.4 Explicitly Defaulted and Deleted Copy Constructors

컴파일러가 생성한 복사 생성자를 명시적으로 디폴트로 만들거나 삭제할 수 있습니다.

SpreadsheetCell(const SpreadsheetCell& src) = default;

or

SpreadsheetCell(const SpreadsheetCell& src) = delete;

복사 생성자를 삭제하면 더 이상 복사할 수 없습니다. 보통 객체를 값으로 전달하지 않도록 할 때 이렇게 설정합니다.

클래스에 복사 생성자가 삭제된 데이터 멤버가 있다면, 그 클래스 또한 복사 생성자가 자동으로 삭제됩니다.

 

2.7 Initializer-List Constructors

이니셜라이저 리스트 생성자(initializer-list constructor)는 std::initializer_list<T>를 첫 번째 매개변수로 받고, 다른 매개변수는 없거나 디폴트값을 가진 매개변수를 추가로 받는 생성자를 말합니다. std::initializer_list<T> 클래스 템플릿은 <initializer_list>에 정의되어 있습니다.

사용법은 다음과 같습니다. 아래에 정의된 클래스는 짝수 개의 원소를 가진 이니셜라이저 리스트만을 매개변수로 받습니다. 만약 짝수개가 아니라면 예외가 발생합니다.

class EvenSequence
{
public:
    EvenSequence(std::initializer_list<double> args)
    {
        if (args.size() % 2 != 0) {
            throw std::invalid_argument("initializer_list shoudl "
                "contain even number of elements");
        }
        m_sequence.reserve(args.size());
        for (const auto& value : args)
            m_sequence.push_back(value);
    }

    void dump() const
    {
        for (const auto& value : m_sequence)
            std::cout << value << ", ";
        std::cout << std::endl;
    }
private:
    std::vector<double> m_sequence;
};

이니셜라이저 생성자 내에서 각 원소에 접근하는 부분은 range-based for문으로 구현할 수 있습니다. 그리고 이니셜라이저 리스트의 원소 수는 size() 메소드로 알아낼 수 있습니다.

 

또한, 이니셜라이저 리스트의 원소들을 for문을 이용해서 복사하는데, 이렇게 하지 않고 vector의 assign() 메소드를 사용해도 됩니다.

EvenSequence(std::initializer_list<double> args)
{
    if (args.size() % 2 != 0) {
        throw std::invalid_argument("initializer_list shoudl "
            "contain even number of elements");
    }
    m_sequence.assign(args);
}

 

EvenSequence 객체는 다음과 같이 생성합니다.

EvenSequence p1{ 1.0,2.0,3.0,4.0,5.0,6.0 };
p1.dump();

try {
    EvenSequence p2{ 1.0, 2.0,3.0 };
}
catch (const std::invalid_argument& e) {
    std::cout << e.what() << std::endl;
}

p2 생성자에서는 이니셜라이저 리스트의 원소 개수가 홀수이기 때문에 예외가 발생합니다.

 

표준 라이브러리는 이니셜라이저 리스트 생성자를 완전히 지원합니다. 예를 들어, std::vector 컨테이너는 다음과 같이 이니셜라이저 리스트를 사용해 초기화할 수 있습니다.

std::vector<string> myVec{ "String 1", "String 2", "String 3" };

 

2.8 Delegating Constructor 위임 생성자

위임 생성자(또는 생성자 위임)을 사용하면 같은 클래스의 다른 생성자를 생성자 안에서 호출할 수 있습니다. 하지만 생성자 안에서 다른 생성자를 직접 호출할 수는 없습니다. 반드시 생성자 이니셜라이저(ctor-initializer)에서 호출해야 하며, member-initializer 리스트에 이것만 작성해야 합니다.

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

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

여기서 string_view 타입의 생성자(위임 생성자)가 호출되면, 이를 타겟 생성자(여기서는 double 타입의 생성자)에 위임합니다. 타겟 생성자가 리턴하면 위임 생성자의 코드가 실행됩니다.

 

위임 생성자를 사용할 때, 다음과 같이 생성자가 재귀적으로 호출되지 않도록 주의해야 합니다.

class MyClass
{
    MyClass(char c) : MyClass{ 1.2 } {}
    MyClass(double d) : MyClass{ 'm' } { }
};

첫 번째 생성자는 두 번째 생성자에 위임하는데, 두 번째 생성자는 다시 첫 번째 생성자에 위임합니다. 이러한 동작에 대해 C++에는 정의된 동작이 없기 때문에 컴파일러마다 구체적인 동작은 다릅니다.

 

2.9 Converting Constructors and Explicit Constructors

지금까지 살펴본 SpreadsheetCell의 생성자들은 다음과 같습니다.

class SpreadsheetCell
{
public:
    SpreadsheetCell() = default;
    SpreadsheetCell(double initialValue);
    SpreadsheetCell(std::string_view initialValue);
    SpreadsheetCell(const SpreadsheetCell& src);
    // .. 나머지 코드 생략
};

하나의 파라미터만 받는 double과 string_view 생성자는 double 또는 string_view를 SpreadsheetCell로 변환하는데 사용할 수 있습니다. 이러한 생성자를 converting constructor(변환 생성자)라고 합니다. 컴파일러는 이 생성자를 사용해서 암시적인 변환을 수행합니다.

다음의 예제 코드를 살펴보겠습니다.

SpreadsheetCell myCell{ 4 };
myCell = 5;
myCell = "6"sv;  // A string_view literal

이 코드는 항상 원하는 대로 동작하지는 않습니다. 컴파일러가 암시적 변환을 하지 않도록 하려면 생성자에 explicit을 작성해주면 됩니다. explicit 키워드는 오직 클래스 정의 내에서만 사용할 수 있습니다.

class SpreadsheetCell
{
public:
    SpreadsheetCell() = default;
    SpreadsheetCell(double initialValue);
    explicit SpreadsheetCell(std::string_view initialValue);
    SpreadsheetCell(const SpreadsheetCell& src);
    // .. 나머지 코드 생략
};

이렇게 변경해주면 이제 더이상 아래 코드는 컴파일이 되지 않습니다.

myCell = "6"sv;  // A string_view literal

 

C++11 이전에는 변환 생성자는 오직 하나의 파라미터만을 가질 수 있었습니다. 하지만 C++11에서부터 리스트 초기화의 지원 덕분에 여러 개의 파라미터를 받을 수 있습니다. 다음 예제 코드를 살펴보겠습니다.

class MyClass
{
public:
    MyClass(int) {}
    MyClass(int, int) {}
};

이 클래스는 2개의 생성자를 가지고 있고, C++11에서부터 둘 다 변환 생성자입니다. 다음 예제는 컴파일러가 자동으로 주어진 인수 1, {1}, {1,2}를 변환 생성자를 사용하여 MyClass의 인스턴스로 변환하는 것을 보여줍니다.

void process(const MyClass& c) {}

int main()
{
    process(1);
    process({ 1 });
    process({ 1, 2 });
}

이러한 컴파일러에 의한 암시적 변환을 막기 위해서는 두 변환 생성자에 explict을 표시해주면 됩니다.

class MyClass
{
public:
    explicit MyClass(int) {}
    explicit MyClass(int, int) {}
};

이렇게 변경하면, 이제 다음과 같이 명시적으로 변환을 수행해야 합니다.

process(MyClass{ 1 });
process(MyClass{ 1, 2 });
C++20에서부터는 explicit에 boolean 인수를 전달할 수 있습니다.
ex) explicit(true) MyClass(int);

 

2.10 컴파일러가 생성하는 생성자들

컴파일러는 모든 클래스에 디폴트 생성자와 복사 생성자를 자동으로 만들어줍니다. 다만, 프로그래머가 직접 작성한 생성자에 따라 컴파일러에서 자동으로 만들어주는 생성자가 달라질 수 있는데, 구체적인 규칙은 다음과 같습니다.

살펴보면, 디폴트 생성자와 복사 생성자 사이에 일정한 패턴이 없다는 것을 알 수 있습니다. 복사 생성자를 명시적으로 정의하지 않는 한 컴파일러는 무조건 복사 생성자를 생성합니다. 반면 어떠한 생성자라도 정의했다면 컴파일러는 디폴트 생성자를 생성하지 않습니다.

 

또 다른 종류의 생성자로 move constructor(이동 생성자)가 있습니다. 이 생성자는 move semantics를 구현하는데 필요합니다. 이는 특정한 상황에서 성능을 높이기 위한 목적으로 사용하는데, 자세한 내용은 다음 포스팅에서 다루도록 하겠습니다.

 

3. 객체 소멸 (소멸자)

객체가 소멸되는 과정은 두 단계로 구성됩니다. 먼저 객체의 소멸자(destructor) 메소드를 호출한 다음 할당받은 메모리를 반환합니다. 객체를 정리하는 작업은 소멸자에서 구체적으로 지정할 수 있으며, 동적 메모리를 해제하거나 파일 핸들을 닫는 작업을 여기서 처리할 수 있습니다. 소멸자를 선언하지 않으면 컴파일러가 자동으로 만들어주는데, 이 소멸자는 멤버를 따라 재귀적으로 소멸자를 호출하면서 객체를 해제합니다.

클래스의 소멸자는 클래스의 이름 앞에 물결 표시(~, tilde)를 붙인 이름의 메소드이며, 아무것도 리턴하지 않습니다.

다음은 간단히 콘솔에 출력만 행하는 소멸자로 작성된 예제 코드입니다.

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

SpreadsheetCell::~SpreadsheetCell()
{
    std::cout << "Desctuctor called." << std::endl;
}

스택 객체는 현재 실행하던 함수, 메소드 또는 코드 블록(execution block)이 끝날 때와 같이 스코프를 벗어날 때 자동으로 삭제됩니다. 다시 말하자면, 닫는 중괄호를 만날 때마다 중괄호로 묶인 코드의 스택에 생성된 객체가 모두 삭제됩니다.

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

int main()
{
    SpreadsheetCell myCell{ 5 };
    if (myCell.getValue() == 5) {
        SpreadsheetCell anotherCell{ 6 };
    } // anotherCell is destoryed as this block ends.
    
    std::cout << "myCell: " << myCell.getValue() << std::endl;
} // myCell is destroyed as this block ends.

스택 객체가 삭제되는 순서는 선언 및 생성 순서와 반대입니다. 예를 들어, 다음 코드에서는 myCell2를 먼저 생성한 뒤 anotherCell2를 생성했기 때문에 anotherCell2가 먼저 삭제된 후 myCell2가 삭제됩니다.

{
    SpreadsheetCell myCell2{ 4 };
    SpreadsheetCell anotherCell2{ 5 }; // myCell2 constructed before anotherCell2
} // anotherCell2 destroyed before myCell2

이러한 순서는 객체로 된 데이터 멤버에 대해서도 똑같이 적용된다. 앞에서 데이터 멤버는 클래스에 선언된 순서대로 초기화된다고 설명했습니다. 따라서 객체의 생성 순서와 반대로 삭제되는 규칙을 적용하면 데이터 멤버 객체도 클래스에 선언된 순서와 반대로 삭제됩니다.

 

스마트 포인터의 도움없이 free store에 할당된 객체는 자동으로 삭제되지 않습니다. 객체 포인터에 대해 delete를 명시적으로 호출해서 그 객체의 소멸자를 호출하고 메모리를 해제해야 합니다.

int main()
{
    SpreadsheetCell* cellPtr1{ new SpreadsheetCell { 5 } };
    SpreadsheetCell* cellPtr2{ new SpreadsheetCell { 6 } };
    std::cout << "cellPtr1: " << cellPtr1->getValue() << std::endl;
    delete cellPtr1; // Destroys cellPtr1
    cellPtr1 = nullptr;
} // cellPtr2 is NOT destroyed because delete was not called on it.

 

4. 객체 대입 (대입 연산자)

C++ 코드에서 int 값을 다른 곳에 대입할 수 있듯이 객체의 값을 다른 객체에 대입할 수 있습니다. 예를 들어 anotherCell 객체에 myCell의 값을 대입하려면 다음과 같이 작성하면 됩니다.

SpreadsheetCell myCell{ 5 }, anotherCell;
anotherCell = myCell;

myCell이 antherCell에 복사된다고 표현하기 쉬운데, C++에서 복사(copy)는 객체를 초기화할 때만 적용되는 표기입니다. 이미 할당된 객체를 덮어쓸 때는 대입(assign)이라고 표현합니다. 참고로 C++에서 복사 기능은 복사 생성자에서 제공합니다. 일종의 생성자이기 때문에 객체를 생성하는 데만 사용할 수 있고, 생성된 객체를 다른 값에 대입하는 데는 쓸 수 없습니다.

 

이 때문에 C++은 클래스마다 대입을 수행하는 메소드를 따로 제공합니다. 이 메소드를 대입 연산자(assignment operator)라고 부릅니다. 이 연산자는 클래스에 있는 = 연산자를 오버로딩한 것이기 때문에 이름은 operator=입니다. 방금 본 예제 코드에서 anotherCell의 대입 연산자는 myCell이라는 인수를 전달해서 호출된다.

지금 설명한 대입 연산자는 복사 대입 연산자(copy assignment operator)라고도 부릅니다. 좌변과 우변에 있는 객체가 대입 후에도 남아 있기 때문입니다. 이렇게 표현하는 이유는 성능상의 이유로 대입 후에 우변의 객체가 삭제되는 이동 대입 연산자(move assignment operator)와 구분하기 위해서 입니다.

보통의 경우에는 대입 연산자를 직접 정의하지 않아도 되며, C++에서 객체끼리 서로 대입할 수 있도록 자동으로 만들어줍니다. 이렇게 만들어 주는 디폴트 대입 연산자는 디폴트 복사 생성자 동작과 거의 같습니다. 즉, 원본 데이터 멤버를 대상 객체로 대입하는 작업을 재귀적으로 수행합니다.

 

4.1 대입 연산자 선언

SpreadsheetCell 클래스의 대입 연산자를 다음과 같이 선언했습니다.

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

대입 연산자는 복사 생성자처럼 원본 객체에 대한 const 레퍼런스(reference-to-const)를 받을 때가 많습니다. 위 코드에서 소스 객체를 rhs라고 표현했는데, 등호의 우변(right-hand side, 우항)의 줄임말입니다. 물론 이름은 마음대로 지정해도 됩니다. 대입 연산자가 호출되는 객체는 등호의 좌변(left-hand side, 좌항)에 있는 객체입니다.

 

그런데 대입 연산자는 복사 생성자와는 달리 SpreadsheetCell 객체에 대한 레퍼런스를 리턴합니다. 그 이유는 다음과 같이 여러 개의 대입 연산이 연속해서 일어날 수 있기 때문입니다.

myCell = anotherCell = aThirdCell;

이 문장이 실행되면 가장 먼저 anotherCell의 우변에 있는 aThirdCell을 대입하는 연산자가 호출됩니다. 그다음으로 myCell에 대한 대입 연산자가 호출됩니다. 그런데 이 연산자의 매개변수는 anotherCell이 아닙니다. aThirdCell을 anotherCell에 대입한 결과가 이 연산의 우변이 됩니다. 이 대입 연산이 제대로 된 결과를 리턴하지 않으면 myCell로 아무것도 전달되지 않습니다.

 

myCell에 대한 대입 연산자가 곧바로 anotherCell을 대입하지 않는 이유는 다음과 같습니다.

myCell.operator=(anotherCell.operator=(aThirdCell));

좀 전에 등호 기호는 실제로 메소드 호출을 간략이 표현한 것에 불과하기 때문에 등호를 풀어쓰면 위와 같이 됩니다. 이렇게 표현하고 나면 anotherCell에서 호출하는 operator=는 반드시 어떤 값을 리턴해야 합니다. 그래야 myCell에 대한 operator=에 그 값을 전달할 수 있습니다. 정상적이라면 anotherCell이 리턴되어야 하며, 여기서 직접 리턴하면 성능이 떨어지므로 antherCell에 대한 레퍼런스를 리턴합니다.

사실 대입 연산자의 리턴 타입을 void로 설정하거나 원하는 타입으로 정할 수 있습니다.

 

4.2 대입 연산자 정의

대입 연산자를 구현하는 방법은 복사 생성자와 비슷하지만 몇 가지 중요한 차이점이 있습니다.

첫째, 복사 생성자는 초기화할 때 단 한 번만 호출됩니다. 그 시점에는 타겟 객체가 유효한 값을 가지고 있지 않습니다. 대입 연산자는 객체에 이미 할당된 값을 덮어씁니다. 그래서 객체에서 메모리를 동적으로 할당하지 않는 한 이 차이점은 크게 드러나지 않습니다. 자세한 사항은 다음 포스팅에서 다루도록 하겠습니다.

둘째, C++은 객체에 자기 자신을 대입할 수 있습니다. 예를 들어 다음과 같이 작성해도 컴파일 에러는 발생하지 않습니다.

SpreadsheetCell cell{ 4 };
cell = cell; // self-assignment

따라서 대입 연산자를 구현할 때 자기 자신을 대입하는 경우도 반드시 고려해야 합니다.

현재 SpreadsheetCell 클래스에서는 단 하나의 데이터 멤버가 double 타입이기 때문에 이 문제가 중요하지는 않습니다. 하지만 클래스에 동적으로 할당한 메모리나 다른 리소스가 있다면 자기 자신을 대입하는 작업을 처리하기 쉽지 않습니다. 이에 대한 내용도 다음 포스팅에서 자세하게 다룰 예정입니다. 이런 문제를 피하려면 대입 연산자를 시작하는 부분에서 자기 자신을 대입하는지 확인해서 만약 그렇다면 곧바로 리턴하게 만들면 됩니다.

 

따라서 SpreadsheetCell 클래스에 정의한 대입 연산자의 앞부분은 다음과 같습니다.

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

첫 번째 줄은 자기 자신을 대입하는지 확인합니다. 자기 자신을 대입하는 현상은 좌변과 우변이 서로 같을 때 성립하는데, 두 객체가 서로 같은지 알아내는 방법 중 하나는 서로 똑같은 메모리 공간에 있는지 확인하는 것입니다. 좀 더 구체적으로 표현하면 두 객체에 대한 포인터가 똑같은지 알아보면 됩니다. 앞서 this라는 포인터로 객체에서 호출할 수 있는 모든 메소드에 접근할 수 있다고 설명했습니다. 그래서 this를 좌변 객체로 지정했습니다. 마찬가지로 &rhs는 우변 객체를 가리키는 포인터입니다. 두 포인터의 값이 같으면 자기 자신을 대입한다고 볼 수 있습니다. 그런데 리턴 타입이 SpreadsheetCell& 이기 때문에 값을 정확히 리턴해야 합니다. 대입 연산자는 항상 *this를 리턴합니다. 자기 자신을 대입할 때도 당연히 이 값을 리턴합니다.

this는 메소드가 속한 객체를 가리키는 포인터입니다. 따라서 포인터가 가리키는 객체는 *this로 표현합니다.

대입 연산자 구현의 나머지 부분은 자기 대입이 아닌 경우에 대한 코드입니다. 이때는 모든 멤버에 대입 연산을 수행해야 합니다.

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

따라서, 자기 대입이 아닌 경우에는 값을 복사하고 마지막에 *this를 리턴합니다.

현재 SpreadsheetCell의 대입 연산자는 단순히 예를 보여주기 위해 구현한 것이며, 이 연산자는 생략해도 됩니다. 여기서 모든 데이터 멤버에 대입하는 작업만 하기 때문에 컴파일러가 생성해주는 것만으로도 충분합니다.

 

4.3 명시적 디폴트/삭제 대입 연산자

컴파일러가 자동으로 생성한 대입 연산자를 다음과 같이 명시적으로 디폴트로 만들거나 삭제할 수 있습니다.

SpreadsheetCell& operator=(const SpreadsheetCell& rhs) = default;

or

SpreadsheetCell& operator=(const SpreadsheetCell& rhs) = delete;

 

5. 컴파일러가 생성하는 복사 생성자와 복사 대입 연산자

C++11부터 클래스에 사용자가 선언한 복사 대입 연산자나 소멸자가 있으면 복사 생성자를 생성해주는 기능을 더 이상 지원하지 않습니다. 이 기능을 계속 사용하고 싶다면 다음과 같이 명시적으로 디폴트를 지정해주어야 합니다.

MyClass(const MyClass& src) = default;

 

또한, C++11부터 클래스에 사용자가 선언한 복사 생성자나 소멸자가 있으면 복사 대입 연산자를 생성해주는 기능도 더 이상 지원하지 않습니다. 따라서 이 기능을 계속 사용하려면 명시적으로 디폴트로 지정해주어야 합니다.

MyClass& operator=(const MyClass& rhs) = default;

 

6. 복사와 대입 구분하기

때로는 객체를 복사 생성자로 초기화할지 아니면 대입 연산자로 대입할지 구분하기 힘들 때가 있습니다. 기본적으로 선언처럼 생겼다면 복사 생성자를 사용하고, 대입문처럼 생겼다면 대입 연산자로 처리합니다.

예를 들어, 다음의 코드를 살펴보겠습니다.

SpreadsheetCell myCell{ 5 };
SpreadsheetCell anotherCell{ myCell };

여기서 anotherCell은 복사 생성자를 통해 생성됩니다.

SpreadsheetCell aThirdCell = myCell;

aThirdCell도 복사 생성자를 이용하여 만드는데, 이 문장이 선언문이기 때문입니다. 위 문장에서 operator=가 호출되지 않으며, 이는 SpreadsheetCell aThirdCell{myCell};의 또 다른 표현일 뿐입니다.

 

하지만 다음과 같이 작성하면,

anotherCell = myCell; // calls operator= for anotherCell

anotherCell이 이미 생성되어 있기 때문에 컴파일러는 operator=를 호출합니다.

 

6.1 Objects as Return Values

함수나 메소드로부터 객체를 리턴할 때, 정확히 복사가 일어나는지 대입이 일어나는지 판단하기 힘들 때가 있습니다. 예를 들어 다음과 같이 구현된 SpreadsheetCell::getString() 코드를 살펴보겠습니다.

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

그리고 이 메소드를 다음과 같이 호출하는 경우를 보겠습니다.

SpreadsheetCell myCell2{ 5 };
std::string s1;
s1 = myCell2.getString();

getString()이 string을 리턴할 때 컴파일러는 string의 복사 생성자를 호출해서 이름없는 임시 string 객체를 생성합니다. 이 객체는 s1에 대입하면 s1의 대입 연산자가 호출되는데, 이 연산자의 매개변수로 방금 만든 임시 string 객체를 전달합니다. 그런 다음 임시로 생성한 string 객체를 삭제합니다. 따라서 이 한 줄의 코드 안에서 복사 생성자와 대입 연산자가 서로 다른 두 객체에 대해 호출됩니다. 하지만 컴파일러마다 얼마든지 다르게 처리할 수 있으며, 값을 리턴할 때 복사 생성자의 오버헤드가 크다면 리턴값 최적화(Return Value Optimization, RVO) 또는 복사 생략(Copy Elision)을 적용해서 최적화하기도 합니다.

 

좀 더 복잡한 예를 살펴보겠습니다.

SpreadsheetCell myCell3{ 5 };
std::string s2 = myCell3.getString();

여기서도 getString()은 리턴할 때 이름없는 임시 string 객체를 생성합니다. 하지만 이번에는 s2에서 대입 연산자가 아닌 복사 생성자가 호출됩니다.

 

move semantics(이동 의미론)에서 컴파일러는 getString()으로부터 string을 리턴할 때 복사 생성자 대신 이동 생성자(move constructor)를 사용할 수 있습니다. 이렇게 하는 편이 더 효율적인데, 이에 관련된 내용은 다음 포스팅에서 자세하게 다루겠습니다.

 

어떤 순서로 실행되는지 또는 어느 생성자나 연산자가 호출되는지 잘 모를 때는 코드에 디버그용 메세지를 출력하는 문장을 임시로 추가해서 디버거로 한 단계씩 실행해보면 쉽게 확인할 수 있습니다.

 

6.2 Copy Constructors and Object Members

생성자에서 대입 연산자를 호출할 때와 복제 생성자를 호출할 때의 차이점도 잘 알아둘 필요가 있습니다. 어떤 객체가 다른 객체를 담고 있다면 컴파일러에서 만들어준 복사 생성자는 객체에 담긴 객체의 복사 생성자를 재귀적으로 호출합니다. 복사 생성자를 직접 정의했다면 위에서 본 생성자 이니셜라이저(ctor-initializer)를 이용하여 이러한 메커니즘을 구현합니다. 이때 생성자 이니셜라이저에서 데이터 멤버를 생략하면 생성자 본문에 작성된 코드를 실행하기 전에 컴파일러가 그 멤버의 대한 디폴트 생성자를 호출해서 초기화 작업을 처리해줍니다. 따라서 생성자의 본문을 실행할 시점에는 데이터 멤버가 모두 초기화된 상태입니다.

 

예를 들어 다음과 같이 복사 생성자를 작성한 경우를 살펴보겠습니다.

SpreadsheetCell::SpreadsheetCell(const SpreadsheetCell& src)
{
    m_value = src.m_value;
}

그런데 복사 생성자 본문 안에서 데이터 멤버에 값을 대입하면 복사 생성자가 아닌 대입 생성자가 적용됩니다. 앞서 설명했듯이 데이터 멤버가 이미 초기화된 상태이기 때문입니다.

 

복사 생성자를 다음과 같이 작성하면 m_value는 복사 생성자를 사용해서 초기화됩니다.

SpreadsheetCell::SpreadsheetCell(const SpreadsheetCell& src)
    : m_value{ src.m_value }
{
}

 


지금까지 C++의 클래스에 대한 기본 내용에 대해 살펴봤습니다. 다음 포스팅에서는 조금 심화 내용에 대해 알아보도록 하겠습니다.

 

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(const SpreadsheetCell& src);

    ~SpreadsheetCell();

    SpreadsheetCell& operator=(const SpreadsheetCell& rhs);

    void setValue(double value);
    double getValue() const;

    void setString(std::string_view value);
    std::string getString() const;

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

    double m_value{ 0 };
};

 

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(const SpreadsheetCell& src)
    : m_value{ src.m_value }
{
}

SpreadsheetCell::~SpreadsheetCell()
{
    std::cout << "Destructor called." << std::endl;
}

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

    m_value = rhs.m_value;
    return *this;
}

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

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

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

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

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

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

댓글