SW 그리고 아빠 엔지니어링 중...

아는 만큼 보이고, 경험해봐야 알 수 있고, 자꾸 써야 내 것이 된다.

L C++

(응용) 생성자(constructors) 초기화

보리남편 김 주부 2024. 4. 21. 11:12
💬 누가 만들어 놓은 클래스에 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 원문 링크

 

728x90

'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