전문가를 위한 C++/ 클래스와 객체 숙달하기

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

클래스 작성 방법

클래스 정의

class SpreadsheetCell
{
public:
void setValue(double inValue);
double getValue() const;
private:
double mValue;
}

(클래스 관련 설명 생략)

객체를 변경하지 않는 멤버 함수는 항상 const로 선언하는 것이 바람직하다. (double getValue() const; 부분)

메서드 정의 방법

함수를 만들 때는 프로토타입뿐만 아니라 함수를 구현하는 정의 코드를 함께 작성하듯이 메서드도 프로토타입뿐만 아니라 메서드를 구현하는 정의 코드도 반드시 작성해야 한다. 이때 메서드 정의 코드보다 클래스 정의 코드가 먼저 나와야 한다.

클래스 저으이는 주로 헤더 파일에 작성하고, 메서드 정의는 소스 파일에 작성한 뒤 소스 파일에서 #include 문으로 헤더 파일을 불러오는 방식으로 사용한다. SpreadsheetCell 클래스의 두 메서드를 다음과 같이 작성한다.

#include "SpreadsheetCell.h"

void SpreadsheetCell::setValue(double inValue)
{
mValue = inValue;
}

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

메서드 이름 앞에 클래스 이름과 콜론 두 개가 붙어 있는데, 여기서 ::를 스코프 지정 연산자라 부른다. 컴파일러가 이 코드를 보면 여기 나온 setValue() 메서드는 SpreadsheetCell 클래스에 속한다는 것을 알 수 있다. 메서드를 구현할 때는 접근 제한자를 생략해도 된다.

(이하 설명 생략)

객체 사용법

객체를 생성해서 사용하는 방법은 크게 두 가지가 있는데, 하나는 스택에 생성하는 것이고 다른 하나는 힙에 생성하는 것이다.

스택에 생성한 객체

SpreadsheetCell myCell, anotherCell;
myCell.setValue(6);
anotherCell.setString("3.2");

cout << "cell 1: " << myCell.getValue() << endl;
cout << "cell 2: " << anotherCell.getValue() << endl;

---
/* 실행 결과
cell 1: 6
cell 2: 3.2
*/

힙에 생성한 객체

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

SpreadsheetCell* myCellp = new SpreadsheetCell();
myCellp->setValue(3.7);

cout << "cell 1: " << myCellp->getValue() << " " << mycellp-getString() << endl;

delete myCellp;
myCellp = nullptr;

힙에 생성한 객체는 화살표 연산자(->)로 멤버에 접근한다. 화살표 연산자는 역참조 연ㅅ나자(*)와 멤버 접근 연산자(.)를 합친 것이다. 화살표 대신 두 연산자르 ㄹ조합해서 사용해도 되지만 코드가 복잡해 진다.

힙에 할당한 메모리를 항상 해제해야 하듯 힙에 할당한 객체 메모리도 반드시 delete로 해제해야 한다. 메모리 관련 문제가 발생하지 않게 하려면 다음과 같이 스마트 포인터를 사용한다.

auto myCellp = make_unique<SpreadsheetCell>();
// 다음과 같이 적어도 된다.
// unique_ptr<SpreadsheetCell> myCellp(new SpreadsheetCell());

myCellp->setValue(3.7);
cout << "cell 1: " << myCellp->getValue() << " " << mycellp-getString() << endl;

객체의 라이프 사이클

객체 생성

(생략)

생성자 사용법

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

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

SpreadsheetCell 객체를 동적으로 할당할 때 생성자를 호출하는 방법은 다음과 같다.

auto smartCellp = make_unique<SpreadsheetCell>(4);
// ... 셀을 다루는 코드를 작성한다.
// 스마트 포인터이므로 직접 삭제 하지 않아도 된다.

// 일반 포인터를 사용해도 되지만 권장하지 않는다.
SpreadsheetCell* myCellp = new SpreadsheetCell(5);
SpreadsheetCell* anotherCellp = nullptr;
anotherCellp = new SpreadsheetCell(4);
// 셀을 다루는 코드를 작성한다.

