9 minute read

‘전문가를 위한 C++ - Marc Gregoire 지음, 남기혁 옮김’ 책을 참고하여 작성한 포스트입니다.


C++은 객체를 정의하거나 사용할 수 있도록 클래스(class)를 제공한다.

스프레드시트 예제

  • 이 장에서는 간단한 스프레드시트 예제를 이용해 개념들을 이해해 나가 본다.



클래스 작성 방법

클래스 정의

  • 클래스는 객체에 대한 동작(메서드)과 속성(데이터 멤버)으로 이루어져 있다.
  • 일반적으로 클래스 정의를 작성한 파일의 이름은 클래스의 이름과 똑같이 짓는다.

클래스 멤버(member)

  • 멤버에는 메서드, 생성자, 소멸자와 같은 멤버 함수(member function),
  • 열거형, 타입 앨리어스, 중첩 클래스 등과 같은 멤버 변수(member variable, 데이터 멤버) 가 있다.
  • static member(정적 멤버)를 제외하고는 기본적으로 멤버는 클래스의 인스턴스(Instance)객체(object)에만 적용된다.
  • 클래스는 개념을 정의하고, 객체는 실체를 표현한다!
  • 멤버 함수와 데이터 멤버의 개수는 제한이 없으나, 서로의 이름이 같으면 안된다.

접근 제어

  • 세가지 접근 제한자(access modifier, access specifier) 가 있다.
    1. public
    2. private
    3. protected
  • 클래스의 기본 접근 제한자는 private 이고, struct 는 기본 접근 제한자가 public 이다.

선언 순서

  • 멤버와 접근제한자를 선언하는 순서는 따로 없다.

클래스 내부의 멤버 초기자

  • 데이터 멤버는 클래스 정의와 동시에 초기화가 가능하다. 데이터 멤버는 항상 초기화하는 것이 좋다.
private:
    int CellValue { 0 };

메서드 정의 방법

  • 클래스 정의는 모듈 인터페이스 파일에 작성한다.
  • 메서드 정의는 모듈 인터페이스 파일에 해도 되고, 모듈 구현 파일(.cpp)에 작성해도 된다.
  • 메서드 정의(구현) 할 때는 접근 제한자를 생략해도 된다.
void SpreadsheetCell::SetValue(int Value)
{
    CellValue = Value;
}
  • ::를 스코프 지정 연산자라고 부른다.
  • 이 표현으로 메서드의 정의가 어느 클래스에 속하는 지 알 수 있다.

데이터 멤버 접근 방법

  • 메서드 본문 안에서는 자신이 속한 객체 내에 모든 데이터 멤버에 접근 가능하다.

    다른 메서드 호출하기

  • 클래스에 정의된 메서드끼리 서로 호출도 가능하다.

    this 포인터

  • 메서드를 호출하면 메서드가 속한 객체의 포인터가 숨겨진 매개변수 형태로 전달되는데, 이 포인터의 이름이 this다.
void SpreadsheetCell::SetValue(int Value)
{
    this->CellValue = Value;
}

객체 사용법

  • 크게 두 가지이다.

    스택에 생성한 객체

SpreadsheetCell MyCell, AnotherCell;
MyCell.SetValue(6);
AnotherCell.SetString("3");
  • 단순 변수를 선언하듯이 객체를 생성하면 된다. 타입이 클래스 이름일 뿐이다.
  • .을 도트(dot)연산자라고 부른다.
  • 도트 연산자로 객체의 public 데이터 멤버에 접근 가능하다.

프리스토어에 생성한 객체

  • new를 사용해 객체를 동적으로 생성할 수도 있다.
SpreadsheetCell* MyCellPtr { new SpreadsheetCell {} };
MyCellPtr->SetValue(4);
delete MyCellPtr;
MyCellPtr = nullptr;
  • 프리스토어에 생성한 객체는 화살표 연산자로 멤버에 접근한다.
  • 화살표 연산자는 역참조 연산자(*)와 멤버 접근 연산자(.)를 합친 것이다.
  • delete 해주어야 하고 싫으면 스마트포인터 쓰기.
