C# Virtual Table의 구조와 Polymorphism에 관한 이해

[제목] C# Virtual Table의 구조와 Polymorphism에 관한 이해

이 아티클은 C#의 Virtual Table 구조를 통해 객체지향프로그래밍의 다형성(Polymorphism)이 어떻게 구현되는지를 간략히 정리한 글이다. 


1. Virtual Table의 구조

한 클래스가 해당 클래스 혹은 상위 Base클래스에서 하나 이상의 가상메서드(virtual 혹은 abstract 메서드)를 갖는다면, 해당 클래스의 Method Table 메타데이타 내에 Virtual Table을 갖게 된다. Virtual Table은 기본적으로 메서드 포인터들을 저장하는 배열로서 흔히 VTable, virtual function table, virtual method table 등으로도 불리운다.

VTable은 아래 그림에서 보이듯이 객체 레퍼런스가 가리키는 Heap 상 객체의 첫부분에 있는 Type Handle이 가리키는 곳의 Method Table내에 위치한다. CLR 버젼에 따라 실제 VTable이 위치하는 Offset 위치는 약간 다르지만, 어쨌든 VTable은 이 Method Table 메타데이타 내에 존재하고 있다.


Method Table내의 VTable

사실 C# / .NET 에서 모든 클래스(혹은 struct)는 기본적으로 System.Object 클래스로부터 상속되므로 모든 클래스가 VTable을 갖는다. 즉, 모든 클래스는 System.Object 클래스의 4개의 가상메서드(ToString(), Equals(), GetHashCode(), Finalize() )를 자신의 VTable안에 갖게 된다. (주: Java는 모든 메서드가 디폴트로 가상메서드이다. C#은 virtual / abstract 를 지정해야 가상메서드가 된다)

만약 Base클래스가 가상메서드를 갖지 않는다면, 파생클래스는 Base 클래스의 메서드를 VTable 슬롯에 추가하지 않는다. 예를 들어, 다음과 같이 Base클래스 A의 메서드가 virtual 없이 선언 되었다면, 파생클래스의 VTable에는 A의 메서드 슬롯을 갖지 않는다.

class A
{
	public void BaseMeth1() {}	
}
class B : A
{
	public void DerivedMeth1() {}	
}

하지만, 만약 Base클래스(A)에서 아래와 같이 virtual 메서드로 선언된다면, 파생클래스(B)는 BaseMeth1 / BaseMeth2를 자신의 가상테이블에 넣게 된다.

class A
{
	public virtual void BaseMeth1() { }
	public virtual void BaseMeth2() { }
}
class B : A
{
	public void DerivedMeth1() { }
	public void DerivedMeth2() { }
}

아래 그림은 파생클래스 B의 VTable이 메모리상에서 표현된 것을 보여 주는데, 처음 4개의 메서드는 System.Object의 가상메서드들이고, 다음의 2개는 Base클래스 A의 가상메서드이다. 그리고 마지막으로 파생클래스 B의 virtual 혹은 nonvirtual 메서드들이 있게 된다 (해당 파생클래스의 메서드 슬롯에는 자신의 public 메서드 뿐만 아니라 private 메서드들도 추가된다). 파생클래스가 조부모, 부모 등의 여러 Hierarchy를 갖는다면, 최상위 Base클래스의 가상메서드부터 부모 클래스 그리고 해당 파생클래스까지 계층적인 순서대로 메서드 포인터 슬롯을 갖게된다.
 


vtable 메모리 표현


2. 메서드 Overriding

C#에서 파생클래스가 Base클래스의 메서드와 동일한 이름으로 메서드를 갖는 방법은 2가지가 있다. 즉, Overriding 하거나 Hiding 하거나이다. 만약 파생클래스에서 동일 메서드가 Overriding 되지 않는다면 그것은 Hiding된 것이다.

그러면 먼저 파생클래스에서 메서드를 Overriding을 하는 경우를 살펴보자. 아래 예제에서 Base클래스(A)는 2개의 가상메서드(Run1 / Run2)를 가지고 있고, 파생클래스(B)는 이 중 하나(Run1)에 대해서만 메서드를 override 하고 있다.

class A
{
    public virtual void Run1() 
    {
        Console.WriteLine("A.Run1");
    }
    public virtual void Run2() { }
}
class B : A
{
    public override void Run1() 
    {
        Console.WriteLine("B.Run1");
    }        
    public void OtherRun() {}
}

이러한 overriding 후 아래와 같이 C# 코드를 실행하면 어떤 결과가 나올까?