delete myCellp;
myCellp = nullptr;
delete anotherCellp;
anotherCellp = nullptr;

디폴트 생성자

스택 객체의 디폴트 생성자를 호출하는 방법은 다음과 같다.

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

스택 객체의 다른 생성자와 달리 디폴트 생성자는 함수 호출 형식을 따르지 않는다. 다음과 같이 기존 함수 호출 형식에 따라 호출하는 실수를 저지르기 쉽다.

SpreadsheetCell myCell();  // 잘못된 문장이지만 컴파일 오류가 발생하지 않는다.
myCell.setValue(6); // 여기서 컴파일 오류가 발생한다.
cout << "cell 1: " << myCell.getValue() << endl;

어이 없게도 컴파일 에러가 발생하는 지점은 디폴트 생성자를 호출하는 문장이 아닌 그 다음 문장이다. 이런 문제를 가장 짜증나는 파싱 문제(most vexing parse)라고 부른다.

힙 객체의 디폴트 생성자를 호출하는 방법은 다음과 같다.

SpreadsheetCell* myCellp = new SpreadsheetCell();
// 다음과 같이 작성해도 된다.
// SpreadsheetCell* myCellp = new SpreadsheetCell;
// 셀을 다루는 코드를 작성한다.

delete myCellp;
myCellp = nullptr;

C++ 03 이하 버전에서는 인수를 받는 생성자를 여러 개 정의할 때 할 일이 없더라도 빈 디폴트 생성자를 반드시 정의해야 했다. 이렇게 빈 껍데기 디폴트 생성자를 일일이 적는 수고를 덜기 위해 C++에서는 명시적 디폴트 생성자를 제공한다. 이를 이용하면 다음과 같이 클래스 구현 코드에 디폴트 생성자를 작성하지 않아도 된다.

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

C+=는 명시적으로 삭제된 생성자란 개념도 지원한다. 예컨대 정적 메서드로만 구성된 클래스를 정의하면 생성자를 작성할 필요가 없을 뿐 아니라 컴파일러가 디폴트 생성자를 만들면 안된다. 이럴 때는 다음과 같이 디폴트 생성자를 명시적으로 삭제해야 한다.

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

생성자 이니셜라이저

C++는 생성자에서 데이터 멤버를 초기화하기 위한 또 다른 방법인 생성자 이니셜라이저(ctor –C++에서는 생성자를 ctor라고 부르기도 한다– 이니셜라이저 또는 멤버 이니셜라이저 리스트)를 제공한다.

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

위 코드에는 생성자 이니셜라이저는 생성자 인수 리스트와 생성자 본문을 시작하는 첫 중괄호 사이에 나온다. 이 구문은 콜론으로 싲가하며 각 항목을 쉼표로 구분한다. 여기 나올 수 있는 항목으로는 함수 호출 형식 또는 유니폼 초기화 문법으로 표현한 데이터 멤버의 이니셜라이저, 베이스 클래스 생성자를 호출하는 구문, 위임된 생성자를 호출하는 구문 등이 있다.

생성자 이니셜라이저로 데이터 멤버를 초기화하는 방식은 생성자 안에서 데이터 멤버를 초기화 하는 것과 다르다. C++에서 객체를 생성하려면 생성자를 호출하기 전에 그 객체를 구성하는 모든 데이터 멤버부터 생성해야 한다. 이렇게 데이터 멤버를 생성하는 과정에서 각 멤버가 다른 객체로 구성됐다면 해당 생성자를 호출한다. 생성자 안에서 객체에 값을 할당하는 시점에는 객체가 이미 생성된 상태다. 여기서는 단지 값을 변경할 뿐이다.

생성자 이니셜라이저를 이용하면 데이터 멤버를 생성하는 과정에서 초깃값을 설정할 수 있는데, 이렇게 하는 것이 나중에 따로 값을 대입하는 것보다 훨씬 효율적이다.

