💬 누가 만들어 놓은 클래스에 API만 추가한다면 크게 고려할 일이 없겠지만 언젠가는 새로운 클래스를 만들고 기존 클래스를 상속해서 파생 클래스를 만드는 때가 올 것이다. 파생 클래스를 만들고 가장 먼저 작업하게 되는 것은 초기화이다. 이 초기화를 할 때 생성자 호출 순서를 잘 이해하고 있어야 올바른 설계를 할 수가 있다. 몇 가지 응용사례를 통해서 생성자 호출 순서를 알아보자.
⚠ 본 글을 기본적인 생성자 초기화는 알고 있다는 가정하에 작성되었다. 혹시 내용이 어렵다면 아래 내용을 먼저 이해하고 다시 읽어주길 바란다.
- 2024.04.17 - [L C++] - (기초) 생성자(constructors) 초기화
🎯 Quiz : 당신에게는 아래는 stream 클래스 이용해서 pipeStream 파생클래스를 만들어야 하는 상황이 되었다. 어떻게 구현을 할 것인가?
#include <iostream>
class Buffer
{
char* buff;
public:
Buffer(int size) : buff(new char[size])
{
std::cout << "버퍼 크기 " << size << "로 초기화" << std::endl;
}
~Buffer() { delete[] buff; }
void useBuffer() { std::cout << "버퍼 사용" << std::endl; }
};
class Stream
{
public:
Stream(Buffer& buf) { buf.useBuffer(); }
};
int main()
{
Buffer buff(1024);
Stream stream(buff);
}
Buffer buff(1024);
Stream stream(buff);
Buffer와 Steam은 디폴트 생성자가 없어서 객체 생성 시 이와 같이 초기화를 해야만 한다. 그래서 PipeStream 클래스를 만들 때 사전에 알고 있어야 하는 두 가지는 다음과 같다.
- 기반클래스와 멤버변수 간의 생성자 호출 순서의 이해
- 기본 생성자가 없는 타입이 멤버 데이터인 경우 조치방법
하나하나 알아보자.
기반클래스와 멤버변수 간의 생성자 호출 순서의 이해
PipeStream을 만들려면 Steam을 상속받아야 할 텐데 파생클래스에서 생성자가 호출될 때 기반클래스의 생성자가 우선 호출이 된다. 그렇다면 기반클래스와 멤버변수 생성자는 어떤 순으로 호출이 될까? 🤔
아래 코드는 멤버변수와 기반 클래스의 생성자 호출 순서를 확인해 볼 수 있는 예제이다.
💬 결과는 아래에 있는데 우선 결과를 예측해 본 뒤 결과를 확인해 보자.
예제
멤버변수와 기반 클래스의 생성자 호출 순서를 확인해 볼 수 있는 프로그램 코드
#include <iostream>
struct BM // Base Member 라는 의미
{
BM() { std::cout << "BM()" << std::endl; }
~BM() { std::cout << "~BM()" << std::endl; }
};
struct DM // Derived Member
{
DM() { std::cout << "DM()" << std::endl; }
~DM() { std::cout << "~DM()" << std::endl; }
};
struct Base
{
BM bm;
Base()
{
std::cout << "Base()" << std::endl;
}
~Base()
{
std::cout << "~Base()" << std::endl;
}
};
struct Derived : public Base
{
DM dm;
Derived()
{
std::cout << "Derived()" << std::endl;
}
~Derived()
{
std::cout << "~Derived()" << std::endl;
}
};
int main()
{
Derived d;
}
결과
BM()
Base()
DM()
Derived()
~Derived()
~DM()
~Base()
~BM()
이해를 하고 있다면 넘어가고, 안된다면 아래 컴파일러가 변경한 코드를 주석으로 표기하였는데, 코드를 보며 호출 순서를 이해해 보자.
예제
컴파일러가 변경한 코드 추가
#include <iostream>
struct BM // Base Member 라는 의미
{
BM() { std::cout << "BM()" << std::endl; }
~BM() { std::cout << "~BM()" << std::endl; }
};
struct DM // Derived Member
{
DM() { std::cout << "DM()" << std::endl; }
~DM() { std::cout << "~DM()" << std::endl; }
};
struct Base
{
BM bm;
Base() // Base() : bm() //컴파일러가 변경한 코드
{
std::cout << "Base()" << std::endl;
}
~Base()
{
std::cout << "~Base()" << std::endl;
// BM::~BM()
}
};
struct Derived : public Base
{
DM dm;
Derived() // Derived() : Base(), dm() //컴파일러가 변경한 코드
{
std::cout << "Derived()" << std::endl;
}
~Derived()
{
std::cout << "~Derived()" << std::endl;
// DM::~DM()
// Base::~Base()
}
};
int main()
{
Derived d; // Derived d = Derived::Derived()
}
📌 위 코드에서 확인할 수 있지만 컴파일러는 기반클래스의 기본 생성자를 우선 초기화하고, 멤버변수를 초기화시킨다. 정리하면 기반 클래스 생성자 -> 멤버데이터 (선언된 순서로) 생성자 자 순으로 초기화된다.
그럼, 디폴트 생성자가 없는 멤버 데이터인 경우에는 컴파일러가 어떻게 하는지 알아보자.
기본 생성자가 없는 타입이 멤버 데이터인 경우 조치방법
우선 디폴트 생성자가 없는 데이터를 생성하려면 어떻게 될까? 🤔
아래 코드는 디폴트 생성자가 없는 Point를 객체 pt로 생성하는 프로그램 코드이다. 결과가 어떻게 되는지 확인해 보자.
#include <iostream>
class Point
{
int x, y;
public:
Point(int a, int b) : x(a), y(b) { }
};
int main()
{
Point pt;
}
결과
<source>: In function 'int main()':
<source>:37:11: error: no matching function for call to 'Point::Point()'
37 | Point pt;
| ^~
<source>:9:9: note: candidate: 'Point::Point(int, int)'
9 | Point(int a, int b) : x(a), y(b) { std::cout << "Point(int, int)" << std::endl; }
| ^~~~~
<source>:9:9: note: candidate expects 2 arguments, 0 provided
<source>:3:7: note: candidate: 'constexpr Point::Point(const Point&)'
3 | class Point
| ^~~~~
<source>:3:7: note: candidate expects 1 argument, 0 provided
<source>:3:7: note: candidate: 'constexpr Point::Point(Point&&)'
<source>:3:7: note: candidate expects 1 argument, 0 provided
에러가 난다. Point pt; 는 다르게 표현하면 Point pt = Point::Point(); 코드와 같다. 즉 컴파일러가 디폴트 생성자를 찾는데 정의된 곳이 없어서 에러를 발생시킨 것이다.
이 문제를 해결하려면 2가지 방법이 있다.
1. 정의되어 있는 생성자 형태로 객체를 생성해야 한다.
Point pt(0,0);
2. 디폴트 생성자를 직접 추가해야 한다.
class Point
{
int x, y;
public:
Point(int a, int b) : x(a), y(b) { }
Point() {} // 코드 추가
3. 컴파일러가 디폴트 생성자를 추가하게 시킨다.
아래와 같이 하면 컴파일러가 기본생성자를 자동으로 생성한다. (default에 대해서는 따로 정리하겠다. 지금은 '이렇게 하는 방법도 있구나' 하고 넘어가자.)
class Point
{
int x, y;
public:
Point(int a, int b) : x(a), y(b) { }
//Point() {}
Point() = default;
Point를 멤버변수로 가진 아래 코드는 어떻게 될까? 🤔
#include <iostream>
class Point
{
int x, y;
public:
Point(int a, int b) : x(a), y(b) {}
};
class Rect
{
Point ptFrom;
Point ptTo;
public:
Rect();
};
int main()
{
Rect r;
}
마찬가지로 에러가 발생한다. 위코드는 컴파일러가 Rect() 생성자 호출 시점에 멤버변수 초기화 리스트를 아래처럼 자동으로 생성하다 보니 디폴트 생성자를 가지고 있지 않은 Point에서 에러가 발생하게 된다.
Rect() : ptFrom(), ptTo()
(앞서 해결한 방법대로) 아래와 같이 명시적으로 초기화 리스트를 만들어 줘야 한다.
#include <iostream>
class Point
{
int x, y;
public:
Point(int a, int b) : x(a), y(b) {}
};
class Rect
{
Point ptFrom;
Point ptTo;
public:
//Rect() {}
Rect() : ptFrom(0,0), ptTo(0,0){} //초기화리스트 사용
};
int main()
{
Rect r;
}
💬 혹시나 Point에 기본생성자를 만들어주면 될 텐데 왜 이렇게 초기화 리스트를 만들어서 넘겨주지?🤔 란 생각을 할 수도 있다. 맞다 그렇게 해도 된다. 어떻게 수정할지는 자신의 몫이기에 위 가이드가 정답은 아니지만 경험적으로 내가 만들지 않은 클래스는 얼마나 어디서 사용하고 있는지 잘 모드는 상태에서 그 클래스를 수정하면서 발생할 수 있는 영향도까지는 고려하고 싶지 않기에 보통은 자신이 작성한 코드(Rect)에서 수정을 한다.
📌 참고 C++11부터는 초기화 리스트 {}를 지원하기에 위 코드 대신 아래와 같이 사용할 수 있다.
class Rect
{
//Rect() : ptFrom(0,0), ptTo(0,0){}
Point ptFrom{ 0,0 };
Point ptTo{ 0,0 };
};
그럼 아래처럼 할 수도 있는 건 아니냐?🤔 라고 생각할 수 있는데
class Rect
{
Point ptFrom( 0,0 );
Point ptTo( 0,0 );
};
결론부터 얘기하면 이 위치에 ()는 에러가 발생된다. 왜 안되는지는 에러메시지를 보면 이해가 되는데,
error: expected identifier before numeric constant
컴파일러는 Point ptFrom( 0,0 ); 코드를 초기화하는 것으로 인식하는 것이 아니라 Point를 리턴하는 ptFrom 명의 함수 선언으로 인식하기에 변수 대신 숫자 '0'을 사용했다고 에러를 발생시킨 것이다.
📜 C++11 이후부터는 선언 시 초기화 리스트 {}를 사용하여 인자를 전달할 수 있다.
자 이제 사전 배경 지식이 준비가 되었다. 그럼 아래 stream 클래스 이용해서 파생클래스 pipeStream를 만들어보자.
//Buffer buff(1024);
//Stream stream(buff);
PipeStream pstream;
main에 아래 두 코드를 파생클래스 안에 구현을 하면 아래와 같이 작성될 수 있다.
#include <iostream>
class Buffer
{
char* buff;
public:
Buffer(int size) : buff(new char[size])
{
std::cout << "버퍼 크기 " << size << "로 초기화" << std::endl;
}
~Buffer() { delete[] buff; }
void useBuffer() { std::cout << "버퍼 사용" << std::endl; }
};
class Stream
{
public:
Stream(Buffer& buf) { buf.useBuffer(); }
};
class PipeStream : public Stream
{
Buffer buff{1024};
public:
// 아래 코드는 컴파일러가 PipeStream() : Stream() 로 변경하는데
//PipeStream(){}
// Steam이 디폴드 생성자가 없어서 에러가 발생하기에
PipeStream() : Stream(buff) {} // 이와 같이 수정을 할 수 있다.
};
int main()
{
//Buffer buff(1024);
//Stream stream(buff);
PipeStream pstream;
}
이미 눈치챈 사람도 있겠지만 컴파일러에 의해 아래와 같이 코드가 조정된다.
Buffer buff{1024};
public:
PipeStream() : Stream(buff) {} //이와 같이 수정을 할 수 있다.
//------------------------------------------------------------------
//위 코드 들은 컴파일러에 의해 아래와 같이 조정이 된다.
PipeStream() : Stream(buff), buff(1024)
여기서 발생하는 문제는 buff가 우선 초기화되어야 하는데 우선순위에 밀려서 Stream 생성자가 먼저 호출되어 Steam 생성자에서는 초기화되지 않은 버퍼가 사용되었다. (기반 클래스 생성자를 호출할 때, 파생 클래스 멤버 데이터를 보내지 말 것!)
그럼 어떻게 이 문제를 해결할 수 있을까? 🤔
이럴 땐 buffer를 초기화하는 클래스를 기반클래스로 만들어 우선 호출되게 정리하는 방법이 있다.
class PipeBuffer
{
protected:
Buffer buff{ 1024 };
};
class PipeStream : public PipeBuffer, public Stream
{
public:
PipeStream() : Stream(buff) {}
};
int main()
{
PipeStream pstream;
}
class PipeStream : public PipeBuffer, public Stream
기반 클래스끼리는 상속 순서대로 우선순위를 가지기에 다중 상속으로 우선 호출되게 하여 buff를 먼저 초기화시킬 수 있다.
PipeStream() : Stream(buff) {}
//컴파일러가 변경하는 코드
PipeStream() : PipeBuffer(), Stream(buff) {}
생성자 안에서 가상함수가 있으면 어떻게 될까? 🤔
생성자 안에서 가상함수를 호출하는 예제를 실행하면 다음과 같은 결과가 나온다.
#include <iostream>
struct Base
{
Base() { init(); }
void foo() { init(); };
virtual void init() { std::cout << "Base::init" << std::endl; } // 1
};
struct Derived : public Base
{
int x;
Derived() : x(10) {}
virtual void init() { std::cout << "Derived::init : " << x << std::endl; } // 2
};
int main()
{
Derived d;
d.foo();
}
결과
Base::init
Derived::init : 10
기반 클래스의 생성자 내부에서 가상함수를 호출했지만 파생클래스의 가상함수가 호출되지 않는다.
Base() { init(); }
하지만 일반함수에서 가상함수를 호출하면 파생클래스의 가상함수가 호출이 된다.
void foo() { init(); };
💡 우선 반대로 생각해 보자.
파생클래스에 init() 함수는 멤버변수 x 값을 출력하는데, 기반 클래스의 생성자 시점이기에 선언하지 않은 멤버변수는 호출할 수 없다. 그래서 이 때는 기반 클래스의 가상함수가 호출된다.
virtual void init() { std::cout << "Derived::init : " << x << std::endl; }
📜 좀 더 개념적으로 접근하면 가상 함수는 객체 생성 시 가상 함수를 실행할 주소를 담은 가상 함수 테이블(vtable)을 만드는데 이는 생성자가 호출되는 시점에는 vtable이 완전히 구성되지 않은 상태라 호출되지 않는다.
📝 그래서 생성자에서 가상함수를 호출하고 싶다면, 실제로 생성자에서 호출하지 말고 생성 후 호출하면 된다.
아래 d.foo()처럼..
struct Base
{
void foo() { init(); };
virtual void init() { std::cout << "Base::init" << std::endl; } // 1
};
struct Derived : public Base
{
int x;
virtual void init() { std::cout << "Derived::init : " << x << std::endl; } // 2
};
int main()
{
Derived d;
d.foo();
}
📜 그럼에도 초기화 과정에서 가상함수를 호출하고 싶다면 C++핵심 가이드라인에서는 아래와 같이 가이드하고 있다.
C.50: 초기화 과정에서 virtual 동작이 필요하다면, 팩토리 함수를 사용하라.
(C.50 : Use a factory fuction if you need "virtual behavior" during initialization)
💬 팩토리 함수를 사용하라는 의미도 결국, 실제로 생성자에서 호출하지 말고 생성 후 호출하라는 의미이다.
📌 참고로 아래 코드는 C++ Core Guidelines에 예시이며,
class B {
protected:
class Token {};
public:
explicit B(Token) { /* ... */ } // create an imperfectly initialized object
virtual void f() = 0;
template<class T>
static shared_ptr<T> create() // interface for creating shared objects
{
auto p = make_shared<T>(typename T::Token{});
p->post_initialize();
return p;
}
protected:
virtual void post_initialize() // called right after construction
{ /* ... */ f(); /* ... */ } // GOOD: virtual dispatch is safe
};
class D : public B { // some derived class
protected:
class Token {};
public:
explicit D(Token) : B{ B::Token{} } {}
void f() override { /* ... */ };
protected:
template<class T>
friend shared_ptr<T> B::create();
};
shared_ptr<D> p = D::create<D>(); // creating a D object
복잡해 보이지만 아래 부분만 떼어서 설명하면 (초기화 과정에서 가상함수를 호출하고 싶다면) 생성자에서 호출하지 말고 실제 D 클래스 객체를 팩토리 함수(create)에서 생성하고 가상함수를 호출하면 된다고 얘기하고 있다.
virtual void f() = 0;
template<class T>
static std::shared_ptr<T> create() //팩토리 함수
{
auto p = std::make_shared<T>(typename T::Token{});
p->post_initialize();
return p;
}
protected:
virtual void post_initialize() // called right after construction
{ f(); }
std::shared_ptr<D> p = D::create<D>(); // creating a D object
참고
* 본 글에 예제 코드는 C++ Core Guidelines와 코드누리 샘플코드를 활용하였습니다.
* 팩토리모드 : https://refactoring.guru/design-patterns/factory-method
C++ 핵심 가이드라인 한글화 프로젝트 링크
C++ Core Guidelines 원문 링크

'L C++' 카테고리의 다른 글
애증의 const 사용하기 (2) (49) | 2024.05.26 |
---|---|
애증의 const 사용하기 (1) (0) | 2024.05.11 |
(기초) 생성자(constructors) 초기화 (1) | 2024.04.17 |
std::move the 이해하기 (40) | 2024.04.12 |
perfect forwarding을 perfect하게 이해하기 (14) | 2024.03.29 |