// 케이스-A
A a = new A();
a.Run1(); 

// 케이스-B
A x = new B();
x.Run1();  

우선 케이스-A 의 경우 변수 a가 클래스 A 타입이고, A 타입의 메서드 Run1을 실행했으므로, 결과는 "A.Run1"이 된다. 하지만 케이스-B 의 경우, 클래스 B 객체를 생성하여 이를 A 타입의 변수에 할당했으므로 클래스 A의 Run1()을 실행할 것 같지만, 실제로는 B의 Run1()을 실행하게 된다.

이는 객체지향프로그래밍(OOP)의 다형성(Polymorphism)을 표현한 예인데, Polymorphism은 Base클래스의 레퍼런스를 사용하여 하위 계층에 있는 다양한 파생클래스의 (동일 signature) 메서드들 실행할 수 있게 하는 기능이다.

그러면, 어떠한 방식으로 이런 다형성이 가능한 것일까?

구체적 예를 들여다 보기 전에, 먼저 2가지 점을 이해할 필요가 있다. 첫째, 한 클래스로부터 객체가 생성되어 어떤 변수에 할당되었을 때, 변수의 타입에 상관없이 해당 클래스의 Method Table을 사용한다는 점이다. 다시 말하면, 위에서 클래스 B로부터 객체가 생성되어 Base클래스인 A 타입으로 변수형이 지정되었더라도 VTable은 B 클래스의 Method Table에 있는 것을 사용한다는 것이다. 둘째, 만약 파생클래스의 객체가 보다 상위의 Base클래스 변수에 할당된다면, 그 변수는 해당 Base클래스의 범위 안에서만 사용 가능한 메서드만을 사용할 수 있다는 것이다. 아래 그림은 B가 A로부터 파생된 클래스라는 가정 하에 이 2가지 점들을 이해하기 쉽게 그린 것이다.


VTable 영역 제한

그러면 이러한 사항들을 염두에 두고 다시 위의 두 케이스를 살펴보자.
먼저 케이스-A의 경우 클래스 A의 객체를 생성한 것이므로 다음과 같이 클래스 A의 VTable을 사용할 것이다. 이 VTable을 보면 a.Run1() 메서드 호출은 VTable에서 5번째 메서드 슬롯 즉 A.Run1()을 가리키는 포인터를 사용하게 된다.


클래스 A VTable

케이스-B 의 경우, 클래스 B 객체를 생성한 것이므로 다음과 같이 클래스 B의 VTable을 사용하게 된다.


클래스 B VTable


이 VTable을 보면, x.Run1() 메서드 호출은 타입 A에서의 Run1 메서드 위치 즉 VTable에서 5번째 메서드 포인터 슬롯을 사용하게 된다. 그런데 원래 대로라면, 이 5번째 슬롯에는 클래스 A의 가상메서드 A.Run1() 포인터가 있어야 하는데, 여기서 이것이 실제 B.Run1()으로 대체되어 있다. 그리고 8번째 슬롯에 있을 것으로 추정되는 B 타입 메서드 B.Run1()은 존재하지 않는다...

이것이 C# virtual/override 키워드의 역활이다.
즉, Base클래스의 virtual 메서드를 파생클래스에서 override 하면 (1) 파생클래스의 VTable에 있는 Base클래스의 가상메서드 슬롯에 파생클래스 override 메서드의 포인터를 집어 넣고, (2) (override 메서드에 대한) 별도의 파생클래스 메서드 슬롯을 새로 만들지 않는 것이다. 그리고, 이것이 OOP의 Polymorphism을 가능하게 만드는 기본 메커니즘이다.

누군가 "타입 A" 변수로 메서드를 요구하면 Base 클래스 A의 메서드 범위에서 해당 (이미 overriding 된) 가상 메서드 포인터 를 리턴하고, "타입 B" 변수로 그 메서드를 요구하면, 파생클래스 메서드 슬롯에는 해당 메서드가 없으니 Base클래스의 가상메서드를 리턴하는데 실제 그곳에는 override 메서드를 가리키는 포인터가 있는 것이다.
 

3. Method Hiding

그러면 이제 마지막으로 Method Hiding에 대해 살펴보자. 위의 예제에서 B를 다음과 같이 변경해 보자. 즉, OtherRun() 메서드를 Run2()로 변경하였다. Run2()는 클래스 A에서 가상메서드로 이미 정의된 것인데, 파생클래스 B에서 override를 쓰지 않고 그냥 (혹은 new를 사용하여) 동일 메서드명을 사용한 경우이다.