클래스를 구성하는 데이터 멤버에 대해 디폴트 생성자가 정의돼 있다면 생성자 이니셜라이저에서 이 객체를 명싲거으로 초기화하지 않아도 된다. 예컨대 std::string 타입의 데이터 멤버가 있을 때 이 멤버의 디폴트 생성자에 의해 이 멤버의 값을 공백 스트링으로 초기화하기 때문에 이니셜라이저에 “”를 지정하면 같은 코드를 두 번 쓰는 셈이다.

반면 클래스에 있는 데이터 멤버에 대해 디폴트 생성자가 정의돼 있지 않다면 생성자 이니셜라이저를 사용해 그 객체를 적절히 초기화해야 한다. 예컨대 SpreadsheetCell 클래스를 다음과 같이 저으이할 수 있다.

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

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

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

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

SomeClass::SomeClass() { }

그런데 구현 코드를 이렇게 작성하면 컴파일 에러가 발생한다. SomeClass의 데이터 멤버인 mCell에 대해 디폴트 생성자가 없기 때문에 컴파일러는 mCell을 초기화할 방법을 알 수 없다. 따라서 mCell을 초기화 하려면 다음과 같이 생성자 이니셜라이저를 작성해야 한다.

SomeClass::SomeClass() : mCell(1.0) { }

생성자 안에서 초깃값을 대입하는 방식을 선호하는 프로그래머도 있지만 이렇게 하면 효율성은 좀 떨어진다. 하지만 어떤 데이터 타입은 반드시 생성자 이니셜라이저나 클래스 내부 생성자 구문으로 초기화해야 한다. 다음 표에 이러한 타입을 정리했다.

데이터 타입 설명
Const 데이터 멤버 const 변수가 생성된 후에는 정상적인 방식으로 값을 대입할 수 없다. 반드시 생성 시점에 값을 지정해야 한다.
레퍼런스 데이터 멤버 가리키는 대상 없이는 레퍼런스가 존재할 수 없다.
디폴트 생성자가 정의되지 않은 객체 데이터 멤버 C++에서는 객체 멤버를 디폴트 생성자로 초기화한다. 디폴트 생성자가 없으면 이 객체를 초기화할 수 없다.
디폴트 생성자가 없는 베이스 클래스 10장에서 자세히 설명한다.

생성자 이니셜라이저를 사용할 때 한 가지 주의할 점이 있다. 여기서 나열한 데이터 멤버는 이니셜라이저에서 나열한 순서가 아닌 클래스 정의에 작성한 순서대로 초기화된다는 것이다. 예컨대 다음과 같이 정의된 Foo 클래스를 살펴보자. 여기에 있는 생성자는 단순히 double 값을 저장한 후 콘솔에 출력한다.

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

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

이때 다음과 같이 Foo 객체를 데이터 멤버로 가지는 MyClass 라는 클래스가 있다고 하자.

class MyClass
{
public:
MyClass(double value);
private:
double mValue;
Foo mFoo;
}

이 클래스의 생성자를 다음과 같이 구현했다.

MyClass::MyClass(double value) : mValue(value), mFoo(mValue)
{
cout << "MyClass::mValue = " << mValue << endl;
}

그러면 생성자 이니셜라이저는 먼저 mValue에 value로 지정한 값을 저장하고나서 Foo 생성자에 mValue를 인수로 전달해서 호출한다. MyClass의 인스턴스는 다음과 같이 생성한다.

My Class instance(1.2);

이렇게 작성한 프로그램의 실행 결과는 다음과 같다.

Foo::mValue = 1.2
MyClass::mValue = 1.2

여기까지는 문제가 없어 보인다. 이번에는 MyClass 정의에서 mValue와 Foo의 위치를 바꿔보자.

class MyClass
{
public:
MyClass(double value);
private:
Foo mFoo;
double mValue;
}

