7. 클래스와 객체에 능숙해지기
객체지향 언어인 C++는 객체를 정의하고 사용하기 위한 기능으로 클래스를 제공한다.
클래스나 객체를 사용하지 않아도 C++언어로 프로그램을 작성할 수 있다. 하지만, 클래스와 객체를 사용하면 C++언어가 제공하는 기초적이면서도 유용한 기능을 활용할 수 있다. 클래스 없이 C++를 이용하는 것은 외국 여행을 다니면서 한식당만 찾는것과 같다.
7.1 스레드시트 실습
실제 실행 가능한 간단한 스프레드 시트 애플리케이션을 만들어보자.
스프레드시트는 2차원 격자로 된 셀이 있고, 각 셀은 숫자나 문자를 가진다. 마이크로소프트 엑셀 같은 상용 스프레드 시트 프로그램은 각 셀의 값을 대상으로 합계 등 수학 연산을 할 수 있다.
우리가 다룰 스프래드시트 예제는 스프레드시트 고유의 기능을 구현하는 것 보다는 익숙한 형태의 프로그램을 통해 클래스와 객체에 관련된 이슈를 이해하는 데 목적이 있다.
스프레드시트 애플리케이션은 두 개의 기본 클래스 SpreadSheet와 SpreadSheetCell을 이용한다. 각 spreadSheet객체는 SpreadShetCell 객체를 가진다, 추가로, SpreadSheetApllication 클래스를 두어 SpreadSheet를 관리한다.
7.2 클래스 만들기
클래스를 만들 때는 클래스의 객체에 적용할 행동(즉, 메서드)과 각 객체가 가질 프로퍼티(즉, 데이터 멤버)를 지정해야 한다.
클래스를 만드는 과정은 클래스 자체를 정의하는 부분과 클래스의 메서드를 정의하는 부분으로 구성되어 있다.
7.2.1 클래스의 정의
간단한 SPreadSheetCell클래스의 구현이다. 각 셀은 숫자 하나만 담을 수 있다고 가정한다.
class SpreadSheetCell
{
public:
void setValue(double inValue);
double getValue() const;
private:
double mValue;
};
모든 클래스의 정의는 class키워드로 시작하고, 클래스 이름이 뒤따른다. C++에서 클래스 정의는 작업 구문 이기 때문에 세미콜론(;)으로 끝나야 한다. 클래스 정의에서 세미콜론을 빠뜨리면 컴파일러는 전혀 관계없어 보이는 에러 메시지를 잔뜩 출력하기 때문에 주의해야 한다.
클래스 정의는 보통 해당 클래스 이름으로 된 파일에 저장된다. 예를 들어 SpreadSheetCell클래스 정의는 SpreadSheetCell.h파일에 저장된다. 이러한 원칙은 강제사항이 아님으로 어떻게 저장할 지는 자유이다.
7.2.1.1클래스 맴버
클래스는 복수의 멤버를 가질 수 있다.
이 멤버에는 함수가 있을 수도 있고(메서드, 생성자, 소멸자 같은 것이 된다) 변수가 있을 수도 있다. 변수의 경우 데이터 멤버라고 부른다.
함수 선언처럼 보이는 다음 두 라인은 클래스가 지원하는 메서드에 대한 선언이다.
void setValue(double inValue);
double getValue() const;
객체의 프로퍼티에 변경이 없는 메서드는 const로 선언하는 것이 좋다.
다음 변수선언처럼 보이는 코드는 클래스의 데이터 멤버 선언이다.
double mValue;
메서드와 데이터 멤버 변수의 정의는 클래스에서 하지만 실제 그 메서드가 수행되고 데이터 멤버 변수의 값이 저장되는 곳은 클래스가 아닌 클래스가 인스턴스화한 객체이다. (정적멤버는 예외적으로 제외한다.)
이 떄문에 각 객체는 객체마다 서로 다른 mValue변숫값을 가진다. 데이터 멤버의 값과 달리 메서드의 구현 내용은 모든 객체가 공유한다. 그리고 멤버간, 메서드 간은 물론이고 멤버와 메서드 간에도 이름이 같아서는 안된다.
7.2.1.2 접근 제어
모든 메서드와 멤버 변수는 public, protected, private 세 가지 접근 식별자(보통 줄여서 접근자 라고 한다.) 중 하나에 속하게 된다. 접근자가 지정된 이후에 선언되는 모든 메서드와 멤버 변수는 새로운 접근자가 나타날 때 까지 해당 접근 속성을 따르게 된다. SpreadSheetCell 클래스에서는 setValue()와 getValue()메서드가 public 접근자에 속하고 mValue는 protected접근자에 속한다.
클래스의 디폴트 접근자는 private이다 즉, 명시적으로 접근자가 선언되지 않은 상태에서 선언되는 모든 메서드와 멤버 변수는 private 접근 속성을 가지게 된다. 예를 들어 public 접근자를 setValue() 메서드 선언 이후로 옮기면 setValue()의 접근 속성이 Private로 바뀐다.
#pragma once
class SpreadSheetCell
{
void setValue(double inValue); // private 접근 속성을 가진다
public:
void setValue(double inValue);
double getValue() const;
private:
double mValue;
};
C++에서는 struct도 class처럼 메서드를 가질 수 있다. 사실 struct가 class와 유일하게 다른 점은, class는 디폴트 접근자가 private지만 struct는 디폴트 접근자가 public이라는 것이다. 예를 들어 SpreadsheetCell 클래스를 struct로 정의하면 다음과 같다.
struct SpreadSheetCell
{
void setValue(double inValue);
double getValue() const;
private:
double mValue;
};
이러한 사실을 일목요연하게 정리하면 다음과 같다.
접근자 | 의미 | 활용 예 |
public | 객체의 public 메서드 또는 public 데이터 멤버는 다른 코드에서 제한없이 호출하거나 읽고 쓸 수 있다. | 사용자 측에서 이용하도록 공개하고 싶은 행동이나 private또는 protected데이터 멤버에 대한 외부 접근 메서드에 적용한다. |
protected | 같은 클래스 안에서는 protected 메서드나 데이터 멤버를 호출하거나 읽고 쓸 수 있다. 또한 그 클래스의 파생 클래스에서도 protected메서드나 데이터 멤버에 자유롭게 접근할 수 있다. | 사용자에게는 노출하지 않고 내부적으로 이용하기 위한 편의 메서드와 (가능하면 모든) 데이터 멤버에 적용한다. |
private | private 메서드나 데이터 멤버는 해당 클래스 안에서만 접근할 수 있고 그 클래스의 서브 클래스에서 조차 접근이 불허된다. | 기본적으로 모든것이 private 아래에 정의되어야 한다. 파생 클래스에서만 접근해야 하는 게터와 세터가 있다면 protected 아레에 두고, 외부 사용자가 접근해야 하는 게터와 세터가 있다면 public 아레에 둔다. |
7.2.1.3 선언 순서
메서드, 데이터 멤버, 접근자는 어떤 순서로든 선언할 수 있다. 메서드든 멤버든 어느 것이 먼저 와도 관계없고 private나 public 앞에 있든 뒤에 있든 C++는 어떤 제한도 하지 않는다. 게다가 이미 사용한 접근자를 반복해서 사용할 수도 있다. 예를 들어 SpreadSheetCell 클래스를 다음과 같이 정의할 수 있다.
struct SpreadSheetCell
{
public:
void setValue(double inValue);
private:
double mValue;
public:
double getValue() const;
};
그러나, 가독성을 위해 가능한, 같은 항목들은 그룹으로 뭉쳐놓는것 이 좋다.
7.2.2 메서드 정의
앞서 정의한 SpreadSheetCell 클래스로도 객체를 만들 수 있다. 하지만, setValue()나 getValue()메서드를 호출하려 하면 링커에서 정의되지 않은 메서드라고 에러를 호출한다. 왜냐하면 클래스 정의에서는 메서드 프로토타입만 선언했고, 그 구현은 정의하지 않았기 때문이다. 함수를 만들 때 프로토타입을 선언하고 몸체를 따로 구현하듯이 메서드도 클래스 안에서 프로토타입을 선언하고 몸체는 따로 구현해야 한다. 이때 클래스 정의가 메서드 구현 앞에 있어야 한다.
보통 클래스 정의는 헤더 파일에 들어가고 메서드 정의는 그 헤더파일을 인클루드 하는 소스 파일에 들어간다. 다음은 SpreadSheetCell 클래스의 두 메서드를 정의한 예 이다.
#include "SpreadSheetCell.h"
void SpreadSheetCell::setValue(double inValue)
{
mValue = inValue;
}
double SpreadSheetCell::getValue() const
{
return mValue;
}
클래스 이름 다음에 콜론 두개(::)를 입력하고 메서드 이름을 입력한다.
void SpreadSheetCell::setValue(double inValue)
위와같은 ::을 스코프 지정 연산자 라고 한다. 여기서는 스코프 지정 연산자가 컴파일러에 setValue() 메서드가 SpreadSheetCell클래스에 속한 것 임을 알려준다. 그리고 메서드를 정의할 때는 어떤 접근자가 쓰였는지 다시 알려줄 필요가 없다.
7.2.2.1 데이터 멤버 접근
setValue()나 getValue()같은 클래스의 메서드는 그 클래스가 인스턴스화한 특정 객체를 대상으로 실행된다. (단, static 메서드는 예외이다) 메서드 몸체 안에는 객체가 가진 클래스의 모든 멤버에 접근할 수 있다. 예를 들어 setValue() 메서드의 몸체 안에서는 mValue 멤버에 접근하여 그 값을 바꿀 수 있다.
mValue = inValue;
만약, setValue() 메서드가 서로 다른 두 객체에서 호출된다면 같은 라인의 코드지만 각 객체에 속한 서로 다른 mValue를 바꾸게 된다.
7.2.2.2 다른 메서드 호출
메서드 안에서 같은 클래스의 다른 메서드를 호출할 수 있다.
예를 들어 SpreadSheetCell 클래스를 확장하는 경우를 보자. 실제 스프레드 시트 애플리케이션이라면 숫자와 더불어 텍스트도 셀에 저장할 수 있다. 텍스트가 저장된 셀을 숫자로 이용하려면 스프레드시트에서 텍스트를 숫자로 변환한다. 만약 텍스트가 숫자를 표현하고 있지 않다면 셀의 값은 무시된다.
이 예제 프로그램에서는 숫자를 표현하지 않는 문자열을 0으로 취급한다.
class SpreadSheetCell
{
public:
void setValue(double inValue);
double getValue() const;
void setString(const std::string& inString);
const std::string& getString() const;
private:
std::string doubleToString(double inValue) const;
double stringToDouble(const std::string& inString) const;
double mValue;
std::string mString;
};
위와같이 수정된 클래스는 텍스트와 숫자를 모두 담을 수 있다. 만약 클라이언트에서 데이터를 string으로 저장하면 그 문자열을 double로 변환할 수 있고, double을 다시 string으로 변환할 수 있다. 만약 텍스트가 숫자를 표현하고 있지 않으면 double 값은 0이 된다.
이 클래스에서 같은 값에 대해 문자열과 숫자 표현이 중복해서 존재하는 것은 단지 설명을 위한 목적으로 실제 코드에서는 중복 데이터가 없는 것이 바람직하다. 이 클래스 정의는 문자열 표현 방식으로 셀의 값을 읽고 쓰기 위한 2개의 메서드와 double와 string간 상호 변환을 위한 private 접근 속성의 편의 메서드 2개를 새롭게 도입하여 정의하고 있다.
편의 메서드들은 string스트림을 이용하고 있다. 다음은 모든 메서드의 구현 정의부이다.
#include "SpreadSheetCell.h"
#include <iostream>
#include <sstream>
using namespace std;
void SpreadSheetCell::setValue(double inValue)
{
mValue = inValue;
mString = doubleToString(mValue);
}
double SpreadSheetCell::getValue() const
{
return mValue;
}
void SpreadSheetCell::setString(const std::string& inString)
{
mString = inString;
mValue = stringToDouble(mString);
}
const std::string& SpreadSheetCell::getString() const
{
return mString;
}
std::string SpreadSheetCell::doubleToString(double inValue) const
{
ostringstream ostr;
ostr << inValue;
return ostr.str();
}
double SpreadSheetCell::stringToDouble(const std::string& inString) const
{
double temp;
istringstream istr(inString);
istr >> temp;
if (istr.fail() || !istr.eof()) {
return 0;
}
return temp;
}
7.2.2.3 this포인터
모든 일반 메서드는 자신이 호출된 객체에 접근할 수 있는 '숨겨져 있는' 파라미터를 넘겨받는다. 이 파라미터를 this포인터라고 하며 이 포인터를 통해 데이터 맴버에 접근하거나 메서드를 호출할 수 있는 것은 물론 다른 메서드의 파라미터로 전달할 수도 있다.
this 포인터는 변수 스코프 문제를 해소하는 데도 이용할 수 있다. 예를 들어 SpreadSheetCell 클래스의 데이터 멤버 이름으로 mValue 대신 value를 사용하고, setValue()메서드의 파라미터 일므도 inValue대신 value를 사용했다고 하자. 이 경우 다음처럼 코드가 모호하게 나타난다.
void SpreadSheetCell::setValue(double Value)
{
Value = Value;
mString = doubleToString(mValue);
}
value가 클래스 멤버인지 메서드의 파라미터인지 알아보기가 어렵다.
이러한 코드는 동작은 가능하지만, 의도한 결과가 나타나지는 않는다.
이떄 다음과 같이 this포인터를 사용하면 명확하게 구분할 수 있다.
void SpreadSheetCell::setValue(double Value)
{
this->Value = Value;
mString = doubleToString(mValue);
}
이미 전에 설명한, 명명규칙 을 잘 따른다면 이런 식의 이름충돌은 발생하지 않는다.
this포인터를 다른 함수나 다른 클래스의 메서드를 호출할 떄 파라미터로 활용할 수도 있다. 예를 들어 다음과 같은 독립 함수가 있다고 가정하자.
void printCell(const SpreadsheetCell& inVell)
{
cout << inCell.getString() << endl;
}
만약 printCell() 함수를 setValue()메서드 안에서 사용하고 싶다면 this포인터를 SpreadSheetCell의 참조에 대응하는 파라미터로 넘겨줄 수 있다.
void SpreadSheetCell::setValue(double inValue)
{
mValue = inValue;
mString = doubleToString(mValue);
printCell(*this);
}
사실, 이러한 내용출력의 경우 별도의 함수를 만들기 보다는 << 연산자를 오버라이딩하면 더편리하게 작업할 수 있다.
7.2.3 객체의 이용
SpreadSheetCell은 2개의 멤버 변수, 4개의 public 메서드, 2개의 private 메서드로 정의되었다. 그런데 이것은 말 그대로 클래스 정의일 뿐이지 객체가 만들어진 것은 아니다. 단지 형상과 행동을 규정하였을 뿐이다. 클래스는 건축에서의 청사징과 같다. 청사진은 건물이 어떤 모양을 가져야하는지 규정하지만, 실제 건물은 아니다. 건물은 청사진에 맞춰서 지어야 존재하게 된다.
건물을 짓는 것과 비슷하게, C++에서는 SpreadSheetCell 타입의 변수를 선언함으로써 SpreadsheetCell '객체'를 생성하게 된다. 그리고 청사진으로부터 같은 모양의 여러 건물을 지을 수 있듯이 SpreadsheetCell 클래스 정의로부터 여러 개의 SpreadsheetCell 객체를 만들 수 있다. 객체 생성 방법은 스택을 사용하느냐, 힙을 사용하느냐에 따라 두가지로 나뉜다.
두 경우 모두 배열로 저장할 수 있다.
7.2.3.1 스택에 생성되는 객체
다음은 SpreadsheetCell 객체를 스택에 생성하는 예다.
SpreadSheetCell myCell, anothewrCell;
myCell.setValue(6);
anotherCell.setString("3.2");
cout << "cell 1: " << myCell.getValue() << endl;
cout << "cell 2: " << anotherCell.getValue() << endl;
마치 일반 변수를 선언하듯 객체를 생성할 수 있다. 단시 변수의 타입이 클래스일 뿐이다. myCell.setValue(6)라인에 있는 모든','은 도트 연산자다. 객체를 대상으로 메서드를 호출할 때는 도트 연산자를 이용한다. 만약 객체에 public 데이터 멤버가 있다면 도트 연산자로 멤버 변수에 접근할 수 있다. 하지만 데이터 멤버를 public으로 선언하는 것은 바람직하지 않다는 것을 기억하자.
7.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;
스마트 포인터를 이용하면 따로 메모리에서 객체를 해제할 필요가 없다. 참조하는 곳이 없으면 자동으로 메모리에서 해제된다.
7.3 객체의 라이프 사이클
객체의 라이프 사이클은 생성, 소멸, 대입(복제) 세 단계로 이루어진다. 각 단계가 어떤 식으로 동작하는지 메커니즘을 이해하고, 행동 방식을 필요에 따라 커스터마즈 하는 방법을 알고 있어야 한다.
7.3.1 객체의 생성
객체는 그것을 스택 변수로 선언하거나 new 또는 new[] 연산자를 호출하거나 스마트 포인터를 이용하여 생성된다. 객체가 생성될 때는 내부에 포함된 객체들도 함꼐 생성된다.
#include <string>
class MyClass
{
private:
std::string mName;
};
int main()
{
myClass obj;
return 0;
}
MyClass에 내장된 string객체는 MyClass객체가 main()함수의 스택 변수로 생성될 때 함꼐 생성되고 main() 함수가 리턴하면서 myClass객체가 소멸할 때 함께 소멸한다.
변수를 선언할 때는 초깃값을 함께 지정하는 것이 편리한 경우가 많다. 예를 들어 int 변수를 선언할때 0으로 초기화한다.
int x = 0;
이와 마찬가지로 객체를 생성할 떄도 초깃값을 줄 수 있다. 객체에 초깃값을 주기 위해서는 생성자 라는 특별한 메서드를 이용한다. 객체가 생성될 떄는 파라미터 유무와 종류에 따라 적절한 생성자가 자동으로 실행된다.
(C++에서는 생성자를 줄여서 ctor 이라고 부르기도 한다.)
7.3.1.1 생성자의 작성
문법적으로 볼 때 생성자는 그것이 속한 클래스와 이름이 같은 메서드다. 단, 생성자는 리턴값을 가지지 않으며, 파라미터가 있을 수도 있고 없을 수도 있다. 파라미터가 없는 생성자를 디폴트 생성자(defalut constructor)라고 부른다. 디폴트 생성자가 정의되어 있지 않다면, 컴파일 에러가 발생할 경우가 많다.
SpeadSheetCell 클래스에 생성자를 추가해보자.
class SpreadSheetCell
{
public:
SpreadSheetCell(double initalValue);
//////////////////////////////////////////////
클래스에 선언된 메서드에 대해 반드시 구현부 정의를 제공해야 하는 것처럼, 생성자도 반드시 구현부를 정의해야 한다.
SpreadSheetCell::SpreadSheetCell(double initalValue)
{
setValue(initalValue);
}
위를보면 알 수 있듯, SpreadSheetCell의 생성자는 SpreadSheetCell 클래스의 메서드이다. 이 떄문에 C++에서는 스코프 지정 연산자를 사용해서 SpreadSheetCell::을 메서드 선언 앞에 붙이도록 하고 있다.
생성자 메서드의 이름은 클래스 이름과 같아서 보기에 조금 어색할 수도 있다. 이 생성자는 setValue()를 호출하여셀의 숫자값과 텍스트값이 모두 준비되도록 하고 있다.
7.3.1.2 생성자의 이용
생성자는 객체를 생성하고 그 값을 초기화할 수 있다. 생성자는 스택 객체와 힙 할당 객체 모두 적용할 수 있다.
스택 객체와 생성자
스택에 SpreadSheetCell 객체를 할당할 떄는 다음과 같이 생성자를 호출한다.
SpreadSheetCell myCell(5), anotherCell(4);
cout << "cell 1: " << myCell.getValue() << endl;
cout << "cell 2: " << anotherCell.getValue() << endl;
즉, 생성자를 메서드로 직접 호출하지 않으며, 생성자를 나중에 호출할 수도 없다는 의미 이다.
스택객체에 대해 생성자를 올바로 호출하는 방법은 변수선언에 직접 생성자 파라미터를 넣는 방법뿐이다.
힙 객체와 생성자
힙에 동적으로 SpreadSheetCell 객체를 생성할 때는 다음과 같은 생성자를 이용한다.
#include <iostream>
#include <memory>
#include <string>
#include "SpreadSheetCell.h"
using namespace std;
int main()
{
auto smartCellp = make_unique<SpreadSheetCell>(4);
// cell 객체를 이용한 연산, 스마트포인터를 사용했기 때문에 delete를 해주지 않아도 된다.
// 아래처럼 일반 포인터를 사용하면 직접 delete해야한다./
SpreadSheetCell* myCellp = new SpreadSheetCell(5);
SpreadSheetCell* anotherCellp = nullptr;
anotherCellp = new SpreadSheetCell(4);
// cell 객체를 이용한 연산
delete myCellp; myCellp = nullptr;
delete anotherCellp; anotherCellp = nullptr;
return 0;
}
스택 객체와는 다르게, SpreadSheetCell 객체의 포인터 변수를 선언할 떄는 바로 생성자를 호출하지 않아도 된다. 대신 new 연산자를 이용해서 실제로 객체를 생성하는 시점에 파라미터를 클래스 명에 전달하면서 생성자를 호출한다.
스택이든 힙이든 포인터 변수를 선언할 때는 즉시 객체로 초기화하지 않는다면 nullptr로 초기화해두는 것이 바람직하다.
만약 nullptr로 초기화되어 있지 않으면 포인터가 랜덤한 주소를 가리키게 됨으로 나중에 잘못된 메모리 접근이 발생했을 때 문제 양상이 어떻게 나타날지 알 수 없어 디버깅이 어려워진다.
그리고 new를 이용해서 생성한 객체는 사용 후 delete 를 이용해서 메모리를 해제해주어야 한다. 스마트 포인터를 사용하면 이러한 번거로움을 피할 수 있다.
7.3.1.3 복수의 생성자 제공
클래스 생성자는 여러 개 존재할 수 있다. 이때 생성자 이름은 모두 클래스 이름과 같지만 파라미터의 개수나 타입은 서로 달라야 한다. C++에서는 같은 이름의 함수가 하나 이상 있으면 함수 호출 시 어떤 함수가 이용될 지는 컴파일러가 호출에 사용된 파라미터 구성을 각 함수와 비교해서 결정하게 된다. 이러한 매커니즘을 오버로딩(Ovecrloading)이라고 부른다.
두 종류의 생성자가 제공되면 SpereadSheetCell 클래스의 이용이 더 편해진다. 하나는 double형태의 초기값을 받고 , 다른 하나는 string 타입의 초깃값을 받도록 한다. 다음은 생성자를 두개 갖게 구현한 SpreadSheetCell 클래스이다.
class SpreadSheetCell
{
public:
SpreadSheetCell(double initalValue);
SpreadSheetCell(const std::string& initalValue);
//나머지 생략
}
두 번째 생성자는 다음과 같이 구현한다.
SpreadSheetCell::SpreadSheetCell(const std::string& initalValue)
{
setString(initalValue);
}
복수 생성자를 사용하면 다음과 같이 나타낼 수 있다.
SpreadSheetCell aThirdCell("Test"); // string타입 파라미터 생성자 이용
SpreadSheetCell aFourthCell(4.4); // double 타입 파라미터의 생성자 이용
auto aThirdCellp = make_unique<SpreadSheetCell>("4.4"); // string 타입 생성자.
cout << "aThirdCell: " << aThirdCell.getValue() << endl;
cout << "aFourthCell: " << aFourthCell.getValue() << endl;
cout << "aThirdCellp: " << aThirdCellp->getValue() << endl;
생성자를 여러개 정의하다 보면 생성자 안에서 다른 생성자를 이용하고 싶을 떄가 있다. 예를 들어 다음과 같이 double타입 생성자에서 string 타입 생성자를 부르고 싶을 수도 있다.
SpreadSheetCell::SpreadSheetCell(const std::string& initalValue)
{
SpreadSheetCell(stringToDouble(initalValue));
}
일반 메서드에서 다른 메서드를 호출할 수 있으니 문제될 것이 없어보인다. 그런데 이러한 코드는 컴파일도 되고 실행도 되지만, 기대한 대로 동작하지는 않는다.
생성자 안에서호출한 SpreadShettCell()생성자는 생성자를 호출하는 객체에 동작하는 것이 아니라 로컬 변수처럼 임시 스택 객체를 만들게 된다.
생성자 위임 기능을 이용해서 생성자 안에서 같은 클래스의 다른 생성자를 호출할 수 있다.
7.3.1.4 디폴트 생성자
파라미터가 없는 생성자를 디폴트 생성자 라고 한다. (또는 인자가 없는 생성자) 디폴트 생성자를 이용하면 객체를 생성하는 측에서 생성자를 명시적으로 호출하지 않더라도, 데 이터 멤버를 초기화할 수 있다.
디폴트 생성자가 필요한 상황
객체의 배열을 생각해보자. 객체의 배열은 두 단계로 생성할 수 있다. 모든 객체에 대해 연속된 메모리를 확보한 다음.
각 객체의 디폴트 생성자를 호출하는 것이다. C++에서는 디폴트 생성자가 없는 클래스에 대해서는 객체 배열을 생성할 수 없다.
예를 들어 디폴트 생성자가 없는 SpreadSheetCell 클래스에 대해 다음처럼 선언하면, 컴파일에 실패한다.
SpreadSheetCell cells[3]; // 컴파일 오류 발생
SpreadSheetCell* myCellp = new SpreadSheetCell[10]; // 똑같이 발생함.
하지만, 객체의 배열을 이용해야 한다면, 디폴트 생성자를 만드는 것이 훨씬 쉬운 방법이다.
만약 생성자를 아무것도 정의하지 않으면, 컴파일러가 자동으로 티폴트 생성자를 만들어 넣어준다.
객체저장에 std::vector와 같은 STL컨테이너를 이용하려면 디폴트 생성자를 꼭 만들어야 한다.
클래스 안에서 다른 클래스를 생성할 떄도 생성할 클래스에 디폴트 생성자가 있으면 편리하다.
디폴트 생성자 작성 방법
다음은 디폴트 생성자가 포함된 SpreadSheetCell 클래스의 정의이다.
class SpreadSheetCell
{
public:
SpreadSheetCell();
생성자에서 mString을 공백 문자열로 초기화할 필요는 없는데, std::string 디폴트 생성자가 자동으로 공백 문자열로 초기화해주기 때문이다.
SpreadSheetCell::SpreadSheetCell()
{
mValue = 0;
}
디폴트 생성자를 스텍 객체 생성에 이용할 때는 다음과 같이 할 수 있다.
SpreadSheetCell myCell;
myCell.setValue(6);
cout << "cell 1: " << myCell.getValue() << endl;
myCell이라는 이름으로 SpreadSheetCell객체를 생성하고, 값을 세팅한 후 출력한다.
지역변수로 선언되는 객체에 디폴트 생성자를 호출할 때는 다른 생성자와 달리 함수 호출 형태로 사용해서는 안된다.
하지만, 다른 생성자와 일관성 있는 사용형태를 고수하고 싶을 수 있다.
불행하게도 디폴트 생성자를 함수 호출 형태로 사용한 라인에 대해 컴파일러는 아무런 오류 메세지를 출력하지 않는다.
하지만 그 다음 라인은 컴파일이 안되게 되는데, 여기서 발생한 문제는 컴파일러가 첫 번쨰 라인 함수명이 myCell이고 공백 파라미터에 리턴 타입이 SpreadSheetCell인 함수 선언으로 착각하기 때문이다.
(객체를 스택에 생성할 때 디폴트 생성자를 이용해야 한다면 괄호를 붙이지 않는다.)
그런데 new연산자로 객체를 힙에 생성할 떄는 디폴트 생성자를 다음과 같이 사용해야 한다.
auto smartCellp = make_unique<SPreadSheetCell>();
// 또는 아래와 같이 일반 포인터 생성가능
SpreadSheetCell* myCellp = new SpreadSheetCell();
// 또는
// SpreadSheetCell* myCellp = new SpreadSheetCell;
// 와 같이 객체를 생성하고 myCellp를 사용한 후 메모리에서 직접 해제
delete myCellp; mycellp = nullptr;
컴파일러에 의해 생성되는 디폴트 생성자
디폴트 생성자는, 따로 정의하지 않아도 자동으로 컴파일러가 정의해주는데, 처음에 만들었던 SreadSheetCell의 선언이 가능했던걸 기억해보자.
class SpreadSheetCell
{
public:
void setValue(double inValue);
double getValue() const;
void setString(const std::string& inString);
const std::string& getString() const;
private:
std::string doubleToString(double inValue) const;
double stringToDouble(const std::string& inString) const;
double mValue;
std::string mString;
};
위와같이 선언되어있는 Class는 디폴트 생성자를 담고있지 않아도, 다음 코드는 정상적으로 컴파일되고 작동한다.
SpreadSheetCell myCell;
myCell.setValue(6);
클래스 정의는 앞의 클래스 정의에 double타입 파라미터를 받는 명시적인 생성자를 추가한 것 이다. 이 정의에서도 디폴트 생성자는 만들지 않는다.
class SpreadSheetCell
{
public:
SpreadSheetCell(double initialValue); // 디폴트는 선언하지 않음
public:
void setValue(double inValue);
double getValue() const;
void setString(const std::string& inString);
const std::string& getString() const;
private:
std::string doubleToString(double inValue) const;
double stringToDouble(const std::string& inString) const;
double mValue;
std::string mString;
};
위와같이 클래스 정의를 이용하면, 잘 작동하던 코드가 컴파일조차 되지 않는다.
SpreadSheetCell myCell;
myCell.setValue(6);
// 동작하지 않는다!
이런 일이 벌어지는 이유는 정의되어있는 생성자가 없을 경우, 컴파일러가 자동으로 디폴트 생성자를 만들어주기 때문이다.
컴파일러에 의해 생성된 디폴트 생성자는 클래스 안에 속한 멤버들에 대해서도 디폴트 생성자를 호출한다.
단, int와 double같은 기본 타입은 생성자 호출이 없다.
하지만 사용자가 직접 생성자를 만들어 넣는 순간 생성자의 형태와는 관계없이 컴파일러의 디폴트 생성자 자동 생성이 더 이상 일어나지 않는다.
( 디폴트 생성자는 인지가 없는 생성자를 의미한다. 즉, 사용자가 직접 생성했던, 컴파일러가 자동으로 생성했던 인자없이 객체를 생성할 때 호출되는 생성자는 디폴트 생성자 이다.)
명시적인 디폴트 생성자
C++03 또는 그 이전 버전의 C++에서는 명시적으로 파라미터가 존재하는 생성자를 사용해야 할 때 파라미터가 없는 디폴트 생성자도 무조건 정의해주어야 했다. 이 때문에 특별히 할일이 없는 디폴트 생성자를 다음처럼 클래스 안에 정의해야 했다.
class MyClass
{
public:
myClass() {};
myClass(int i );
};
하지만 인터페이스 클래스라면 구현부를 노출해서는 안 되기 때문에 위 클래스 정의는 바람직 하지 않다.
class MyClass
{
public:
MyClass();
MyClass(int i);
};
더미 디폴트 생성자의 구현부는 다음처럼 정의하여 CPP파일에 위치시킨다.
MyClass::MyClass() {}
이러한 작업은 조금 거추장스럽고 어색하기 때문에, 현재의 C++표준에서는 명시적인 디폴트 생성자 라는 개념이 생겼다.
이것을 사용하면 다음과 같이 구현부 없이도 디폴트 생성자를 간편하게 선언할 수 있다.
class MyClass
{
public:
MyClass() = default;
MyClass(int i);
};
MyClass는 int 파라미터를 받는 커스텀 생성자를 정의하고 있다. 그런데 명시적인 디폴트 생성자 선언의 표시로 default 키워드를 사용하고 있기 떄문에, 컴파일러가 자동으로 디폴트 생성자를 만들어 준다.
명시적으로 삭제된 생성자
C++는 명시적으로 삭제된 생성자 라는 개념도 지원한다. 이 기능을 이용하면 컴파일러가 자동으로 생성하는 디폴트 생성자 까지 포함하여 생성자가 전혀 정의되지 않은 클래스를 만들 수 있다. 다음과 같이 디폴트 생성자에 delete 표기를 하면 된다.
class MyClass
{
public:
MyClass() = delete;
};
7.3.1.5 생성자 초기화 리스트
지금까지 설명에서는 데이터 멤버를 초기화 할떄 다음처럼 생성자의 바디를 이용했다.
SpreadSheetCell::SpreadSheetCell()
{
mValue = 0;
}
C++는 생성자 초기화 리스트 라 부르는 또 다른 데이터 멤버 초기화 방법을 제공한다. 다음은 SpreadSheetCell의 디폴트 생성자를 생성자 초기화 리스트를 이용하여 재작성 한 것이다.
SpreadSheetCell::SpreadSHeetCell() : mValue(0)
{
}
생성자 초기화 리스트는 생성자 인자 목록과 바디 시작 중괄호 사이에 위치한다. 이 리스트는 콜론으로 시작하여 각 항목이 쉼표로 나뉜다. 각 항목은 함수 호출 형태를 사용하며 괄호 사이에 데이터 멤버의 초깃값을 받는다. 여기에는 데이터 항목뿐만 아니라 베이스 클래스의 생성자도 호출할 수 있다. 또는 위임된 생성자를 호출할 수도 있다.
생성자 초기화 리스트를 이용해서 데이터 멤버를 초기화하는 것은 생성자 바디에서 초기화 코드를 정의하는 것과 상당히 다른 방식으로 동작한다. C++에서 객체를 생성할 떄는 생성자를 호출하기 전에 모든 데이터 멤버가 먼저 생성되어 메모리에 할당된 상태여야 한다.
이때 객체 타입인 데이터 멤버는 생성자가 호출되고 기본 타입은 그 데이터가 할당된 메모리에 남아있는 임의의 값을 가지게 된다. 생성자 초기화 리스트는 이러한 과정에서의 멤버에 대한 생성자 호출과 기본 타입 데이터의 초깃값을 선택할 수 있게 해준다. 즉, 컴파일러가 기본적으로 수행하는 과정을 필요에 맞게 수정한다.
어차피 실행된 과정을 이용하는 것이기 떄문에 나중에 생성자 바디에서 코드를 통해 초기화 하는 것보다 훨씬 효율적이다. 위 예시에서는 mString을 공백으로 초기화하고 있는데, mString의 디폴트 생성자는 스스로를 공백 문자로 초기화 하기 떄문에 mString("")은 없어도 된다.
데이터 멤버 클래스가 디폴트 생성자를 제공하지 않는다면 반드시 생성자 초기화 리스트를 이용해서 그 멤버 클래스의 명시적인 생성자를 호출해주어야 한다.
에를들어 SpreadSheetCell클래스가 다음과 같이 정의되어 있다면,
class SpreadSheetCell
{
public:
SpreadSheetCell(double d);
};
생성자의 구현부는 아래처럼 정의된다.
SpreadSheetCell::SpreadSheetCell(double d) { }
이 클래스에는 디폴트 생성자가 없고, double 타입 파라미터를 받는 생성자만 있다. 다음처럼 이 클래스를 데이터 멤버로 가진 다른 클래스를 보자.
class SomeClass
{
public:
SomeClass();
private:
SpreadSheetCell mCell;
};
SomeClass의 생성자는 다음처럼 정의 될 수 있다.
SomeClass::SOmeClass() { }
하지만, 이 클래스를 다음처럼 선언하여 객체를 만들면 컴파일 에러가 발생한다.
컴파일러는 SomeClass의 mCell데이터 멤버를 어떻게 초기화해야 할지 알지 못한다.
mCell클래스는 디폴트 생성자가 없기 때문이다.
이에 대한 해결책은 다음과 같이 mCell 데이터 멤버에 대해 생성자 초기화 리스트를 사용해서 명시적으로 double타입 파라미터의 생성자를 호출해주는 것이다.
SomeClass::SOmeClass() : mCell(1, 0) { }
생성자 초기화 리스트는 데이터 멤버의 초기화가 객체 생성 시점에 즉석에서 일어날 수 있도록 해준다.
어떤 프로그래머는 생성자 바디에서 값 대입을 통해 멤버를 초기화하는 것을 선호하기도 한다.
하지만, 어떤 데이터 타입은 생성자 초기화 리스트가 아니면 초기화할 수 없다. 다음 표는 초기화 리스트로만 초기화 할 수 있는 경우를 정리하였다.
데이터 타입 | 설명 |
const 데이터 멤버 | const 변수는 그 변수가 생성된 이후에는 값을 대입할 수 없다. 이 변수에 대한 초깃값 할당은 반드시 변수 생성 시점에 해야한다. |
참조형 데이터 멤버 | 참조형은 무언가를 참조하지 않은 채로는 존재할 수 없다. |
디폴트 생성자가 없는 객체 타입 멤버 | C++에는 객체 멤버를 디폴트 생성자를 이용해서 생성한다. 만약 디폴트 생성자가 없는 멤버가 있으면 그 객체를 초기화할 수 없다. |
디폴트 생성자가 없는 베이스 클래스 | 해당 베이스 클래스는 초기화 리스트 내부가 아니라면 생성사 호출이 불가능하기 때문 |
생성자 초기화 리스트에는 한 가지 중요한 것이 있는데,
각 멤버를 초기화 할 때 초기화 리스트에 등장하는 순서로 초기화하는 것 이 아니라 클래스 정의에서 그 멤버들이 등장하는 순서로 초기화 한다는 점이다.
SpreadSheetCell의 클래스를 정의하였을 때,
class SpreadSHeetCell
{
public:
//코드생략
private:
double mValue;
std::string mString;
};
생성자 초기화 리스트에서 다음과 같이 문자열 멤버를 초기화한다면,
SpreadSheetCell::SpreadSheetCell(const string& initialValue) :
mString(initalValue), mValue(stringToDouble(mString)) // 잘못된 순서!
{
}
이러한 코드는 컴파일은 가능하지만(경고는 표시됨) 의도한대로 동작은 안된다.
초기화 리스트 순서에 따라 mString이 mValue보다 먼저 초기화 되리라고 짐작할 순 있지만, C++는 그런식으로 동작하지 않는다. SpreadSheetCell클래스 정의에는 mValue가 mString보다 먼저 정의되어 있기 때문에 mValue가 먼저 초기화 된다. 그런데 mValue를 초기화할 떄 mString을 이용하고 있기 때문에 아직 재대로 된 초깃값을 가지지 못한 문자열을 이용하게 되어 mValue가 의도한 값을 가지지 못하게 된다.
이에 대한 해결책은 다음처럼 mValue를 초기화할 때 생성자 파라미터를 직접 이용하도록 하는 것이다. 그리고 코드를 보는 사람이 혼돈하지 않도록 그 순서도 바꾸어주어야 한다.
SpreadSHeetCell::SpreadSheetCell(const string& initalValue) :
mValue(stringToDouble(initalValue)), mString(initalValue)
{
}
생성자 초기화 리스트에서는 초기화 나열 순서에 따라 멤버가 초기화되는것 이 아니라 클래스 정의 시점에서 멤버들이 등장하는 순서에 따라 초기화된다.
7.3.1.6 복제 생성자
C++에는 복제 생성자 라 불리는 특별한 생성자가 있다. 이 생성자는 객체의 복제본을 만들어야 할 때 편리하게 사용된다.
프로그래머가 명시적으로 복제 생성자를 만들지 않으면 컴파일러가 자동으로 만들어준다. 컴파일러가 만들어주는 복제 생성자는 기본 타입 멤버는 그대로 복제하고 객체 타입 멤버는 그 객체의 복제 생성자를 호출해준다.
다음은, SpreadSheetCell 클래스의 복제 생성자 정의이다.
class SpreadSheetCell
{
public:
SpreadSheetCell(const SpreadSheetCell& src);
// 나머지 생략
};
복제 생성자에서는 파라미터로 받는 원본 객체에 대해 상수 참조를 함으로 const와 &가 붙어있다. 다른 생성자와 마찬가지로 리턴값을 가지지 않는다. 생성자 안에서는 원본 객체가 가진 모든 멤버 필드에 대해 복제 작업을 해야 한다.
기술적으로는 복제 생성자 바디 안에서 뭐든 할 수 있다. 하지만 객체를 복제한다는 기본적인 동작에서 벗어나는 작업을 하는 것은 좋은 생각이 아니다.
SpreadSheetCell::SpreadSheetCell(const SpreadSheetCell& src) :
mValue(stc.mValue), mString(src.mString)
{
}
복제 생성자에서 원본의 값을 가져올 때, 생성자 초기화 리스트를 이용하는 경우와 생성자 바디에서 이용하는 경우 약간의 차이가 있다. 여기서는 초기화 리스트를 이용하고 있다.
멤버 변수가 m1, m2 .... mn 과 같이 선언되어있다면 컴파일러는 다음과 같은 형태로 복제 생성자를 자동으로 만들어준다.
classname::classname(const classname& src) :
m1(src.m1), m2(src.m2), .... mn(src.mn) {}
따라서 대부분은 복제 생성자를 직접 만들 필요는 없다.
복제 생성자가 호출되는 상황
함수를 호출하면서 파라미터를 넘길 떄는 기본적으로 그 값이 복제되어 전달된다. 이러한 방식을 값에 의한 전달이라고 한다. 그러므로 클래스 객체를 함수나 메서드의 인자로 전달하면 컴파일러에 의해 복제 생성자가 호출되어 새로운 객체가 만들어진다. 예를 들어 SpreadSheetCell클래스에 다음과 같은 setString()메서드가 정의되어 있다고 가정하자.
void SpreadSheetCell::setString(string inString)
{
mString = inString;
mValue = stringToDouble(mString);
}
string은 int나 double같은 기본 타입이 아니라 클래스다. 메서드를 호출하면서 inString에 string타입 객체를 전달하면 inString 객체에 대해 string의 복제 생성자가 호출되어 새로운 객체로 초기화 된다. 다음 코드에서는 name변수가 setString()에 넘겨질 때 새로운 객체가 생성되면서 name객체가 가진 값으로 초기화되고 메서드 안에서 파라미터 inString으로 보여진다.
SpreadSheetCell myCell;
string name = "heading one";
myCell.setString(name); // name의 복제
setString() 메서드가 작업을 완료하면 inString객체도 소멸한다 inString객체는 name의 복제본이기 때문에 원본인 name객체는 inString이 소멸하더라도 아무런 영향이 없다.
복제 생성자는 함수나 메서드에서 객체를 리턴할 때도 호출된다. 이 때는 컴파일러가 임시 객체를 만들어서 복제 생성자를 호출한다. 파라미터를 전달할 때 const 키워드를 사용하면 복제 작업 참조로 전달되어 오버헤드를 줄일 수 있다.
명시적인 복제 생성자 호출
객체의 복제본이 필요할 때는 복제 생성자를 직접 이용할 수도 있다. 다음은 복제 생성자를 이용해서 SpreadSheetCell 객체의 복제본을 만드는 예 이다.
SpreadSheetCell myCell2(4);
SpreadSheetCell myCell3(myCell2); // myCell3는 myCell2와 같은 값을 가진다.
참조에 의한 객체 전달
함수나 메서드를 호출할 때 파라미터로 넘겨지는 객체가 복제되는 오버헤드를 피하려면 참조형 을 이용하면 된다.
객체가 참조로 전달되면 그 주솟값만 복제되고 내용은 복제되지 않기 때문에 값에 의한 전달보다 더 효율적이다. 게다가 동적 메모리 할당과 관련된 문제도 피할 수 있다.
객체를 참조로 전달하면 메서드에서 파라미터로 받은 원본 객체의 내용을 바꿀 수 있게 된다. 만약 복제 오버헤드를 피하기 위한 의도로만 참조를 이용한다면 const 키워드를 붙이는 것이 더 안전하다.
앞 예제들에서 SpreadSheetCell 클래스의 메서드들은 모두 const string에 대한 참조를 파라미터로 사용하고 있다. 만약, string객체를 참조로 받지 않는다면 SpreadSheetCell 클래스 정의가 다음과 같이 된다.
class SpreadSheetCell
{
public:
SpreadSheetCell();
SpreadSheetCell(double initalValue);
SpreadSheetCell(std::string initalValue);
SpreadSheetCell(const SpreadSheetCell& src);
void setValue(double inValue);
double getValue() const;
void setString(std::string inString);
std::string getString() const;
private:
std::string doubleToString(double inValue) const;
double stringToDouble(std::string inString) const;
double mValue;
std::string mString;
};
다음은 setString()메서드의 구현부 이다. 메서드의 바디는 전혀 변한 것이 없고 파라미터 타입만 바뀌었다.
void SpreadSheetCell::setString(string inString)
{
mString = inString;
mValue = stringToDouble(mString);
}
특별한 이유가 없다면 함수나 메서드의 인자 타입으로 값에 의한 전달 대신 const참조형을 사용하는 것이 좋다.
SpreadSheetCell 클래스의 doubleToString() 메서드는 항상 string값으로 리턴해야 한다. 이 함수는 내부에서 로컬 변수로 string 객체를 만들어서 리턴하고 있기 때문이다. 참조형으로 변수를 리턴하면 함수가 종료되었을 때 객체가 소멸되어 더 이상 유효하지 않기 때문에 정상적으로 작동할 수 없다.
복제 생성자의 명시적인 자동 생성과 자동 생성 방지
앞서 살펴본 디폴트 생성자와 마찬가지로, 복제 생성자도 defalut와 delete키워드를 이용해서 컴파일러의 자동 생성을 명시적으로 지정하거나 방지할 수 있다.
SpreadSheetCell(const SpreadSheetCell& src) = defalut;
또는
SpreadSheetCell(const SpreadSheetCell& src) = delete;
7.3.1.7 initalizer_list 생성자
initalizer_list 생성자는 첫 번째 파라미터로 std::initalzier_list<T>타입을 념겨 받으면서 다른 파라미터가 없거나 디폴트 값이 지정된 파라미터만 가지는 생성자를 말한다.
std::initalzier_list<T>를 사용하기 위해서는 <initalziser_list>를 인클루드 해야한다. 다음은 해당 생성자를 정의한 예 이다.
#pragma once
#include <initializer_list>
#include <stdexcept>
#include <iostream>
#include <vector>
using namespace std;
class EvenSequence
{
public:
EvenSequence(initializer_list<double> args)
{
if (args.size() % 2 != 0) {
throw invalid_argument("initalizer_list should"
"contain even number of elements. ");
}
mSequence.reserve(args.size());
for (auto value : args) {
mSequence.push_back(value);
}
}
void dump() const
{
for (auto value : mSequence) {
cout << value << ", ";
}
cout << endl;
}
private:
vector<double> mSequence;
};
initializer_list생성자 안에서는 앞서 소개한 반복자를 이용해도 args로 넘어온 값에 접근할 수 있다. args의 항목 개수는 size() 메서드로 알 수 있다.
EvenSequence의 초기화 리스트 생성자는 주어진 초기화 리스트부터 항목들을 복제하는 데 범위 기반 for루프를 이용하고 있다. 가능하면 STL 알고리즘을 활용하는 것이 권장된다. 위 범위 기반 for루프와 push_back()메서드를 STL로 고쳐쓰면 다음과 같다.
mSequnce.insert(cend(mSequence), cbegin(args), cend(args));
initializer_list 생성자를 이용하면 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};
STL클래스 들은 모두 initializer_list 생성자를 지원하고 있다. 예를 들어 std::vector 컨테이너를 다음처럼 initializer_list를 이용해서 초기화할 수 있다.
std::vector<std::string> myVec = {"string 1", "string 2", "string 3"};
초기화 리스트 생성자를 지원하지 않는다면 생성자가 아닌 push_back()메서드를 이용해서 다음처럼 일일이 항목을 세팅해야 한다.
std::vector<std::string> myVec;
myVec.push_back("String 1");
myVec.push_back("String 2");
myVec.push_back("String 3");
initializer_list의 활용은 클래스 생성자에만 국한되지 않는다. 일반 함수에도 사용할 수 있다.
7.3.1.8 클래스 내 멤버 초기화
C++부터는 다음 예제처럼 멤버 변수의 초기화를 클래스를 정의하는 시점에 바로 할 수 있도록하고있다.
#include <string>
class MyClass
{
private:
int mInt = 1;
std::string mStr = "test";
};
C++11이전 버전에서는 생성자의 바디나 생성자 초기화 리스트에서만 초화 할수 있었다.
7.3.1.9 생성자 위임
생성자 위임은 생성자 안에서 같은 클래스와 다른 생성자를 호출하는 것을 말한다. 단, 생성자 바디에서 다른 생성자를 부를 수는 없고 다음처럼 생성자 초기화 리스트를 이용해야 하고 멤버 초기화 리스트 중에서 유일한 항목이어야 한다.
SpreadSheetCell::SpreadSheetCell(const string& initalValue)
: SpreadSheetCell(stringToDouble(initialValue))
{
}
string 타입 인자를 가진 생성자는 인자가 double 타입인 생성자에 객체 생성을 위임하고 있다. double 타입 생성자가 실행되고 난 뒤 string 타입 생성자의 바디가 실행된다.
생성자를 위임할 떄는 재귀적으로 꼬리를 무는 상황을 피해야 한다.
class MyClass
{
MyClass(char c) : MyClass(1, 2) { }
MyClass(double d) : MyClass('m') { }
};
첫 번째 생성자는 두 번째 생성자에 생성자 위임을 하고 있고 두 번쨰 생성자는 다시 거꾸로 첫 번쨰 생성자에 생성자 위임을 하고 있다. 이러한 코드가 어떻게 동작할지는 C++표준에 정해진 것이 없음으로 컴파일러 마음이다.
7.3.1.10 컴파일러 자동 생성 생성자 정리
디폴트 생성자와 복제 생성자는 컴파일러가 자동으로 생성할 수 있다. 이러한 생성자는 원칙에 따라 프로그래머가 직접 생성한 생성자로 대체될 수도 있다.
직접 정의한 생성자 | 컴파일러가 자동 생성하는 생성자 | 사용 가능한 객체 생성 방법 |
없음 | 디폴트 생성자 복제 생성자 |
인자가 없는 생성 SpreadSheetCell cell; 다른 객체의 복제 : SpreadSheetCell myCell(cell); |
디폴트 생성자 | 복제 생성자 | 인자가 없는 생성: SpreadSheetCell cell; 다른 객체의 복제 SpreadSheetCell myCell(cell); |
복제 생성자 | 없음 | 이론적으로는 다른 객체를 복제한 생성이 가능하나, 현실적으로 닭과 달걀 문제처럼 복제할 객체가 없음으로 결과적으로 객체 생성이 불가능하다. |
복제 생성자가 아닌 파라미터가 있는 생성자 | 복제 생성자 | 인자가 주어진 생성 : SpreadSheetCell cell(6); 다른 객체의 복제 SpreadSheetCell myCell(cell); |
디폴트 생성자 복제 생성자가 아닌 파라미터가 있는 생성자 |
복제 생성자 | 인자가 없는 생성: SpreadSheetCell cell; 인자가 주어진 생성: SpreadSheetCell myCell(5); 다른 객체의 복제 SpreadSheetCell anotherCell(cell); |
디폴트 생성자와 복제 생성자의 자동 생성 원칙 간에는 딱히 유사한 패턴이 없다. 복제 생성자는 프로그래머가 직접 정의하지 않으면 무조건 자동 생성된다. 반면 디폴트 생성자는 프로그래머가 직접 정의한 생성자가 있으면 그 종류와 관계없이 자동생성되지 않는다.
그리고 deflaut와 delete 키워드를 이용해서 컴파일러 자동 생성자를 명시하거나 배제할 수 있다.
7.3.2 객체의 소멸
객체가 소멸할 떄는 두가지 작업이 발생한다. 각 순서대로 동작하게 되는데
- 객체의 소멸자 호출
- 할당받은 메모리 반환
순으로 이루어진다. 소멸자에서는 객체의 메모리가 반환되기 전에 해야할 정리 작업을 수행할 수 있다. 예를 들어 내부 멤버를 위해 할당받은 메모리를 반환하거나, 열어놓은 파일 핸들을 닫는 등의 작업이다. 소멸자를 정의하지 않으면 컴파일러가 자동으로 생서앻준다. 자동으로 생성된 소멸자는 클래스 내 맴버들을 돌아가며 재귀적으로 소멸자를 호출하여 객체가 삭제될 수 이쏟록 한다.
스텍에 생성된 객체는 유효 범위를 벗어날 때 자동으로 삭제된다. 즉, 해당 객체가 생성된 함수나 메서드 또는 중괄호 블록을 벗어나는 순간 소멸한다. 즉, 코드 실행이 중괄호 블록이 끝나는 지점에 도달하면, 그 블록 내에서 스택에 생성된 객체는 모두 삭제된다.
int main()
{
SpreadSheetCell myCell(5);
if(myCell.getValue() == 5) {
SpreadSheetCell anotherCell(6);
} // anotherCell 객체는 블록이 끝나면 삭제됨.
cout << "myCell: " << myCell.getValue() << endl;
return 0;
} // myCell은 이 블록이 끝나는 시점에 소멸한다.
스텍에 생성된 객체는 선언(그리고 생성) 순서의 역순으로 삭제된다. 예를 들어 다음 코드에서 muCell2는 anotherCell2보다 앞서서 생성되었기 때문에 삭제될 때는 anotherCell2가 myCell2보다 먼저 삭제된다.(중괄호 블록은 코드내 어디든 존재할 수 있고, 중첩도 자유롭다)
{
SpreadSheetCell myCell2(4);
SpreadSheetCell anotherCell2(5); // myCell2가 anotherCell2보다 먼저 생성됨.
}// myCell2는 anotherCell2보다 뒤에 소멸
이러한 소멸과 생성 순서는 객체의 데이터 멤버에도 동일하게 적용된다. 객체 생성시 데이터 멤버의 초기화가 클래스 내 멤버 선언 순서를 따른다는것을 다시 떠올려보자. 객체가 소멸될 때는 이러한 초기화 순서의 역순으로 데이터 멤버의 소멸이 일어난다.
힙에 생성된 객체는 자동으로 소멸되지 않는다. 명시적으로 delete 연산자를 호출하면서 객체의 포인터를 인자로 남겨야 소멸자가 호출되고 메모리에서 해제된다.
int main()
{
SpreadSheetCell* cellPtr = newSpreadSheetCell(5);
SpreadSheetCell* cellPtr = newSpreadSheetCell(6);
cout << "cellPtr1: " << cellPtr1 -> getValue() << endl;
delete cellPtr1; // cellPtr1삭제
cellPtr1 = nullptr;
return 0;
} // cellPtr2 는 delete가 호출되지 않았기 때문에 삭제되지 않는다.
이렇게 cellPtr2처럼 delete되지 않은 포인터를 남겨두어서는 안된다. 동적으로 할당된 메모리는 항상 delete나 delete[]를 이용해서 해제해야 한다. new로 할당된 메모리는 delete로 해제하고 new[]로 할당된 메모리는 delete[]로 해제한다. 스마트포인터를 이용하면 신경쓰지 않아도 자동으로 해제된다.
어떤 툴들은 이런 메모리록을 자동으로 찾아주기도 한다.
7.3.3 객체의 대입
int변수의 값을 다룬 int 변수에 대입하듯이 객체도 다른 객체에 대입할 수 있다. 예를들어 다음 코드는 myCell을 anotherCell에 대입한다.
SpreadSheetCell myCell(5), anotherCell;
anotherCell = myCell;
이때 대입이 아니라 복제가 일어났다고 생각할 수도 있다. 하지만 C++에서는 객체가 생성되면서 값이 초기화 될 때만 복제가 일어난다. 이미 메모리를 할당받은 객체에 대해 그 값이 덮어써지는 상황은 대입 이 더 적합한 표현이다. C++에서는 복제 생성자만 객체를 복제할 수 있고, 복제 생성자는 사후 대입이 아닌 객체 생성을 위헤서만 호출될 수 있다.
이 떄문에 C++에서는 객체간 대입을 지원하는 대입 연산자 라는 별도의 방법을 제공한다. 대입 연산자는 =연산자를 각 클래스에서 오버로딩하여 구현한다. 이 때문에 이 연산자를 operator=이라고 한다. 위 예제에서 anotherCell은 myCell을 인자로 하여 대입 연산자가 호출된 것 이다.
대입 연산자를 복사 대입 연산자 라고 부르기도 한다. 왜냐하면 연산자의 좌변과 우변 모두 연산자 실행 후에도 모두 유효하기 때문이다. 이렇게 달리 부르는 이유는 이동 대입 연산자와 구분하기 위함이다.
일반적으로 프로그래머가 대입연산자를 직접 구현할 필요가 없다. C++언어 차원에서 객체간 대입이 가능하도록 자동으로 대입연산자를 만들어 준다. C++에서 대입 연산의 기본 동작은 복제 동작과 거의 같다. 객체의 데이터 멤버에 대해 재귀적으로 원본 객체에서 대상 객체로 대입 연산을 수행한다.
7.3.3.1 대입 연산자의 선언
다음은 대입 연산자 선언이 포함된 SpreadSheetCell 클래스 정의이다.
class SpreadSheetCell
{
public:
// 나머지 정의 생략
SpreadSheetCell& operaotr=(const SpreadSheetCell& rhs);
// 나머지 생략
대입 연산자는 복제 생성자처럼 원본 객체를 const참조형으로 받는다. 이 예에는 원본 객체를 = 기호의 우변 항목 이라는 의미로 rhs라 이름 붙였다. 대입 연산자가 호출되는 객체는 =기호의 좌변 객체이다.
복제 생성자와는 달리 대입 연산자는 SpreadSheetCell의 참조 객체를 리턴한다. 그 이유는 대입 연산이 다음처럼 중첩되서 수행될 수 있기 때문이다.
myCell = anotherCell = aThirdCell;
위 코드가 실행되었을 떄, 가장 먼저 anotherCell의 대입 연산자가 aThirdCell을 '우변 인자'; 로 하여 호출된다. 그 다음에 myCell의 대입 연산자가 호출되는데,, 이때 우변 항목 인자는 anotherCell이 아니다, myCell의 대입 연산자는 anotherCell의 대입연산자가 aThirdCell을 인자로 하여 실행된 리턴값을 우변 항목 인자로 취하게 된다. 만약 대입연산에 실패하게 되면, 리턴값이 없기 땜누에 myCell로 전달할 인자가 없게된다.
myCell 의 대입 연산자가 그냥 anotherCell을 인자로 취해도 문제가 없지 않나? 라고 생각할 수 있다 문제는 = 기호가 단지 메서드 호출을 래핑할 뿐 이라는데 있다. 위 코드를 함수 호출 형태로 다시쓰면 다음과 같다.
myCell.oprator=(anotherCell.operaotr=(aThirdCell));
myCell.operator=이 실행되면 anotherCell.operator= 이 반드시 값을 리턴해야 한다. 맥락상, 올바른 리턴값은 anotherCell 그자체가 되어야 하고, 객체리턴에 따른 임시 객체로서 복제 오버헤드를 피하려면 참조형으로 리턴하는 것이 바람직 하다.
7.3.3.2 대입 연산자 정의
대입 연산자의 구현은 복제 생성자의 구현과 비슷하지만 몇 가지 중요한 차이점이 있다.
첫 번쨰로 복제 생성자는 객체 초기화 시점에만 호출되기 때문에 대상 객체의 멤버가 아직 유효하지 않다. 하지만 대입연산자는 이미 생성된 객체를 대상으로 하기 때문에 멤버의 메모리 할당 완료 여부에 신경을 쓰지 않고도 값을 덮어쓸 수 있다.
물론 동적으로 할당되는 메모리를 사용하지 않는다면 이러한 차이점이 의미가 없을 수 있다.
두 번째로 C++에서는 객체가 자기 스스로 대입하는것이 문법적으로 가능하다는 것 이다.
SpreadSheetCell cell(4);
cell = cell; //자기스스로 대입
대입 연산자를 구현할 때 자신을 인자로 하는 경우를 배제하면 안된다. 대신 작업을 그대로 수행하는 것도 문제다.
대입연산자가 실행되면 가장 먼저 인자가 자기 자신인지 검사하고, 만약 그렇다면 작업을 진행하지 않고 리턴하게 해야한다. 만약, 이런상황을 예측한다면 다음과 같이 코드를 작성할 수 있다.
SpreadSheetCell& SpreadSheetCell::operator = (const SpreadSheetCell& rhs)
{
if(this == &rhs) {
자기자신을 확인하는 가장 좋은 방법은 두 객체의 주솟값을 참조하여 같은 주소인지 확인하는 것 이다
그래서 this와 &rhs를 비교하여 그 값이 같다면, 같은 주솟값을 가지고 있다면, 두 객체가 같은 대상을 가리키고, 자기자신인걸 알 수 있다.
이런경우 그냥 return 을 다음과 같이 정의해도 무방하다.
return *this;
}
this포인터는 연산자가 실행된 객체를 가리키기 때문에 *this는 객체 자신이 된다. 리턴 타입이 참조형 이기 떄문에, *this를 리턴하면 컴파일러가 참조형으로 전달한다.
7.3.3.3 대입 연산자의 명시적인 선언과 배제
컴파일러가 자동으로 생성하는 대입 연산자는 defalut와 delete 키워드를 이용해서 다음과 같이 명시적으로 사용을 선언할 수도 배제할 수도 있다.
SpreadSheetCell& operator=(const SpreadSheetCell& rhs) = defalut;
또는
SpreadSheetCell& operator=(const SpreadSheetCell& rhs) = delete;
7.3.4 대입과 복제를 구분하는 방법
어떤 때는 객체가 복제 생성자에 의해 초기화되는 건지 대입 연산자가 실행되어 값이 대입되는 건지 모호한 경우가 있다.
기본적으로 변수 선언을 할 떄는 복제생성자가 이용되고 대입 연산을 할 때는 대입 연산자가 호출된다.
SpreadSheetCell myCell(5);
SpreadSheetCell anotherCell(myCell);
ANotherCell은 복제 생성자를 통해 생성된다.
SpreadSheetCell aThirdCell = myCell;
aThirdCell도 복제 생성자를 통해 생성된다. 왜냐하면 변수 선언이기 때문이다. 위 코드는 operator=을 호출하지 않는다! 위 코드를 다르게 작성하면 SpreadSheetCell aThirdCell(myCell); 과 같다. 하지만 다음의 경우 anotherCell은 이미 생성되었기 때문에 컴파일러가 operator=를 호출한다.
anotherCell = myCell; // anotherCell의 operator = 을 호출
7.3.4.1 리턴값으로서의 객체
함수나 메서드에서 객체를 리턴할 때 복제가 일어날 지 대입이 일어날 지 정확히 알 수 없는 때도 있다.
string SpreadSheetCell::getString() const
{
reutrn mString;
}
위 메서드를 다음과 같이 이용한다고 할때
SpreadSheetCell myCell2(5);
string s1;
s1 = myCell2.getString();
getString()이 mString을 리턴하면 컴파일러는 string의 임시 객체를 생성하면서 string의 복제 생성자를 호출한다. 그리고 s1에 리턴값을 대입할 떄는 생성된 임시 객체를 인자로 하여 s1의 대입 연산자가 호출되고, 대입 연산자의 실행이 끝난 후에는 임시 객체가 소멸한다. 즉, 이 한줄 코드에서 복제 생성자와 대입 연산자가 각각 서로 다른 객체를 대상으로 모두 호출된다. 하지만 컴파일러가 이러한 과정에 리턴값 최적화 (RVO)를 적용할 수 있다.
7.3.4.2 복제 생성자와 객체의 데이터 멤버
생성자에서 멤버 초기화를 할 때 복제 생성자가 사용되는지 대입 연산자가 사용되는지 잘 생각 해야한다. 데이터 멤버 중 객체가 있으면, 컴파일러가 자동으로 생성하는 복제 생성자에서는 멤버의 복제 생성자를 재귀적으로 호출한다.
직접 복제 생성자를 만들 때는 컴파일러가 생성한 복제 생성자와 마찬가지로 생성자 초기화 리스트에서 멤버의 복제 생성자를 호출할 수 있다. 이때 만약 멤버 초기화를 빠뜨리면 컴파일러가 빠뜨린 멤버에 대해서 디폴트 생성자를 부른다.
이 디폴트 생성자는 직접 작성한 복제 생성자의 바디가 실행되기 전에 수행된다. 이 떄문에 생성자 바디가 실행되는 시점에는 모든 멤버가 초기화된 상태가 된다.
예를 들어 복제 생성자는 다음과 같이 작성했다고 가정해보자.
SpreadSheetCell::SpreadSheetCell(const SpreadSheetCell& src)
: mString(src.mString)
{
mValue = src.mValue;
}
복제 생성자 안에서 데이터 멤버에 값을 대입하면 복제 생성자가 아니라 대입 연산자가 호출된다. 앞서 설명했듯이 이미 mVlaue가 초기화된 상태이기 때문이다. 앞서 이야기했듯이 이미 컴파일러가 mValue를 초기화했기 때문이다.
7.4 요약
객체가 생성, 소멸, 대입되는 라이프 사이클과 그러한 상황을 발생시키는 메서드를 기억하자.
또한, 생성자 초기화 리스트, initalzier_list 생성자 등 생성자 작성 문법을 설명하고 복제 대입 연산자 개념을 이해하자.
어떤 상황에서 컴파일러가 생성자를 자동으로 생성하는지, 인자가 없는 디폴트 생성자가 언제 불리는지를 기억하자.
'전문가를 위한 C++정리' 카테고리의 다른 글
8. 클래스와 객체 마스터하기 8.2 여러 종류의 데이터 멤버 (0) | 2024.02.05 |
---|---|
8. 클래스와 객체 마스터하기 8.1 동적 메모리 할당을 통한 객체 생성 (0) | 2024.02.05 |
6. 재사용성을 높이는 디자인 6.2 재사용성이 높은 코드를 디자인하는 방법 (0) | 2024.01.31 |
6. 재사용성을 높이는 디자인 6.1 재사용 철학 (0) | 2024.01.30 |
5. 객체를 이용한 디자인 5.5 추상화 (0) | 2024.01.30 |