auto MyCellSmartPtr { make_unique<SpreadsheetCell>() };
MyCellSmartPtr->SetValue(7);
// 직접 해제 안해도 된다.



객체의 라이프 사이클

  • 생성(creation), 소멸(destruction), 대입(assignment)의 세 단계로 구성된다.

    객체 생성

  • 스택에 생성될 객체는 선언되는 시점에 생성되고,
  • 동적으로 생성하는 경우에는 직접 공간을 할당해서 생성한다.
  • 객체 생성시 객체에 포함된 객체도 함께 생성된다. 소멸될 때도 함께 소멸한다.
  • 생성자(constructor, ctor)를 통해 객체 선언과 동시에 초깃값 설정이 가능하다.

생성자 작성 방법

  • 클래스 이름과 똑같고, 리턴 타입이 없다.
  • 매개변수가 없는 경우 디폴트 생성자(default constructor)라고 부른다.
  • 생성자도 클래스 멤버다.

생성자 사용법

스택 객체 생성자

SpreadsheetCell MyCell(5), AnotherCell { 4 };
cout << MyCell.GetValue() << endl;
  • 생성자를 직접 호출하는 것은 안된다.
  • 선언문과 분리해도 안된다.
SpreadsheetCell MyCell;
MyCell.SpreadsheetCell(5); // error!!

프리스토어 객체 생성자

// 스마트 포인터
auto SmartCellPtr { make_unique<SpreadsheetCell>(4)};

// 일반 포인터
SpreadsheetCell* CellPtr1 { new SpreadsheetCell { 5 }};
// 혹은 다음과 같이
SpreadsheetCell* CellPtr2 { nullptr };
Cellptr2 = new SpreadsheetCell { 4 };

delete CellPtr1;
delete CellPtr2;
CellPtr1 = nullptr;
CellPtr2 = nullptr;
  • 포인터는 반드시 적절한 값이나 nullptr로 초기화 해주어야 한다!

생성자 여러 개 제공하기

  • 인수의 개수나 타입을 다르게 하는 오버로딩(overloading)을 통해 한 클래스에 여러 생성자를 만들 수 있다.(오버로딩 자세한 내용은 9장)
  • 같은 클래스에서 생성자끼리 호출하려면 위임 생성자(delegating constructor)를 이용해야 한다.
  • 그러지 않으면 내부적으로 이름 없는 임시 객체가 생서오디어 원래 초기화하려는 객체의 생성자가 호출되지 않아 의도한 바로 실행되지 않는다.!(내용 추가)

디폴트 생성자(default constructor)

  • 영인수 생성자(0-argument constructor, 제로 인수 생성자)라고도 불린다.

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

  • 객체 배열이 생성될 때 객체 모두를 담을 충분한 공간을 메모리에 먼저 할당하고, 그러고 나서 각 객체들의 디폴트 생성자를 호출한다.
  • 디폴트 생성자를 정의하지 않으면 객체 배열 생성 시 배열내 모든 원소 객체들을 다음과 같이 초기화 해주어야 한다.
SpreadsheetCell Cells[2] { SpreadsheetCell {3}, SpreadsheetCell {1} };
  • 하나라도 빼먹으면 에러가 발생한다.
  • 디폴트 생성자를 직접 정의하지 않으면 컴파일러가 대신 만들어 준다.

디폴트 생성자 작성 방법

public:
  SpreadsheetCell();

SpreadsheetCell::SpreadsheetCell()
{
    CellValue = 0;
}
  • 스택 객체의 디폴트 생성자 호출 방법은 다음과 같다.
  • 다른 생성자들과는 달리 디폴트 생성자는 함수 호출 형식을 따르지 않는다!
SpreadsheetCell MyCell;
MyCell.SetValue(6);
// 혹은 이렇게 해도 된다.
SpreadsheetCell MyCell {};