그러면 실행 결과는 다음과 같이 나온다. 구체적인 값은 시스템마다 다르다.

Foo::mValue = -9.25596e+61
MyClass::mValue = 1.2

의도와 전혀 다른 결과가 나오는데, 데이터 멤버는 생성자 이니셜라이저에 나온 순서가 아니라 클래스 정의에 나온 순서대로 초기화 되기 때문이다. 따라서 앞에서 나온 코드처럼 작성하면 Foo 생성자가 호출될 때 초기화되지 않은 mValue가 전달된다. 

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

복제 생성자

C++에서는 복제 생성자라는 특수한 생성자를 제공한다. 다른 객체와 똑같은 객체를 생성할 때 사용한다. 복제 생성자를 작성하지 않으면 컴파일러가 대신 만들어준다. 컴파일러가 생성한 복제 생성자는 데이터 멤버가 기본 타입이면 똑같이 복사하고, 객체 타입이면 그 객체의 복제 생성자를 호출한다.

SpreadsheetCell 클래스에 복제 생성자를 추가하면 다음과 같다.

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

복제 생성자는 원본 객체에 대한 const 레퍼런스를 인수로 받는다. 다른 생성자와 마찬가지로 리턴값은 없다. 생성자 안에서 원본 객체에 있는 데이터 멤버를 모두 복사한다. 물론 구체적인 동작은 얼마든지 바꿔도 되지만, 관례를 벗어나지 않는 것이 바람직하므로 새로 만들 객체의 데이터 멤버를 모두 기존 객체의 데이터 멤버로 초기화한다. 이렇게 작성한 SpreadsheetCell의 복제 생성자는 다음과 같다. 특히 생성자 이니셜라이저에 주목한다.

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

데이터 멤버가 m1, m2 …. mn과 같이 선언돼 있다면 컴파일러는 다음과 같이 생성자를 만들어 준다.

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

따라서 대부분은 복제 생성자를 직접 작성할 필요가 없다.

복제 생성자가 호출되는 경우

C++에서 함수에 인수를 전달할 때 기본적으로 값으로 전달된다(값 전달 방식이 적용된다) 다시 말해 함수나 메서드는 값이나 객체의 복사본을 받는다. 따라서 함수나 메서드에 객체를 전달하면 컴파일러는 그 객체의 복제 생성자를 호출하는 방식으로 초기화한다.

예컨대 다음과 같이 string 매개변수를 값으로 받는 printString() 함수가 있다고 하자.

void printString(string inString)
{
cout << inString << endl;
}

