일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
- UE5
- UnrealEngine5
- Unreal Engine5
- C
- 백준
- directx
- DirectX11
- IFileDialog
- RootMotion
- GeeksForGeeks
- softeer
- NRVO
- algorithm
- 오블완
- winapi
- const
- RVO
- 팰린드롬 만들기
- baekjoon
- 프로그래머스
- UnrealEngine4
- 줄 세우기
- Frustum
- C++
- 1563
- TObjectPtr
- 언리얼엔진5
- 2294
- Programmers
- 티스토리챌린지
- Today
- Total
Game Develop
객체를 사용하기 전에 반드시 그 객체를 초기화하자. 본문
제목을 다시 바꿔말하면..
멤버 초기화 리스트를 사용해 멤버변수들을 초기화하자.
C++규칙에 의하면 어떤 객체이든 그 객체의데이터 멤버는 '생성자의 본문'이 실행되기 전에 초기화되어야 한다고 명시되어있다.
Foo::Foo(const string& name, const int count)
{
this->m_Name = name;
this->m_Count = count;
}
위 코드에서 멤버변수들은 초기화되었는가?
아니다. '생성자의 본문'이 실행되기 전에 초기화 되어야 한다고 되어있는데, 위 코드는 본문에서 멤버변수에 '대입'을 했을 뿐이다.
그리고 좀 더 정확히는 저 대입연산을 하기전에 멤버변수들의 기본생성자들이 호출되어서 값을 초기화했을 것이다.
근데 m_Count의 타입에 해당하는 int형같은 기본제공 타입의 데이터일 경우, 대입되기 전에 초기화 되리란 보장이 없다고 한다.
어떻게 보면 사소하지만, 그래도 이런 불확실성을 없애는 과정을 미리미리 탄탄하게 쌓아놓는게 나중의 큰 일을 막는데에 도움이 될 것이다.
그러기위해서 위의 코드처럼 생성자본문에 대입연산을 하는것보다는 그 전부터 미리 초기화를 해야하고, 그러기 위해
' 멤버 초기화 리스트 (Member Initializer List) ' 를 사용한다.
Foo(const string& name, const int count) : m_Name(m_Name), m_Count(count)
{
// 멤버 초기화 리스트를 이용해 멤버변수들을 초기화.
}
첫번째 코드처럼 대입연산으로 멤버변수들을 초기화한다면, 결국 멤버변수들의 기본생성자를 호출한다음에 거기에 더해서 대입연산자를 수행하는것이다.
하지만 멤버 초기화 리스트를 사용하면 기본생성자호출하는 선에서 끝난다.
둘 다 멤버변수를 초기화한다는 결과는 똑같지만, 전자의 방법은 대입연산자를 굳이 한번 더 호출하는 것 뿐이고, 효율적인 측면에서 차이가 있다.
매개변수가 없는 기본생성자같은경우에도 멤버초기화리스트를 사용해서 멤버변수를 다 초기화해두자.
특히나 상수(const)이거나 참조자(&)인 멤버변수의 경우엔 멤버 초기화 리스트를 통해서만 초기화가 가능하다.
이 두 타입은 대입연산 자체가 불가능하기 때문이다.
그리고 컴파일러를 막론하고, 아래의 순서는 동일하다.
1. 부모클래스가 자식클래스보다 먼저 초기화된다.
2. 멤버변수는 선언된 순서대로 초기화된다.
즉, 멤버변수의 선언순서와 멤버초기화리스트에서 초기화순서가 만약 다르더라도, 멤버변수의 선언순서를 따라간다는 의미이다.
그렇기때문에 반드시 멤버변수의 선언순서와 동일한 순서로 초기화를 해야한다.
이것 또한 의도치 않은 결과를 낳을 수 있기 때문이다.
class Foo
{
public:
Foo() : y(0), x(y) {}
private:
int x;
int y;
};
int main() {
Foo f;
cout << f.x << endl; // 쓰레기값 출력.
}
이 코드를 보면 멤버변수가 선언된 순서는 x, y 이지만 생성자에서 멤버변수가 초기화된 순서는 y, x이다.
하지만 멤버 초기화리스트는 선언 순서를 따라간다고 했기 때문에, x를 먼저 y로 초기화 후, y를 0으로 초기화 한다.
근데 x를 먼저 y로 초기화 할 때 y는 아직 초기화가 안된 상태, 즉 쓰레기값이 들어가 있기때문에 이 값으로 x를 초기화한다.
그렇기 때문에 메인함수에서 x를 출력하면 쓰레기값이 나오게 된다.
비지역 정적 객체의 초기화 순서는 개별 번역 단위(Transition Unit) 에서 정해진다.
일단 단어들의 뜻풀이부터 먼저 해보자.
정적 객체 : 자신이 생성된 시점부터 프로그램이 끝날 때까지 살아있는 객체.
그렇기때문에 스택이나 힙에있는 객체들은 해당사항이 안된다. 해당되는것으로는
1. 전역객체
2. 네임스페이스 유효범위에서 정의된 객체
3. 클래스 안에서 static으로 선언된 객체
4. 함수안에서 static으로 선언된 객체
5. 파일 유효범위에서 static으로 정의된 객체
여기서 함수안에 있는 정적 객체를 지역 정적 객체(Local Static Object)라고 하고, 나머지를 비지역 정적 객체(Non-Local Static Object)라고 한다.
위의 다섯가지 객체들은 프로그램이 끝나야만 소멸자를 호출한다.
여기서 번역단위 (Transition Unit)이란, 컴파일을 통해 하나의 목적파일(Object File, 기계코드로 이루어진 파일) 을 만드는 바탕이 되어지는 소스코드를 일컫는다.
그래서 기본적으로 소스코드 파일하나를 번역단위라 할 수 있는데, 해당 소스코드파일에서 #include한 파일들까지 합쳐서 하나의 번역단위라 한다.
자 이제 어떠한 상황을 예시로 들어보자.
별도로 컴파일된 소스파일이 두 개 이상이 있고, 각 소스파일에 비지역 정적 객체(전역 객체, 네임스페이스에 있는 객체, 클래스와 파일안에 있는 정적객체) 가 한 개 이상 들어있는 경우에, 이 객체들의 초기화 순서는 어떻게 될까?
정답은 '모른다' 이다. 정해져 있지 않다.
아래의 소스파일 두개를 봐보자.
class FileSystem
{
// ......
public:
int GetNumDisks() const;
//....
};
extern FileSystem tfs; // 외부사용자가 쓰게 될 객체.
class Directory
{
public:
Directory()
{
//.......
m_DiskCount = tfs.GetNumDisks();
}
private:
int m_DiskCount;
};
두 클래스는 별개의 번역단위라는거를 일단 인지하자.
그리고 Directory클래스를 정의한 쪽의 메인함수에서 Directory의 생성자를 호출해서 인스턴스를 만들었다고 가정해보자.
자 여기서 문제가 생긴다.
Directory의 생성자에서는 다른 번역단위의 tfs를 이용해서 자신의 멤버변수를 초기화했는데, 그럴려면 '반드시' tfs는 미리 초기화가 되어 있어야 한다.
근데 반드시 초기화가 되어있을까? 그렇지 않다는게 문제다..
다시 말하자면 서로 다른 번역단위에 정의된 비지역 정적 객체들 상대의 상대적인 초기화 순서는 정해져 있지 않다.
이런 불확실한 현상을 막기위해, 설계에 변화를 준다.
비지역 정적 객체를 하나씩 맡는 함수를 준비하고, 이 안에 각 객체를 넣는것이다.
즉, 함수안에 넣음으로써 지역 정적 객체가 되는 것이다.
class FileSystem
{
// ......
public:
int GetNumDisks() const;
//....
};
FileSystem& tfs() // extern 했던 tfS 객체를 이 함수로 대신한다.
{ // 클래스 안에 static 멤버함수로 들어가도 상관없다.
static FileSystem fs;
return fs;
}
class Directory
{
public:
Directory()
{
// .......
m_DiskCount = tfs().GetNumDisks();
// .......
}
private:
int m_DiskCount;
};
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
Directory dir;
}
꽤나 어디서 봤던 유형인 것 같다면, 아마 싱글톤 패턴일 것이다.
하지만 이런 유형의 코드 (비상수 정적 객체를 사용하는, 즉 값이 계속 업데이트 될 수 있는 정적객체를 사용하는)는 멀티쓰레드 환경에서 문제가 생길 수 있다.
이런 문제를 해결하기위한 방법중 하나로는, 멀티쓰레드에 돌입하기전에 저렇게 참조자를 리턴하는 함수를 전부 미리 호출해버리는 것이다.
그러면 초기화에 관계된 경쟁상태(Race Condition)을 막을 수 있다.
어쨌든 초기화 순서문제를 방지하기 위해, 위의 정적객체의 참조자를 리턴하는 함수의 형태를 사용한다는 아이디어는 결국 객체들의 초기화 순서를 제대로 맞춰뒀다는 가정하에 이루어진다.
예를들어 객체 B가 초기화되기전에 반드시 객체 A가 초기화되어야 하는데, 객체 A의 초기화가 B에 의존하도록 되어있다는식의 말도안되는 구조라면, 당장 고쳐야한다.
정리
1. 기본제공 타입의 객체는 직접 손으로 초기화 한다.
경우에 따라 저절로 되기도 하고, 안되기도 하기 때문이다.
2. 생성자에서 멤버변수에 대한 초기화는 멤버 초기화 리스트를 애용하도록 하자.
그리고 멤버 초기화 리스트를 사용할 때는 반드시 멤버변수 선언순서와 동일하게 작성하도록 하자.
3. 여러 번역단위에 있는 비지역 정적 객체들의 초기화 순서문제는 피해서 설계해야 한다.
그 방법중 하나로 싱글턴패턴같이 비지역 정적 객체를 지역 정적객체로 바꿔서 해당 객체의 참조자를 리턴하는 방식이 있다.