// 이런 실수는 하면 안된다.
SpreadsheetCell MyCell(); // 컴파일 에러는 발생하지 않는다.
MyCell.SetValue(7); // 이 문장에서 컴파일 에러 발생!
  • 위 같이 다른 엉뚱한 곳에서 에러가 발생하는 유형을 가장 짜증나는 파싱(most vexing parse) 에러 라고 부른다.
  • 컴파일 에러가 발생하지 않은 첫 문장을 컴파일러는 인수를 받지 않고 리턴 타입이 SpreadsheetCell인 MyCell이란 함수를 선언한다고 본 것이다!!

  • 프리스토어 객체의 디폴트 생성자는..
    auto SmartCellPtr { make_unique<SpreadsheetCell>() };
    // 일반 포인터로도 가능한데 일단 생략 ㅎ
    

    컴파일러에서 생성한 디폴트 생성자

  • 생성자를 하나도 지정하지 않아야 컴파일러가 디폴트 생성자를 대신 만들어 준다!
  • 컴파일러에서 생성한 디폴트 생성자(compiler-generated default constructor)는 클래스 내의 객체 멤버에 대해서도 디폴트 생성자를 호출해준다. 물론 int나 double 같은 기본 타입은 초기화 안한다.

명시적 디폴트 생성자(explicitly defaulted constructor)

public:
	SpreadsheetCell() = default;
	SpreadsheetCell(int InitialValue);
  • default 키워드를 이용해 생성자를 정의하면, 다른 생성자를 정의해두었어라도, 컴파일러가 디폴트 생성자를 자동으로 생성한다.
  • default구문은 클래스 정의 코드에 적어도 되고, 구현 파일에 적어도 된다.

명시적으로 삭제된 생성자(explicitly deleted constructor)

  • 정적(static) 메서드로만 구성된 클래스는 생성자 코드를 작성할 필요가 없고, 컴파일러가 자동으로 디폴트 생성자를 만들게 두면 안된다. 이럴 때 사용한다.
    public:
      SpreadsheetCell() = delete;
    
  • 클래스 내에 디폴트 생성자를 삭제한 데이터 멤버가 있다면 그 클래스의 디폴트 생성자도 자동으로 삭제된다.

생성자 초기자(constructor initializer)

  • ctor-initializer 또는 멤버 초기자 리스트(member initializer list) 라고도 부른다.
SpreadsheetCell::SpreadsheetCell(int InitialValue)
	: CellValue { Initialvalue } // 소괄호 가능
{

}
  • 생성자 초기자로 데이터 멤버를 초기화하는 방식은 생성자 안에서 데이터 멤버를 초기화할 때랑은 다르다!

c++에서 객체를 생성하기 위해서는 그 객체를 구성하는 데이터 멤버를 모두 생성하고 나서 생성자를 호출한다.
즉, 생성자 안에서 값을 할당하는 시점에는 이미 생성된 상태에서 값만 바꾸는 것이다.
하지만, 생성자 초기자를 이용하면 데이터 멤버를 생성하는 과정에서 초깃값을 설정할 수 있어 훨씬 효율적이다.

  • 클래스 내에 디폴트 생성자가 정의되지 않은 데이터 멤버는 생성자 초기자로 적절히 초기화 해주어야 한다!
    ```cpp class SomeClass { public: SomeClass(); private: SpreadsheetCell MyCell; };

// SpreadsheetCell에 디폴트 생성자가 정의되지 않은 경우에 SomeClass::SomeClass() {} // 에러 발생! MyCell을 어떻게 초기화해야 할지 모름.. // 이렇게 해주어야 한다. SomeClass::SomeClass() : MyCell { 5 } { }

- 위 같은 경우 뿐 아니라 
	1. const 데이터 멤버
	2. 레퍼런스 데이터 멤버
	3. 디폴트 생성자가 없는 베이스 클래스 
- 들은 생성자 초기자나 클래스 내부 초기자로 초기화해주어야 한다.!
- 그리고 클래스 내 나열된 데이터 멤버가 초기화되는 순서는 생성자 초기자에 나온 순서가 아닌! <u>클래스 정의에 나온 순서를 따른다.</u>

### 복제 생성자(copy constructor)
- 다른 객체와 똑같은 객체를 생성하는 특수 생성자이다.
- 원본 객체에 대한 const 레퍼런스를 인수로 받고, 원본 객체에 있는 데이터 멤버를 모두 복사한다.  
```cpp
public:
	SpreadsheetCell(const SpreadsheetCell& Src);
  • 복제 생성자도 직접 정의하지 않으면 컴파일러가 대신 만들어 준다.
  • 보통 컴파일러가 만들어 준 복제 생성자로 충분하므로 직접 작성할 필요 없다.

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

  • C++ 에서 함수에 인수를 전달할 때는 기본적으로 값 전달 방식(pass-by-value)을 따른다.
  • 값 전달 방식은 해당 객체의 복사본을 인수로 전달하는 방식이다.
  • 이 과정에서 객체의 복제 생성자가 호출되겠죠.
  • 복제 생성자 매개변수는 const 레퍼런스로 해주어 복제 오버헤드를 줄이는 것이 좋다.
  • 객체를 값으로 리턴할 때도 복제 생성자가 호출된다.

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

    SpreadsheetCell Cell1 { 4 };
    SpreadsheetCell Cell2 { Cell1 };
    

    레퍼런스(참조) 방식으로 객체 전달하기

  • 레퍼런스 전달 방식(pass-by-reference)을 사용하면 복제 연산으로 인한 오버헤드를 줄일 수 있다.
  • 그리고 객체의 동적 메모리 할당에 관련된 문제도 피할 수 있다.(9장)
  • 이 경우 원본 객체가 변경될 위험이 있으니 const를 붙여주면 좋다.
  • 복제오버헤드가 적은 경우나, int 같은 기본 타입은 차이가 거의 없으므로 값 전달 방식을 사용하면 된다.

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