C++에서 제공하는 string 타입은 사실 기본 타입이 아니라 일종의 클래스다. 그래서 코드에서 printString(0에 string 매개변수를 전달해서 호출하면 string 매개변수인 inString은 이 클래스의 복제 생성자를 호출하는 방식으로 초기화된다. 이 복제 생성자의 인수가 바로 printString()에 전달한 string이다. 다음과 같이 printString()에서 매개변수를 name으로 지정해서 호출하면 inString 객체를 초기화할 때 string의 복제 생성자가 실행된다.

string name = "heading one";
printString(name); // name을 복제한다.

printString() 메서드가 실행을 마치면 inString이 삭제된다. 이 값은 실제로 name의 복사본이므로 name은 원래 값 그대로 남아 있다. 물론 복제 생성자에 매개변수를 const 레퍼런스로 전달하면 복제 오버헤드를 줄일 수 있다. (C#에서는 in 이라는 키워드로 비슷한 것을 할 수 있는데 7.2 이상 버전에서 사용 가능하다) 함수에서 객체를 값으로 리턴할 때도 복제 생성자가 호출된다.

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

복제 생성자를 명시적으로 호출할 수도 있다. 주로 다른 객체를 똑같이 복사하는 방식으로 객체를 만들 때 이 방식을 사용한다. 예컨대 SpreadsheetCell 객체의 복사본을 만들려면 다음과 같이 작성한다.

SpreadsheetCell myCell1(4);
SpreadsheetCell myCell2(myCell1); // myCell2는 myCell1과 같다.
레퍼런스로 객체 전달하기

함수나 메서드에 객체를 레퍼런스로 전달하면 복제 연산으로 인한 오버헤드를 줄일 수 있다. 객체를 레퍼런스로 전달하는 방식이 값으로 전달하는 것보다 대체로 효율적이다. 객체에 있는 내용 전체가 아닌 객체의 주소만 복사하기 때문이다.

객체를 레퍼런스로 전달할 때 그 값을 사용하는 함수나 메서드는 원본 객체를 변경할 수 있다. 단지 성능의 이유로 레퍼런스 전달 방식을 사용한다면 객체가 변경되지 않도록 객체 앞에 const를 붙여야 한다. 이를 const 레퍼런스로 객체를 전달한다고 표현한다.

참고로 SpreadsheetCell 클래스를 보면 std::string_view를 매개변수로 받는 메서드가 몇 개 있다. 2장에서 설명한 것처럼 string_view는 포인터와 길이만 갖고 있다. 그래서 복제 오버헤드가 적기 때문에 주로 값 전달 방식으로 작성한다. (이는 C#에서도 마찬가지라서 객체를 매개변수로 넘길 때 굳이 참조형태로 보낼 필요는 없는 것 같다. 힙의 주소를 가리키는 스택 변수가 복사되기 때문)

int, double 등과 같은 기본 타입은 반드시 값으로 전달해야 한다. 이런 타입을 const 레퍼런스로 전달한다고 해서 크게 나아지지 않는다.

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

명시적으로 디폴트로 만든 복제 생성자와 명시적으로 삭제된 복제 생성자

컴파일러가 생성한 복제 생성자를 명싲거으로 티폴트로 만들거나 삭제할 수 있다.

SpreadsheetCell(const SpreadsheetCell& src) = default;
SpreadsheetCell(const SpreadsheetCell& src) = delete;

복제 생성자를 삭제하면 객체를 더 복제할 수 없다. 객체를 값으로 전달하지 ㅇ낳게 할 때 이렇게 설정한다.

이니셜라이저 리스트 생성자

이니셜라이저 리스트 생성자란 std::initializer_list<T>를 첫 번째 매개변수로 받고 다른 매개변수는 없거나 디폴트 매개변수를 추가로 받는 생성자를 말한다. 사용법은 다음과 같다.

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

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

이니셜라이저 리스트 생성자 안에서 각 원소에 접근하는 부분을 범위 기반 for 문으로 구현할 수 있다. 이니셜라이저 리스트의 원소 수는 size() 메서드로 알아낼 수 있다.

EvenSequence 클래스에 정의한 이니셜라이저 리스트 생성자는 범위 기반 for 문을 이용해서 initializer_list<T>의 원소를 복사한다. 이렇게 하지 않고 vector의 assign() 메서드를 사용해도 된다.

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 invalid_argument& e)
{
cout << e.what() << endl;
}

p2 생성자에서 익셉션이 발생하는데, 이니셜라이저 리스트의 원소 수가 홀수이기 때문이다. 이 문장에서 다음과 같이 등호(대입 연산자)를 생략해도 된다.

EvenSequence p1{ 1.0, 2.0, 3.0, 4.0, 5.0, 6.0 };

표준 라이브러리에 나온 클래스는 모두 이니셜라이저 리스트 생성자를 지원한다. 예컨대 std::vector 컨테이너를 다음과 같이 이니셜라이저 리스트로 초기화할 수 있다.

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

이니셜라이저 리스트는 생성자 뿐만 아니라 일반 함수에서도 사용할 수 있다.

위임 생성자

위임 생성자를 사용하면 같은 클래스의 다른 생성자를 생성자 안에서 호출할 수 있다. 하지만 생성자 안에서 다른 생성자를 직접 호출할 수는 없다. 반드시 생성자 이니셜라이저에서 호출해야 하며, 멤버 이니셜라이저 리스트에 이것만 적어야 한다.

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

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

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

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

컴파일러가 생성하는 생성자에 대한 정리

컴파일러는 모든 클래스에 디폴트 생성자와 복제 생성자를 자동으로 만들어준다. 그런데 프로그래머가 직접 작성한 생성자에 따라 컴파일러가 만들어주는 생성자가 달라질 수 있다. 여기세 적용되는 규칙은 다음과 같다.

직접 정의한 생성자 컴파일러가 만들어주는 생성자 객체 생성 방법
없음

디폴트 생성자
복제 생성자

인수가 없는 경우:
SpreadsheetCell cell;
다른 객체를 복제하는 경우:
SpreadsheetCell myCell(cell);
디폴트 생성자만 정의한 경우 복제 생성자 인수가 없는 경우:
SpreadsheetCell cell;
다른 객체를 복제하는 경우:
SpreadsheetCell myCell(cell);
복제 생성자만 정의한 경우 업음 이론적으로는 다른 객체를 복제할 수 있지만 실제로는 어떠한 객체도 생성할 수 없다. 복제 방식을 사용하지 않는 생성자가 없기 때문이다.
한 개의 인수 또는 여러 개의 인수를 받는, 복제 생성자가 아닌 생성자만 정의한 경우 복제 생성자 인수가 있는 경우:
SpreadsheetCell cell(6);
다른 객체를 복제하는 경우:
SpreadsheetCell myCell(cell);
한 개의 인수 또는 여러 개의 인수를 받는, 복제 생성자가 아닌 생성자 또는 디폴트 생성자 하나만 정의한 경우 복제 생성자

인수가 없는 경우:
SpreadsheetCell cell;
인수가 있는 경우:
SpreadsheetCell cell(5);
다른 객체를 복제하는 경우:
SpreadsheetCell anotherCell(cell);

디폴트 생성자와 복제 생성자 사이에 일정한 패턴이 없다는 것을 알 수 있다. 복제 생성자를 명시적으로 정의하지 않는 한 컴파일러는 무조건 복제 생성자를 만든다. 반면 어떤 생성자라도 정의했다면 컴파일러는 디폴트 생성자를 만들지 않는다.

디폴트 생성자와 디폴트 복제 생성자는 명싲거으로 디폴트로 만들거나 삭제하는가에 따라 자동 생성 여부가 결정된다.

객체 소멸

객체가 제거되는 과정은 두 단계로 구성된다. 먼저 객체의 소멸자를 호출한 다음 할당받은 메모리를 반환한다. 객체를 정리하는 작업을 소멸자에서 구체적으로 지정할 수 있다. 동적 메모리를 해제하거나 파일 핸들을 닫는 작업을 여기서 처리할 수 있다.

소멸자를 선언하지 않으면 컴파일러가 하나 만들어주는데, 이를 이요애 멤버를 따라 재귀적으로 소멸자를 호출하면서 객체를 삭제할 수 있다. 

스택 객체는 현재 실행하던 함수, 메서드 또는 코드 블록이 끝날 때와 같이 스코프(유효 범위)를 벗어날 때 자동으로 삭제된다. 다시 말해 코드가 닫는 중괄호를 만날 때마다 중괄호로 묶인 코드의 스택에 생성된 객체가 모두 삭제된다. 예컨대 다음과 같다.

int main()
{
SpreadsheetCell myCell(5);

if (myCell.getValue() == 5)
{
SpreadsheetCell anotherCell(6);
} // 이 블록이 끝날 때 anotherCell이 삭제된다.

cout << "myCell: " myCell.getValue() << endl;
return 0;
} // 이 블록이 끝날 때 myCell이 삭제된다.

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

{
SpreadsheetCell myCell2(4);
SpreadsheetCell anotherCell2(5);
} // anotherCell2가 먼저 삭제된 뒤 myCell2가 삭제된다.

이러한 순서는 객체로 된 데이터 멤버에 대해서도 똑같이 적용된다.

스마트 포인터를 사용하지 않은 힙 객체는 자동으로 삭제되지 않는다. 객체 포인터에 대해 delete를 명시적으로 호출해서 그 객체의 소멸자를 호출하고 메모리를 해제해야 한다. 예컨대 다음과 같다.

int main()
{
SpreadsheetCell* cellPtr1 = new SpreadsheetCell(5);
SpreadsheetCell* cellPtr2 = new SpreadsheetCell(6);
cout << "cellPtr1: " << cellPtr1->getValue() << endl;
delete cellPtr1;
cellPtr1 = nullptr;
return 0;
} // cellPtr2에 대해 delete를 직접 호출하지 않았기 때문에 삭제됮 ㅣ않는다.

객체에 대입하기

C++ 코드에서 int 값을 다른 곳에 대입할 수 있듯이 객체의 값을 다른 객체에 대입할 수 있다. 

SpreadsheetCell myCell(5), anotherCell;
anotherCell = myCell;

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

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

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

보통의 경우와 마찬가지로 대입 연산자를 직접 정의하지 않아도 된다. C++에서 객체끼리 서로 대입할 수 있도록 자동으로 만들어주기 때문이다. 이렇게 만들어주는 디폴트 대입 연산자는 디폴트 복제 동작과 거의 같다. 즉 원본의 데이터 멤버를 대상 객체로 대입하는 작업을 재귀적으로 수행한다.

대입 연산자 선언 방법

SpreadsheetCell 클래스의 대입 연산자는 다음과 같다.

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

대입 연산자는 복제 생성자처럼 원본 객체에 대한 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));

