생성자 내에서는 절대로 가상 함수를 호출하지 말라
객체가 완전히 생성되기 이전에 가상 함수를 호출하면 이상 동작을 일으킨다. 어떤 타입이든 생성자가 수행을 완료할 때까지는 객체가 완전히 생성되었다고 할 수 없다. 따라서 생성자 내에서 가상 함수를 호출하면 예상처럼 동작하지 않는다.
다음 코드를 살펴보자.
class B
{
protected B()
{
VFunc();
}
protected virtual void VFunc()
{
Console.WirteLine("VFunc in B");
}
}
class Derived : B
{
private readonly string msg = "Set by initializer";
public Derived(string msg)
{
this.msg = msg;
}
protected override void VFunc();
{
Console.WirteLine(msg);
}
public static void Main()
{
var d = new Derived("Constructed in main");
}
}
코드를 실행하면
- "Constructed in main"
- "VFunc in B"
- "Set by Initializer"
중 무엇이 출력될 거 같은가?
숙련된 C++ 개발자라면 "VFunc in B"라고 답을 할지도 모르겠다. 일부 C# 개발자들은 "Constructed in main"이라고 할 것이다. 하지만 답은 "Set by Initializer"다.
베이스 클래스의 생성자를 살펴보면 자기 클래스 내에 정의된 가상 함수를 호출하고 있다. 하지만 파생 클래스가 가상 함수를 이미 재정의하고 있기 때문에 런타임에는 파생 클래스에서 재정의된 함수를 호출한다. 왜냐면 런타임에 객체의 타입이 Derived이기 때문이다. C#의 정의에 따르면 생성자의 본문으로 진입하는 순간 해당 객체는 이미 초기화가 완료된 것으로 간주한다. 모든 멤버 변수를 초기화 구문을 이용하여 초기화할 수 있을는지는 모르겠지만, 대부분의 경우 모든 멤버 변수가 이 시점에 유효한 값을 갖도록 초기화되었다고 단정하기는 어렵다. 단지 멤버 변수에 대한 초기화 구문을 완료했을 뿐이며 파생 클래스의 생성자 본문은 아직 수행조차 되지 않았기 때문이다.
이 때문에 객체를 생성하는 동안 가상 함수를 호출하면 일관성 문제가 발생할 수 있다. C++의 경우 가상 함수가 생성 중인 객체의 타입을 확인하도록 설계되었다. 또한 런타임에 객체의 타입은 객체가 완전히 생성된 이후라야 확정될 수 있도록 설계되었다.
코드를 더 세부적으로 살펴보자. 우선 생성 중인 객체는 Derived 타입의 객체다. 따라서 Derived 객체에서 재정의한 가상 함수를 호출해야 한다. 이 부분에 대한 C++의 규칙은 C#과 사뭇 다르다. 먼저 C++의 경우 각 클래스의 생성자가 실행되면 객체의 런타임 타입이 변경된다. 둘째로 C#에서는 현재 타입이 추상 베이스 클래스인 경우 가상 메서드가 null 메서드 포인터가 될 가능성을 원칙적으로 배제하려 했다. 수정된 베이스 클래스를 살펴보자.
abstract class B
{
protected B()
{
VFunc();
}
protected abstract void VFunc();
}
class Derived : B
{
private readonly string msg = "Set by initializer";
public Derived(string msg)
{
this.msg = msg;
}
protected override void VFunc();
{
Console.WirteLine(msg);
}
public static void Main()
{
var d = new Derived("Constructed in main");
}
}
이 경우 B 타입의 객체를 생성할 수 없기 때문에 파생 클래스에서 반드시 VFunc()를 구현해야 정상적으로 컴파일된다. C#은 런타임에 타입을 고려하여 VFunc()를 호출한다. 이러한 설계 방법이 생성자 내에서 추상 함수를 호출했을 때 런타임 예외를 피할 수 있는 유일할 해법이기 때문이다. 숙련된 C++ 개발자라면 동일 코드를 C++로 작성했을 때 런타임 오류가 발생하리라 생각할 것이다. 실제로 앞의 코드를 C++로 구현한 경우 B의 생성자에서 VFunc()를 호출하면 크래시(Crash)가 발생한다.
하지만 C#에서 택한 이러한 전략은 매우 위험해 보이기까지 한다. 실제로 msg는 변경이 불가능하도록 readonly 변수로 선언했으며, 객체의 전체 수명 동안 동일한 값을 가져야 한다. 하지만 생성자가 작업을 완료할 때까지 잠깐 동안이지만 msg는 의도하지 않게 다른 값으로 변경된다. 멤버 초기화 구문과 생성자 본문은 하나의 세트로 보아야 한다. 실제로 파생 클래스의 생성자 본문으로 진입했을 때 멤버 변수는 초기화 구문에 의해서 설정된 값이나 혹은 시스템에 의해서 설정된 값(0)을 가질 것이다. 하지만 클래스는 생성자가 여전히 수행 중이므로 어떠한 값도 갖고 있지 않은 것으로 간주해야만 한다.
베이스 클래스의 생성자 내에서 가상 함수를 호출하면 파생 클래스가 가상 함수를 어떻게 구현했는지에 따라 매우 민감하게 동작하게 된다. 파생 클래스가 어떻게 작성될지 예상할 수는 없는 노릇이므로 베이스 클래스의 생정자 내에서 가상 함수를 호출하게 되면 구조가 매우 취약한 코드가 돼버린다. 파생 클래스에서 멤버 초기화 구문을 통해서 모든 변수를 초기화하면 될 것 같지만 대부분의 경우 생성자로 전달된 매개변수를 이용하여 객체를 초기화할 것이기 때문에 이 같은 방법은 제약이 너무 많다. 생성자에게 가상 함수를 호출해도 되는 유일한 경우는 파생 클래스가 기본 생성자만을 정의하고 있고, 다른 어떤 생성자도 가지고 있지 않은 경우뿐이다. 하지만 이는 파생 클래스를 정의하는 과정에서 심각한 제약을 가하는 것이며, 이러한 규칙에 따라 다른 사람들이 코드를 작성하리라 기대하기도 어렵다. 이러한 규칙을 강요하면서까지 생성자 내에서 가상 함수를 호출하는 코드를 포함시키는 것은 향후 큰 문제를 유발할 가능성이 내포하는 것과 다르지 않다. 더 정확히 말하자면 올바르게 동작할 가능성이 거의 없다. 다행인 것은 Visual Studio에 포함된 FxCop이나 정적 코드 분석기가 관련 규칙을 총해 이러한 코드 패턴을 쉽게 발견할 수 있다는 점이다.
출처
'프로그래밍 > Effective C#' 카테고리의 다른 글
Effective C# Item 13 : 정적 클래스 멤버를 올바르게 초기화하라 (0) | 2023.10.27 |
---|---|
Effective C# Item 8 : 이벤트 호출 시에는 null 조건 연산자를 사용하라. (1) | 2023.10.26 |
Effective C# Item 2 : const보다는 readonly가 좋다 (1) | 2023.10.24 |
Effective C# Item 4 : string.Format()을 보간 문자열로 대체하라 (1) | 2023.10.23 |
Effective C# Item 12 : 할당 구문보다 멤버 초기화 구문이 좋다 (1) | 2023.10.20 |
댓글