SpreadsheetCell(const SpreadsheetCell& Src) = default;
//
SpreadsheetCell(const SpreadsheetCell& Src) = delete;

초기자 리스트 생성자(initializer list constructor)

  • std::initializer_list<T>를 첫 번째 매개변수로 받고, 다른 매개변수는 없거나 디폴트값을 가진 매개변수를 추가로 받는 생성자
public:
	SpreadsheetCell(initializer_list<int> args)
	{
		IntContainer.reserve(args.size());
		for(const auto& Value : args)
		{
			IntContainer.push_back(Value);
		}
	}
private:
	vector<int> IntContainer;
  • 위 코드에서 IntContainer.assign(args);만으로 생성자 내 코드 전부 대체 가능하다.

위임 생성자(delegating constructor)

  • 위임 생성자를 통해 같은 클래스내 생성자끼리 서로 호출이 가능하다.
  • 그러더라도 생성자 본문 안에서 다른 생성자를 직접 호출할 수는 없다.
  • 반드시 생성자 초기자에서 호출해야 한다.
SpreadsheetCell::SpreadsheetCell(int InitialValue)
	: SpreadsheetCell { double InitialValue }
{

}
  • int 타입 생성자가 호출되면 double 생성자에 위임하게 된다.
  • 대상 생성자 리턴 후 위임 생성자가 실행된다.
  • 서로가 서로를 위임해 순환 구조가 생기면 안된다!

명시적 생성자로 변환하기

  • 생성자는 기본적으로 변환 생성자(converting constructor)가 되어 컴파일러가 암묵적 변환을 수행하는 데 사용 한다.
SpreadsheetCell MyCell { 4 };
MyCell = 5; // 5(int)가 SpreadsheetCell으로 변환된다.
  • 암묵적으로 변하길 원치 않는 경우 explicit 키워드를 사용하면 된다.
public:
	explicit SpreadsheetCell(int InitialValue);
...

SpreadsheetCell MyCell { 4 };
MyCell = 5; // 컴파일 에러 발생
  • 암묵적 변환이 필요한 경우가 아니면 생성자는 가능하면 explicit으로 지정하는 것이 좋다.


객체 제거

  • 객체가 제거되는 과정은 두 단계로 이루어진다.
    1. 먼저 객체의 소멸자(destructor) 호출
    2. 할당 받은 메모리 반환
  • 소멸자를 직접 정의하지 않으면 컴파일러가 만들어준다.
  • 클래스와 같은 이름 앞에 틸드(~)를 붙여준다.
  • 스택 객체는 현재 실행하던 함수 또는 코드 블록이 끝나는 등 스코프(유효 범위)를 벗어날 때 자동으로 제거된다.(중괄호를 벗어나면)
  • 일반 포인터를 사용한 프리스토어 객체는 자동으로 제거 되지 않아, 객체 포인터에 대해 delete를 명시적으로 호출해서 객체의 소멸자를 호출하고 메모리를 해제해 주어야 한다.