대입 연산자 정의 방법

대입 연산자를 구현하는 방법은 복제 생성자와 비슷하지만 몇 가지 중요한 차이점이 있다. 첫째, 복제 생성자는 초기화할 때 단 한 번만 호출된다. 그 시점에는 타깃 객체가 유효한 값을 갖고 있지 않다. 대입 연산자는 객체에 이미 할당된 값을 덮어쓴다. 그래서 객체에서 메모리를 동적으로 할당하지 않는 한 이 차이점은 크게 드러나지 않는다.

둘째, C++는 객체에 자기 자신을 대입할 수 있다.

SpreadsheetCell cell(4);
cell = cell; // 자기 자신을 대입

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

SpreadsheetCell 클래스에서는 그럴 필요가 없다. 단 하나뿐인 데이터 멤버가 double이란 기본 타입으로 지정되었기 때문이다. 하지만 클래스에 동적으로 할당한 메모리나 다른 리소스가 있다면 자기 자신을 대입하는 작업을 처리하기 쉽지 않다. 이에 대해서는 9장에서 자세히 설명하고, 이런 문제를 해결하려면 연산자를 시작하는 부분에서 자기 자신을 대입하는지 확인해서 곧바로 리턴하게 만들면 된다.

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

// 자기 자신이 아니라면 모든 멤버에 대해 대입을 수행한다.
// 대입 연산 수행