C# new 키워드는 여기에서, 해당 메서드가 Base클래스의 동일명의 메서드와 관련이 없는, 파생클래스에서 새로 생성된 이름임을 표시한다. 여기서 new를 쓰지 않으면 컴파일러 Warning을 발생시키지만, 실제 런타임에서는 new가 없어도 동일한 효과를 낸다.

class A
{
    public virtual void Run1()
    {
        Console.WriteLine("A.Run1");
    }
    public virtual void Run2() 
    {
        Console.WriteLine("A.Run2");
    }
}
class B : A
{
    public override void Run1()
    {
        Console.WriteLine("B.Run1");
    }
    public new void Run2()
    {
        Console.WriteLine("B.Run2");
    }
}
class Program
{
    static void Main(string[] args)
    {
	    //케이스-A
        A a = new B();        
        a.Run2();  // 베이스의 Run2 실행

		//케이스-B
        B b = (B) a;
        b.Run2(); // 파생클래스 Run2 실행
    }
}

위의 코드처럼 파생클래스에서 override를 쓰지 않고 Base클래스와 동일한 메서드명을 사용하면, 파생클래스에서는 Base클래스의 메서드를 더이상 사용할 수 없게 되는데, 이를 Method Hiding이라 한다.

위 예제에 대한 파생클래스(B)의 VTable을 살펴 보면 아래와 같다. 여기서 적색 사각형은 Base클래스(A)에 대한 가상메서드이고, 청색은 파생클래스(B)의 메서드들이다.


Method Hiding VTable

여기서 주목할 것은 (1) Method Hiding으로 파생클래스에 추가한 Run2() 메서드가 청색의 파생클래스 메서드 슬롯에 새로 추가되었다는 점과 (2) 적색의 Base클래스 메서드 Run2()가 B.Run2()가 아니라 원래대로 A.Run2() 라는 점 (이는 override되지 않았다는 것을 의미) 이다.

그러면 이제 Main()의 케이스-A를 살펴보자. B 객체를 생성했으므로, B 객체의 VTable을 사용할 것이고, 객체레퍼런스를 변수 A 타입에 할당했으므로 타입 A가 볼 수 있는 영역 즉 System.Object 영역 + 적색 영역만을 엑세스 할 수 있게 된다. 여기에서 Run2는 6번째 포인터 즉 A.Run2()를 실행하게 된다.

다음 케이스-B를 보면, 변수 a를 타입 B로 캐스팅해서 타입 B형의 변수를 생성하였다. 변수 b는 VTable에서 System.Object 영역 + 적색 + 청색 즉 모든 메서드를 엑세스할 수 있는데, 여기서 파생클래스 영역에 Run2()가 있으므로 이를 실행하게 된다 (만약 없었으면, Base클래스의 메서드를 실행했을 것이다). 여기에서 우리는 Method Overriding과는 달리 Method Hiding은  변수 타입에 따라 서로 다른 결과를 출력하게 됨을 알 수 있다.

일반적으로 객체지향프로그래밍에서 Polymorphsim은 유용하게 널리 사용되는데, 개념적으로 Method Hiding은 이러한 유용성을 방해하는 기법이므로 Hiding은 좀처럼 잘 사용되지 않는다. 하지만 몇 가지 특수한 경우에 이를 사용하는 (혹은 사용할 수 밖에 없는) 경우가 있다. 가장 대표적인 예로서 미리의 변화에 대응하기 위한 Forward Compatiblity 보장의 경우이다. 예를 들어 MS가 .NET 1.0에서 한 Base클래스를 배포하면서 Run() 이라는 메서드를 쓰지 않다가 2.0에서 이를 추가하였다고 가정해 보자. 그런데 어떤 한 회사가 만약 이 1.0 Base클래스를 상속하여 많은 파생클래스를 만들고 여기서 Run()을 사용하고 있었다면, Method Hiding 허용 없이는 새로운 2.0 API를 사용할 수 없게 된다. 또 다른 한 예로, 외부 API 중 상속해서 사용하고 싶은 클래스의 한 메서드가 용도에 적합하지 않을 때, 만약 해당 메서드가 virtual이 아니라면 override 할 수 없는 상황이 될 것이고, 아마 이 때 Hiding이 유일한 방식이 될 수 있을 것이다.

관련 아티클




본 웹사이트는 광고를 포함하고 있습니다. 광고 클릭에서 발생하는 수익금은 모두 웹사이트 서버의 유지 및 관리, 그리고 기술 콘텐츠 향상을 위해 쓰여집니다.