객체에 대입하기

  • 객체의 복제(copy)는 객체를 초기화할 때 이루어지고, 대입(assign)은 이미 값이 할당된 객체를 덮어쓸 때 일어난다.
  • 보통 = 연산자를 오버로딩하여 대입 연산자(assignment operator)로 활용한다.
  • 이 경우 오른쪽 객체가 대입 후에도 남아 있으므로 복제 대입 연산자(copy assignment operator) 라고도 부른다.
  • 이와 반대인 이동 대입 연산자(move assignment operator)는 대입 후 오른쪽 객체가 삭제되며, 자세한 내용은 9장에서!

대입 연산자 선언 방법

public:
	SpreadsheetCell& operator=(const SpreadsheetCell& rhs); // right-hand side
  • 리턴 값이 객체에 대한 리퍼런스 이므로, Cell1 = Cell2 = Cell3 도 가능하다.

대입 연산자 정의 방법

  • 클래스에 동적으로 할당한 메모리나 다른 리소스가 있다면 자기 자신을 대입하는 작업이 쉽지 않으므로 (자세한 내용은 9장)
  • 대입 연산자 오버로딩할 때 대입되는 대상이 같은 클래스인지 확인하는 과정을 넣어주면 좋다.
  • 모든 데이터 멤버에 대해 대입 작업만 하는 것은 컴파일러가 생성해주는 것으로 충분하다.
SpreadsheetCell& SpreadsheetCell::operator=(const SpreadsheetCell& rhs)
{
	if(this == &rhs) // 자기 자신을 대입하는 경우
	{
		return *this;
	}
	else
	{
		CellValue = rhs.CellValue;
		return *this;
	}
}

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

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


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

  • 복제 생성자나 복제 대입 연산자를 직접 선언하면 컴파일러가 자동으로 생성하지 않는다.
  • 그럼 명시적으로 default 로 지정해주면 됨.


복제와 대입 구분하기

  • 선언에 가까우면 복제 생성자를 사용하고, 대입에 가까우면 대입 연산자로 처리하면 된다.

    리턴값이 객체인 경우

SpreadsheetCell Cell1 { 5 };
string S1;
S1 = MyCell1.GetString();

string S2 = MyCell1.GetString();
  • GetString()이 스트링을 리턴할 때 컴파일러는 string의 복제 생성자를 호출해 임시 string 객체를 생성하고,
  • s1에 대입할 때 대입 연산자가 호출되며, 임시 string 객체를 = 연산자의 매개변수로 전달한다.
  • 이후 임시 string 객체는 삭제된다.
  • 컴파일러에 따라 복제 생성자의 오버헤드가 큰 경우 복제 생략(copy elision)을 적용해 최적화하기도 한다.
  • s2에서는 대입 연산자가 아닌 복제 생성자가 호출된다.

복제 생성자와 객체 멤버

  • 객체 안에 다른 객체가 담겨 잇다면 컴파일러에서 만들어준 복제 생성자는 다른 객체의 복제 생성자를 재귀적으로 호출한다.
  • 이때 생성자 초기자에서 데이터 멤버를 생략했다면 컴파일러가 디폴트 생성자를 호출해 초기화한다.
  • 그래서 생성자 본문 실행할 시점에는 데이터 멤버 모두 초기화된 상태가 된다.
  • 데이터 멤버가 이미 초기화된 상태에서는 대입 연산자를 통해 값이 대입된다.
  • 즉, 복제 생성자 본문 안에서는 대입 연산자를 통해 값이 대입되고, 복제 생성자 초기자에서는 복제 생성자를 통해 초기화된다.
SpreadsheetCell::SpreadsheetCell(const SpreadsheetCell& src)
{
	CellValue = src.CellValue;
}

//

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

Leave a comment