// 최종적으로 리턴한다.
return *this;
}

두 객체가 서로 같은지 알아내는 방법 중 하나는 서로 똑같은 메모리 공간에 있는지 확인하는 것이다. 좀 더 구체적으로 표현하면 두 객체에 대한 포인터가 똑같은지 알아보면 된다. this는 메서드가 속한 객체를 가리키는 포인터이므로 포인터가 가리키는 객체는 *this로 표현한다. 컴파일러는 선언된 리턴값과 일치하는 객체에 대한 레퍼런스를 리턴한다.

자기 자신이 아닌 경우에는 모든 멤버에 대해 대입 연산을 수행해야 한 후 *this를 리턴한다.

명시적으로 디폴트로 만들거나 삭제한 대입 연산자

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

컴파일러가 만들어주는 복제 생성자와 복제 대입 연산자

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

MyClass(const MyClass& src) = default;

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

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

복제와 대입 구분하기

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

SpreadsheetCell myCell(5);
SpreadsheetCell anotherCell(myCell);

anotherCell은 복제 생성자를 이용하여 만든다.

SpreadsheetCell aThirdCell = myCell;

aThirdCell도 복제 생성자를 이용하여 만든다. 선언문이기 때문이다. 위 문장에서는 operator=이 호출되지 않는다. 이는 SpreadsheetCell aThirdCell(myCell);의 다른 표현에 불과하다.

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

anotherCell = myCell;

anotherCell이 이미 생성되었기 때문에 컴파일러는 operator=를 호출한다.

리턴값이 객체인 경우

함수나 메서드에서 객체를 리턴할 때 복제될지 대입될지 판단하기 힘들 때가 있다. 예컨대 다음 코드를 살펴보자.

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

이 메서드를 다음과 같이 호출하는 경우를 보자.

SpreadsheetCell myCell2(5);
string s1;
s1 = myCell2.getString();

getString()이 스트링을 리턴할 때 컴파일러는 string의 복제 생성자를 호출해서 이름 없는 임시 string 객체를 생성한다. 이 객체를 s1에 대입하면 s1의 대입 연산자가 호출되는데 이 연산자의 매개변수로 방금 만든 임시 string 객체를 전달한다. 그런 다음 임시로 생성한 string 객체를 삭제한다. 따라서 이 한 줄의 코드 안에서 복제 생성자와 대입 연산자가 서로 다른 두 객체에 대해 호출된다.

하지만 컴파일러마다 다르게 처리할 수 있으며, 값을 리턴할 때 복제 생성자의 오버헤드가 크다면 리턴값 최적화(Return Value Optimization, RVO) 또는 복제 생략(copy elision)을 적용해서 최적화 하기도 한다.

SpreadsheetCell myCell3(5);
string s2 = myCell3.getString();

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

이동 의미론에 따르면 getString()에서 스트링 값을 리턴할 때 컴파일러는 복제 생성자 대신 이동 생성자를 사용한다. 이렇게 하는 편이 더 효율적인데, 자세한 내용은 9장에서 설명한다.

복제 생성자와 객체 멤버

생성자에서 대입 연산자를 호출할 떄와 복제 생성자를 호출할 때의 차이점도 잘 알아둘 필요가 있다. 어떤 객체가 다른 객체를 담고 있다면 컴파일러에서 만들어준 복제 생성자는 객체에 담긴 객체의 복제 생성자를 재귀적으로 호출한다.

복제 생성자를 직접 정의했다면 앞서 본 생성자 이니셜라이저를 이용해서 이런 메커니즘을 직접 구현한다. 이때 생성자 이니셜라이저에서 데이터 멤버를 생략하면 생성자 본문에 작성된 코드를 실행하기 전에 컴파일러가 그 멤버에 대한 (디폴트 생성자를 호출해서) 초기화 작업을 처리해준다. 따라서 생성자의 본문을 실행할 시점에는 데이터 멤버가 모두 초기화된 상태다.

예컨대 다음과 같이 복제 생성자를 작성한 경우를 보자.

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

그런데 복제 생성자 본문 안에서 데이터 멤버에 값을 대입하면 복제 생성자가 아닌 대입 연산자가 적용된다. 데이터 멤버가 이미 초기화된 상태이기 때문이다.

복제 생성자를 다음과 같이 작성하면 mValue는 복제 생성자를 사용해서 초기화된다.

SpreadsheetCell::SpreadsheetCell(const SpreadsheetCell& src) : mValue(src.mValue)
{
}
[ssba]

The author

Player가 아니라 Creator가 되려는 자/ suyeongpark@abyne.com

댓글 남기기

이 사이트는 스팸을 줄이는 아키스밋을 사용합니다. 댓글이 어떻게 처리되는지 알아보십시오.