IT_Programming/C · C++

[C++/CLI] C++: .NET 프레임워크 프로그래밍을 위한 가장 강력한 언어

JJun ™ 2008. 1. 10. 11:02
LONG

타입 되돌아보기

박싱(boxing)에 대해 썰을 풀기 전에, 값 타입과 참조 타입을 왜 구분하는지 되짚어보는 것도

괜찮을 것이다.

값 타입의 인스턴스는 단순한 값으로 여기고, 참조 타입의 인스턴스는 객체로 여길 수도 있지. 객체의 필드를 저장하는 데는 메모리가 필요할 뿐만이 아니라, 모든 객체에는 객체 헤더가 있는데, 이 헤더는 가상 메소드를 위한 클래스 계층도 등의 객체 지향 프로그래밍의 기본 서비스가 가능토록 하고, 모든 종류의 용도에 붙게될 메타데이터를 제공토록 하지. 하지만, 가상 메소드와 인터페이스로 인한 이 객체 헤더의 메모리 부하는, 종종 너무 값비싼일이 될 수도 있는데, 특히 원하는 전부가 정적 타입인 단순 값과 그 값에 대한 몇몇 컴파일러에 강제된 연산일 경우에 그래. 물론 몇몇 경우에는 컴파일러가 이 객체 부하를 옵티마이징으로 제거할 수도 있지만, 모든 경우에 해당하는 것은 아니지. 적어도 성능(performance)에 대해 신경쓴다면, 분명 managed 코드에는 값과 값 타입을 다루는 데에 이점이 있어. 여기에는 네이티브 C++의 타입 시스템에서와 같은 빈틈(split)이 없단 말야. 물론 C++는 어떤 프로그래밍 패러다임도 부과하지 않지만, 이러한 이유로 인하여 C++위에 라이브러리를 만들어냄으로써 독특한 타입 시스템을 구축하는 것이 가능해지지.

박싱(boxing)

그럼 박싱이란 무엇일까? 박싱이란 값과 객체간의 빈틈을 연결짓는 메카니즘이야. 비록 CLR은 모든 타입이, 간접적이건 직접적이건 간에, Object에서 파생될 것을 요구하지만, 사실 값에 대해서는 그렇지가 않아. 스택에 존재할 정수와 같은 단순 값은 단지 컴파일러가 특정 연산을 가능케 하는 하나의 메모리 블록일 뿐이야. 만약 값을 객체처럼 다루고 싶다면, 그 값은 반드시 객체가 되어야 해. 그 값은 Object에서 파생된 메소드를 제공할 수 있어야 하지. 박싱이란 개념은 바로 이를 가능케 하기 위해 CLR이 제공하는 메카니즘이야. 그러므로 박싱이 실제로 어떻게 동작하는지를 알아두는 것은 꽤나 유용한 일이쥐. 첫째, 값은 ldloc IL 명령어에 의해 스택에 담겨. 둘째, 이 박싱 IL 명령어를 사용하는 데는 큰 부하가 걸려. 컴파일러는 Int32같은 그 값의 정적 타입을 제공하고, CLR은 계속하여 그 값을 스택에서 빼내온(pop) 다음, 그 값과 그 값에 대한 객체 헤더를 담을 충분한 양의 메모리를 할당하지. 이 새로이 생성된 객체에 대한 참조(reference)는 스택에 담겨(push). 이 과정 모두가 그 박싱 명령어에 의한 결과야. 마지막으로, 그 객체에 대한 참조를 얻기 위해서는 stloc IL 명령어를 이용하여 스택에서 그 참조를 빼내서 지역 변수에 저장해야 되.

이제 질문은, 값에 대한 박싱을 프로그래밍 언어가 명시적 연산으로 표현하는지, 또는 묵시적 연산으로 표현하는지에 대한 것이야. 다른 말로 표현하자면, 이를 위해 명시적 캐스트, 또는 어떤 다른 구조물이 사용되는가란 뜻이야. C# 언어 설계자는 묵시적 변환을 선택했어. 결국, 정수는 Object에서 직접적으로 파생되는 Int32 타입이야.

int i = 123;
object o = i;
우리가 이미 배웠다시피, 문제는 박싱이 단순한 업캐스트(upcast)가 아니라는 데 있어. 그보다는 박싱이란 잠재적으로 값비싼 연산인, 값에서 객체로의 변환이야. 이러한 이유로, MC++에서는 __box 키워드를 이용하여 박싱을 명시적으로 행하지.

int i = 123;
Object* o = __box(i);
물론 MC++에서는 값을 박싱할 때에 정적 타입 정보를 버리지 않아도 되. 하지만 C#에는 이러한 기능이 없지.

int i = 123;
int __gc* o = __box(i);
강력히 타입에 묶여 박싱된(strongly-typed boxed) 값에는 dynamic_cast를 사용하지 않고 단순히 그 객체를 역참조함으로써 값 타입으로의 재변환(unboxing)을 이룰 수 있다는 이점이 있지.

int c = *o;
물론 MC++에서의 명시적 변환으로 인한 구문적(syntactic) 부하는 대부분의 경우에서 너무 크다고 증명되었어. 이러한 이유로, C++/CLI 언어 설계 중 이 부분이 변경되어, 묵시적으로 변환하는 C#의 그것으로 바뀌었지. 이와 동시에, C++/CLI는 다른 .NET 언어가 표현할 수 없는 강력히 타입에 묶여 박싱된(strongly-typed boxed) 값을 직접적으로 표현하는 타입 안정성(type-safety)을 간직했어.

int i = 123;
int^ hi = i;
int c = *hi;
hi = nullptr;
물론, 이 것이 암시하는 바는 객체를 가리키지 않는 핸들을 0으로 초기화할 수 없다는 것인데(포인터와는 달리), 왜냐하면 이렇게 하면 단순히 값 0를 박싱하는 결과를 가져오걸랑. 바로 이것이 nullptr 상수이 존재하는 이유야. 이 상수는 어떤 핸들에건 대입될 수 있어, C#의 null 키워드와 동일한 역할을 하지. 비록 nullptr가 C++/CLI 언어에 새로이 도입된 키워드이긴 하지만, Herb Sutter와 Bjarne Stroustrup이 표준 C++에서조차 포인터에 사용하라고 추천하는 놈이기도 해.

참조 타입과 값 타입 작성하기

다음의 몇몇 절에서는 CLR 타입을 작성하는 데 필요한 몇몇 세부 사항에 대해 다룰거야.

C#에서의 class 키워드는 참조 타입을 선언하기 위해 사용하고, struct 키워드는 값 타입을 선언하기 위해 사용하지.

class ReferenceType {}
struct ValueType {}
C++에는 이미 classstruct 키워드에 대해 잘 정의된 의미가 붙어있기 때문에, C#에서의 사용법은 C++에는 해당되지 않아. 원래의 언어 설계중에는, 참조 타입을 나타내기 위해 class 앞에 __gc 키워드를, 값 타입을 위해 __value 키워드를 놓았었지.

__gc class ReferenceType {};
__value class ValueType {};
C++/CLI에서는 사용자 식별자와 충돌을 일으키지 않을 장소에 새로운 키워드를 제공하지. 참조 타입의 경우 classstruct 앞에 ref를 붙이는 것이고, 비슷한 방법으로 값 타입을 선언할 때는 value를 붙이는 것이야.

ref class RefrerenceType {};
ref struct ReferenceType {};

value class ValueType {};
value struct ValueType {};
classstruct 중 무엇을 선택할지는 멤버들의 가시성에 대한 기본 값을 무엇으로 하느냐에 달려있어. 주된 차이는 CLR 타입이 오직 public 상속만 지원한다는 것이야. 상속에 private이나 protected 키워드를 사용하면 컴파일 오류를 일으킬꺼야. 그러므로 상속에 public 키워드를 사용하는 것은 문법적으로 문제는 없지만, 코드만 장황하게 보일 뿐이지.

접근성(accessibility)

CLR은 많은 양의 접근성 수정자(accessibility modifier)를 제공하는데, 그 수는 클래스 멤버 함수와 변수에 적용키 위해 네이티브 C++에서 제공된 접근성 수정자의 개수를 넘어서지. 이 뿐만 아니라, CLR에서는 중첩된 타입 외에도 네임스페이스 타입의 접근성도 정의할 수 있어. 최하위 수준의 언어가 되어야 한다는 목표를 위해서, C++/CLI는 CLR을 타깃으로 한 그 어떤 상위 수준 언어보다도 더 세밀히 접근성을 제어할 수 있도록 설계되었지.

네이티브 C++에서의 접근성과 CLR에 의해 정의된 접근성간의 차이를 알아보자구. 네이티브 C++의 경우, 접근성 수정자는 동일한 프로그램 범위 안의 다른 코드에서 멤버에 접근하는 것을 제한하기 위해 사용되는 반면, CLR의 경우에는 타입 자체의 접근성도 정의해야 하고, 그 타입의 멤버를 동일한 어셈블리 안의 다른 코드에서뿐만 아니라, 다른 어셈블리에서도 참조하는 것에 대한 제한을 두기 위해 사용해.

네임스페이스나 classdelegate 타입같은 중첩되지 않은 타입은, 타입 정의부 앞에 public이나 private를 붙임으로써, 자신이 속한 어셈블리 외부에 대한 그 자신의 가시성을 지정할 수 있어.

public ref class ReferenceType {};
이 가시성을 명시적으로 지정해주지 않는다면, 이 타입은 자신이 속한 어셈블리에서만 보이게 될거야.

멤버에 적용되는 접근 지정자는 확장되어 두 개의 키워드를 한번에 쓸 수 있는데, 이렇게 함으로써 그 지정자 뒤에 붙을 이름에 대해 내부와 외부 접근을 동시에 지정할 수 있지. 두 개를 지정함으로써 접근 제한을 좀더 세밀히 할 수 있게된 이 방법은, 어셈블리 외부에서의 접근성과 어셈블리 내부에서의 접근성을 정의하기 위한거야. 만일 키워드 하나만 사용한다면, 내부와 외부 접근성 모두에 적용될거야. 이러한 설계로 인해, 타입과 멤버의 접근성을 훨씬 더 유연하게 정의할 수 있게 되지.

public ref class ReferenceType
{
public:
    //어셈블리 내부와 외부 모두에서 보인다.
private public:
    //어셈블리 내부에서만 보인다.
protected public:
    //어셈블리 외부에서는 파생 타입만, 내부에서는 모두 볼 수 있다.
};

속성(property)

중첩된 타입과는 별개로, CLR 타입은 오직 메소드와 필드만을 담을 수 있어. 프로그래머가 좀더 명확하게 그의 의도를 담으려면, 메타데이터를 이용할 수 있는데, 이로써 특정 메소드들을 다른 프로그래밍 언어가 속성(property)으로 다루도록 지정할 수 있지. 사실, CLR 속성이란 그 속성을 담은 타입의 멤버야. 하지만, 속성은 저장 공간을 할당받지 않기에, 단지 그 속성을 구현한 각각의 메소드에 대한 이름붙은 참조에 불과해. 각기 다른 컴파일러들은 소스 코드에서 속성에 해당하는 구문을 마주쳤을 때, 그에 맞는 메타데이터를 만들어내야 하지. 이로써 그 타입의 사용자는 자신이 사용하는 언어에 해당하는 속성 구문을 이용하여 그 속성을 구현하는 getset 메소드를 사용할 수 있어. 네이티브 C++과는 달리, C#은 속성에 대한 지원이 매우 뛰어나지.

public string Name
{
    get
    {
        return m_name;
    }
    set
    {
        m_name = value;
    }
}
C# 컴파일러는 각기 해당하는 get_Nameset_Name 메소드뿐만 아니라, 연관성을 지시하는 메타데이터도 포함시킬 것이야. MC++에서는 __property 키워드를 도입하여, 메소드가 속성(property semantic)을 구현한 것이라는 것을 나타내.

__property String* get_Name()
{
    return m_value;
}
__property String* set_Name(String* value)
{
    m_value = value;
}
이 모습은 분명 이상적이지가 못해. 추해보이는__property 키워드를 사용해야할 뿐만 아니라, 이들 두 멤버 함수가 실제로는 함께 묶여있다는 어떠한 표시도 없단말야. 이 방법은 유지보수 시에 잡아내기 힘든 버그를 유발시킬 수도 있지. 하지만 속성에 대한 C++/CLI에서의 설계는 훨씬 더 간결하고, C#의 그것에 훨씬 더 비슷해졌어. 이제 보겠지만 그 뿐만 아니라, 더 강력하기 조차 해.

property String^ Name
{
    String^ get()
    {
        return m_value;
    }
    void set(String^ value)
    {
        m_value = value;
    }
}
대단히 향상된 모습이지. 컴파일러는 get_Nameset_Name 메소드뿐만 아니라, 이를 속성이라고 선언하는 메타데이터도 함께 만들어낼꺼야. 또 하나 좋은 점은 이 속성 값을 네 어셈블리 밖의 코드에서도 읽어낼 수 있는 반면, 쓸때는 네 어셈블리 안의 코드에서만 가능하게끔 만들 수도 있다는 것이야. 이렇게 하려면, 속성 이름 뒤에 붙는 중괄호 안에서 접근 지정자를 사용하면 되지.

property String^ Name
{
public:
    String^ get();
private public:
    void set(String^);
}
속성 지원에 대해 마지막으로 알아둘 만한 점은 C++/CLI는 속성을 얻고 쓰는 데 특별한 처리가 필요없는 곳을 위한 약식 구문도 지원한다는 것이야.

property String^ Name;
여기서도 컴파일러는 get_Nameset_Name 메소드를 만들어낼 터인데, 이번에는 String* 멤버 변수가 담긴 기본 구현체 또한 제공할꺼야. 이 약식 구문의 이점은 나중에라도 이 단순 속성을 더 많은 의미가 담긴 구현체로 바꿀 수 있다는 것과, 이렇게 하여도 이 클래스의 인터페이스를 깨지 않게 된다는 것이야. 너는 속성의 유연함을 갖춘 필드의 단순성을 그대로 얻게 되는 것이지.

대리자(delegate)

네이티브 C++에서의 함수 포인터는 코드를 비동기적으로 실행하는 메카니즘을 제공하지. 너는 함수를 가리키는 포인터, 즉 펑터(functor)를 저장할 수 있고, 적절한 어떤 시점에 그 함수를 불러낼 수 있어. 이 방법은 검색에서 객체를 비교하는 것같은 그 구현체의 일부에서 알고리즘을 떼어내기 위해서만 활용된거 같아. 하지만 이 외에도, 진짜 비동기 프로그래밍에도 사용할 수 있는데, 각기 다른 쓰래드에서 한 펑터를 불러내는 것이 바로 여기에 속하지. 다음은 ThreadPool 클래스 예제인데, 이 클래스는 일꾼 쓰래드(worker thread)에서 동작할 함수에 대한 포인터를 큐에 넣게끔 하지.

class ThreadPool
{
public:

    template <typename T>
    static void QueueUserWorkItem(void (T::*function)(),
                                  T* object)
    {
        typedef std::pair<void (T::*)(), T*> CallbackType;
        std::auto_ptr<CallbackType> p(new CallbackType(function, object));

        if (::QueueUserWorkItem(ThreadProc<T>,
                                p.get(),
                                WT_EXECUTEDEFAULT))
        {
            // 이제 ThreadProc가 pair 삭제에 대한 책임을 진다.
            p.release();
        }
        else
        {
            AtlThrowLastWin32();
        }                
    }

private:

    template <typename T>
    static DWORD WINAPI ThreadProc(PVOID context)
    {
        typedef std::pair<void (T::*)(), T*> CallbackType;

        std::auto_ptr<CallbackType> p(static_cast<CallbackType*>(context));

        (p->second->*p->first)();
        return 0;
    }

    ThreadPool();
};
C++에서는 이 쓰래드 풀을 사용하는 것이 단순하기도 하고 자연스럽게 느껴지지.

class Service
{
public:

    void AsyncRun()
    {
        ThreadPool::QueueUserWorkItem(Run, this);
    }

    void Run()
    {
        // 긴 연산이 들어갈 자리
    }
}
분명 ThreadPool 클래스는 특정 시그니쳐로 이루어진 함수 포인터가 있어야만 동작한다는 점에서 매우 제한적이야. 하지만 이 제한성은 단지 이 예제에 해당하는 것이지, C++ 그 자체에 해당하는 것이 아니야. 일반화된 펑터에 대한 자세한 설명은 Andrei Alexandrescu가 쓴 Modern C++ Design을 참고하면 될거야.

C++ 프로그래머가 비동기 프로그래밍을 구현해야 하거나 비동기 프로그래밍에 관한 풍부한 라이브러리를 얻고자 한다면, 이에 대한 지원을 내장한 CLR이 그야말로 적합하지. 대리자는 함수 포인터와 비슷하지만, 대리자가 특정 메소드에 묶일 수 있는지 여부를 타깃 객체라던가 그 메소드가 속한 타입이 결정 못한다는 점이 달라. 시그니쳐가 일치하는 한, 메소드는 나중에 불러들이기 위해 대리자에 추가시킬 수 있어. 이 특징은 C++ 템플릿을 이용하여 어떤 클래스의 멤버 함수라도 사용될 수 있도록 만든 위 예제와 적어도 의미상으로는 비슷하지. 물론 대리자는 이보다 훨씬 더 많은 것들을 제공하는데, 특히 간접 메소드 호출(invocation)에 대해선 극도로 유용한 메카니즘이라고 할 수 있지. 다음은 C++/CLI를 사용하여 대리자 타입을 정의하는 예제야.

delegate void Function();
대리자 사용법은 간단해.

ref struct ReferenceType
{
    void InstanceMethod() {}
    static void StaticMethod() {}
};

// 대리자를 생성하고 인스턴스 멤버 함수를 묶는다(bind).
Function^ f = gcnew Function

                     (gcnew ReferenceType, ReferenceType::InstanceMethod);

// 또한 대리자를 결합하는 방법으로 정적 멤버 함수를 묶어서
// 대리자 사슬(chain)을 형성한다.

f += gcnew Function(ReferenceType::StaticMethod);

//두 개의 함수 모두를 불러낸다.
f();


결론

물론, Visual C++ 2005 컴파일러 말고도 C++/CLI만해도 논할 사항은 더 많지만, 이 칼럼이 CLR을 타깃으로 하는 프로그래머를 위해 C++/CLI가 무엇을 제공하는지에 대한 좋은 소개글이 되었으면 좋겠어. 이 새로운 언어를 사용하면, 생산성, 간결함, 성능을 희생시키지 않고도 .NET 애플리케이션을 C++로 작성하는 데 있어 유래가 없던 강력함과 우아함을 느끼게 될것이라 믿어 의심치 않아.

아래의 테이블은 일반적으로 사용되는 대부분을 빠르게 참조할 수 있도록 요약한 것이야.

설명                        C++/CLI                                C#

------------------------------------------------------------------------------
참조 타입 할당    
ReferenceType^ h =                   ReferenceType h = 
                      gcnew ReferenceType;              new ReferenceType();

------------------------------------------------------------------------------
값 타입 할당        ValueType v(3, 4);                     ValueType v = new ValueType

                                                                                      (3, 4);

------------------------------------------------------------------------------
참조 타입,

스택 의미론          ReferenceType h;                       불가능      

------------------------------------------------------------------------------

Dispose 호출

메소드                 ReferenceType^ h =                     ReferenceTYpe h =
                        gcnew ReferenceType;                   new ReferenceType();
                     delete;
                                      ((IDisposable)h).Dispose();

------------------------------------------------------------------------------
Dispose 메소드

구현                      ~TypeName() {}                        void IDisposable.Dispose() {}

------------------------------------------------------------------------------
Finalize 메소드

구현                       !TypeName() {}                        ~TypeName() {}
------------------------------------------------------------------------------

박싱(boxing)             int^ h = 123;                             object h = 123;
------------------------------------------------------------------------------

언박싱(unboxing)      int^ hi = 123;                             object h =123;                       

                         int c = *hi;                                 int i = (int)h;

------------------------------------------------------------------------------
참조 타입 정의          ref class ReferenceType {};       

                        ref struct ReferenceType {};         class ReferenceType {}

------------------------------------------------------------------------------
값 타입 정의             
value class ValueType {};
                         value struct ValueType {};          
struct ValueType {}

------------------------------------------------------------------------------
속성 사용법              
h.Prop = 123;                             h.Prop = 123;
                         int v = h.Prop;                            int v = h.Prop;

------------------------------------------------------------------------------
속성 정의                
property String^ Name                string Name
                        {                                            {
                           String^ get()                                get
                           {                                               {
                              return m_value;                             return m_name;
                           }                                                }
                           void set(String^ value)                    set
                           {                                                {
                              m_value = value;                            m_name = value;
                           }                                                 }
                         }                                           }

------------------------------------------------------------------------------
ARTICLE

참고 : MSDN에 올라온 "C++: The Most Powerful Language for .NET Framework Programming"

         번역한 것입니다.


C++: .NET 프레임워크 프로그래밍을 위한 가장 강력한 언어 by Kenny Kerr

요점: Visual C++ 2005에서 새로이 소개되는 C++/CLI 언어의 설계와 원리에 대해 탐구해봅니다. .NET 프로그래밍을 위한 가장 강력한 언어 - C++/CLI - 를 이용하여, 파워풀한 .NET 애플리케이션을 만드는 데에 이 칼럼 내용을 도움되길 바래용~

들어가면서

Visual C++ 팀은 많은 시간을 들여가며, 사용자의 의견을 듣고, .NET과 C++로 작업을 하였으니..그 결과 Visual C++ 2005에서의 CLR에 대한 지원을 재설계하기로 맘먹었어. 이 재설계된 물건을 가리켜, C++/CLI라고 부르니, 이는 CLR 타입을 사용하고 제작하는 데 있어 좀더 자연스러운 구문(syntax)을 제공하는 데 초점을 모은 결과야. 요 칼럼에서는 이 새로운 구문을, CLR에 가장 밀접하게 관계된 언어인 C#과 Managed C++와 비교해볼 것이야. 또한, 적당한 곳에서 네이티브 C++와 유사한 개념에 대해서도 보여줄 것이야...

CLI(Common Language Infrastructure)는 MS .NET 기술을 근간으로한 명세서들을 모아논 것이니, CLR(Common Language Runtime)은 CLI를 구현해 놓은 것이야. C++/CLI 언어의 설계는 이 CLI를 자연스리 C++이 지원하는 데에 목표로 잡았고, Visual C++ 2005 컴파일러는 CLR에 대한 C++/CLI를 구현한 것이야.

여기에는 두 가지 중대한 사항이 있는데, 첫 번째는 Visual C++이 CLR을 타깃으로하는 가장 하위 수준의 언어라는 점(MSIL보다도), 두 번째는 .NET 프로그래밍이 원래의 C++ 프로그래밍 만큼이나 자연스러워져야 한다는 점이지.

이 칼럼은 C++ 프로그래머를 위해 쓰여진 것이니, C#이나 VB.NET에서 C++/CLI로 넘어오라고 말하진 않겠어. 만일 네가 C++를 사랑하고, C#의 생산성을 유지하는 동시에 C++ 고유의 능력 모두를 이용하고 싶다면, 이 칼럼은 그야말로 네게 딱!인 칼럼이야. 뿐만 아니라, 어떻게 해서 Visual C++ 2005를 통해, 좀더 우아하고도 효율적인 .NET 코드를 작성하게 만들 수 있는지에 초점을 맞출 것이야.

객체 생성

CLR은 두 가지 타입, 즉 값 타입과 참조 타입을 정의하지. 값 타입은 할당과 제어의 효율성을 위해서 설계되었지. 이 타입은 C++의 내장 타입처럼 동작하고, 네 자신만의 타입으로도 만들 수있는 것으로서, Bjarne Stroustrup은 이를 concrete 타입이라고 불러. 반면 참조 타입의 설계는 네가 객체 지향 프로그래밍에서 기대하는 모든 특징, 즉 클래스 계층체(hierarchy)와 이에 따라붙는 여러 가지의 것들(파생 클래스, 가상 함수 등)을 제공하는 데 목적을 두었지. 뿐만 아니라 이 참조 타입은 CLR을 통해, 가비지 콜렉션이라 부르는 자동 메모리 관리같은 런타임 기능도 추가로 제공하지. CLR은 이들 두 타입에 대해 세부적인 런타임 타입 정보도 함께 제공하는데, 이 기능을 가리켜 리플렉션(reflection)이라고 불러.

값 타입은 스택에 할당되고, 참조 타입은 managed 힙에 할당되는데, 이 힙은 CLR의 가비지 콜렉터(GC)가 관리해. 네가 C++로 어셈블리(assembly)를 개발한다면, 네이티브 C++ 타입을 CRT 힙에 할당할 수 있어. 마치 네가 언제나 그래왔던 것처럼. 장래에는 말야, Visual C++ 팀이 네이티브 C++ 타입을 이 managed 힙에도 할당할 수 있도록 만들 것이야. 결국, 가비지 콜렉션은 네이티브 C++에서도 똑같이 매력적인 놈이 되는 것이쥐.

네이티브 C++에서는 객체가 생성될 장소를 고를 수 있었지?

어떤 타입도 스택이나 CRT 힙에 할당될 수 있어.

               // 스택에 할당된다.
               std::wstring stackObject;

               // CRT 힙에 할당된다.
               std::wstring* heapObject = new std::wstring;

보다시피, 객체 할당될 장소는 타입 각각에 대해서 독립적으로 지정할 수 있고, 이 선택권은 전적으로 프로그래머에게 달려있지. 게다가, 할당 구문 또한 스택과 힙의 경우가 완전히 다르쥐.

하지만, C#에서는 값 타입을 스택에다가 생성하고, 참조 타입은 힙에다가 생성해. 아래에 보면, System.DateTime 타입 작성자가 이 타입을 값 타입으로 선언하였어.

// 스택에 할당된다.
System.DateTime stackObject = new System.DateTime(2003, 1, 18);

// managed 힙에 할당된다.
System.IO.MemoryStream heapObject = new System.IO.MemoryStream();
보다시피, 객체가 스택과 힙 중 어디에 할당되는지 선언만 갖고는 알 길이 없어. 이 선택권은 전적으로 그 타입의 작성자와 런타임에게만 주어진 것이쥐.

C++을 위한 Managed Extensions, 짧게 불러서 Managed C++(MC++)은 네이티브 C++ 코드와 Managed 코드를 짬뽕할 수 있는 능력을 지녔어. 이 MC++은 C++ 표준 규칙을 따르면서도, CLR 구조물의 모든 기능을 지원하도록 C++을 확장한 것이쥐. 하지만 불행하게도, 확장물이 너무 많기 때문에, C++에서 Managed 코드를 작성하기란 여간 힘든 일이 아니야.

// 스택에 할당된다.
DateTime stackObject(2003, 1, 18);

// managed 힙에 할당된다.
IO::MemoryStream __gc* heapObject = __gc new IO::MemoryStream;
이를 보면, 값 타입을 스택에 할당하는 예는 C++ 프로그래머에겐 매우 평범한 모습이야. 하지만, managed 힙의 예를 보면, 꽤나 이상스럽게 보이지. __gc는 MC++에서 추가된 키워드 중 하나야. MC++은 네가 뭘 의도하려는지를 추론해낼 수 있기 때문에, 위의 예는 아래와 같이 __gc 키워드 없이 재작성할 수 있어. 아래는 기본 규칙으로 알려진 것이야.

// managed 힙에 할당된다.
IO::MemoryStream* heapObject = new IO::MemoryStream;
위 예는 훨씬 더 네이티브 C++처럼 보이지? 하지만, 문제는 heapObject가 진짜 C++ 포인터가 아니라는 데 있어. C++ 프로그래머는 포인터를 정적인 값으로 생각할 터이지만, 가비지 콜렉터는 객체를 메모리에서 맘대로 옮기걸랑. 또 다른 문제가 하나 더 있는데, 이 코드만 봐서는 위 객체가 네이티브 힙에 할당되는지, managed 힙에 할당되는지를 알 수가 없다는 것이야. 아마도 너는 이 타입이 (어디에 할당될지를 알도록) 우짜 작성되었는지를 알아야 할거야. 이토록 C++ 포인터에 의미를 덧붙이는 것은 좋지 않은 아이디어임은 이들 말고도 무진장 많아.

C++/CLI는 C++ 포인터와 CLR 객체 참조를 구분할 수 있도록 핸들이란 개념을 도입했어. C++ 포인터에 의미를 덧붙이지 않음으로써, 애매모호함을 유발시키는 여러 요인을 제거해버렸지. 게다가, CLR에 대한 지원도 핸들을 도입함으로써 훨씬 더 자연스러워졌어. 예를 들자면 말야, C++에서도 직접적으로 참조 타입에 대한 연산자 재정의(overload)를 할 수 있게 되었는데, 이는 핸들에 대해서도 연산자 재정의를 할 수 있게 되었기 때문이쥐. 이러한 이점은 "managed" 포인터에선 불가능한 일이었는데, 왜냐하면 C++은 포인터에 대해서는 연산자를 재정의를 금지하걸랑.

// 스택에 할당된다.
DateTime stackObject(2003, 1, 18);

// managed 힙에 할당된다.
IO::MemoryStream^ heapObject = gcnew IO::MemoryStream;

여기서도 마찬가지로, 값 타입의 선언에는 놀라울 것이 없어. 하지만 참조 타입의 경우에는 다르쥐. ^ 연산자는 이 값을 CLR 참조 타입에 대한 핸들로 선언해. 핸들의 값이 자동적으로 가비지 콜렉터에 의해 그 핸들이 참조하는 객체로 갱신된다는 의미인, 핸들 트랙은 메모리를 옮겨다닌다는 것이야. 게다가, 핸들은 재-바인딩(rebinding)이 가능하기 때문에, 마치 C++ 포인터처럼 다른 객체를 가리킬 수도 있어. 또하나 염두해둬야 할 점은 gcnew 연산자인데, 이놈은 new 연산자가 놓이는 위치에 사용하게되. 이놈은 객체가 managed 힙에 할당된다는 것을 명시적으로 지시하는 데 사용하지. 이제 new 연산자는 더이상 managed 타입에 대해 재정의되지 않기 때문에, 오직 CRT 힙에다만 객체를 할당할 것이야(물론 네가 너만의 new 연산자를 제공 안한다면 말야.) 너무 C++만 사랑하려고 하지 말어!

결국, 객체 생성에 대해 한마디로 말하자면, 다음과 같이 표현할 수 있어:

네이티브 C++ 포인터는 명백하게 CLR 객체 참조와는 구분되더라.

메모리 관리 vs 리소스 관리

네가 가비지 콜렉터와 같은 어떤 환경을 다루고자 할 때에, 리소스 관리와 메모리 관리를 구분짓는다면 꽤나 편리해질거야. 보통 이 가비지 콜렉터란 놈은 네 객체가 담길 메모리를 할당하고 해제하는 데에 관심이 있걸랑. 해서 이 놈은 네 객체가 갖게 될지도 모를 다른 리소스들, 예를 들어, 데이터베이스 콜렉션이라던가, 커널 객체를 가리키는 핸들에 대해서는 상관을 안하지. 이제 보게될 두 절에서는 말야, 나는 메모리 관리와 리소스 관리에 대해서 각각 썰을 풀어보겠어. 이 놈들은 반드시 이해해두어야 할 매우 중요한 주제거덩.

메모리 관리

네이티브 C++에서는 프로그래머가 메모리 관리에 대해 직접적으로 조작할 수 있어. 스택에 객체를 할당한다는 의미는, 그 객체를 위한 메모리가 특정 함수 호출이 일어났을 때에 할당되고, 그 메모리는 그 함수가 반환될 때에 해제되는 동시에 스택 또한 해제된다는 뜻이지. 한편 동적 객체 할당은 new 연산자를 이용해서 이룰 수 있어. 이 메모리는 CRT 힙에 할당되고, 그 메모리에 대한 해제는 프로그래머가 그 객체를 가리키는 포인터에 대해 delete 연산자를 사용할 때에 명시적으로 일어나. 이같은 메모리에 대한 세밀한 조작은 C++로써 극단적으로 효율적인 코드를 작성할 수 있게되는 이점이 되기도 하지만, 프로그래머가 부주의할 때엔 메모리 누출을 일으키는 요인이 되기도 하지. 물론, 메모리 누출을 피하기 위해 가비지 콜렉터에 완전 의존해서는 안되지만, 이 가비지 콜렉터에 의한 메모리 관리는 CLR이 채용한 방식일 뿐만 아니라, 매우 효과적인 방식이기도 해. 게다가 가비지 콜렉터에 의한 힙에는 이 외에도 메모리 할당 성능이 좋아진다거나 참조(reference)의 격리성 등의 여러 이점이 있지. 이러한 이점 모두는 라이브러리를 통해 C++에서도 이룰 수는 있지만, 그럼에도 불구하고 CLR을 더욱 돋보이게 하는 점은 CLR이 모든 프로그래밍 언어에 통용되는 단일 메모리 관리 프로그래밍 모델을 제공한다는 것이야. C++을 통한 COM 자동화 객체 모델에서, 데이터 타입을 상호운용하고 마샬링하기 위해 필요한 일들을 생각해봐. 이를 생각해보면 단일 메모리 관리 프로그래밍 모델이 얼마나 놀라운 물건인지를 깨닫게 될거야. 여러 프로그래밍 언어를 묶어내는 가비지 콜렉터..이를 갖게되는 일은 엄청난 것이라니깐.

CLR에서의 스택이란 개념은, 효율성에 관한 분명한 이유가 있을 때에 사용하는 값 타입이 할당되는 장소야. 하지만 CLR은 managed 힙에 객체를 할당하기 위한 newobj IL(intermediate language) 명령어도 제공하지. 이 명령어는 C#에서 참조 타입에 대해 new 연산자를 사용할 때를 위해 제공돼. 하지만 CLR에는 C++의 delete 연산자에 해당하는 함수가 없어. 이전에 할당된 메모리는 애플리케이션이 더이상 그 메모리를 참조하지 않아 가비지 콜렉터가 콜렉션을 결정내릴 때에 수거되지.

MC++ 역시, new 연산자가 참조 타입에 대해 적용될 때에 newobj 명령어를 발생시키지만, 이런 managed 또는 가비지 콜렉션이 이루어지는 포인터에 delete 연산자를 사용하는 것은 잘못된 일이야. 이는 분명 일관성을 깨뜨리는 일이기도 하고, C++ 포인터로 참조 타입을 나타내는 것이 왜 나쁜 아이디어인지를 보여주는 또다른 예이기도 해.

C++/CLI는 우리가 이 절에서 객체 생성에 대해 다뤘던 것들과는 달리, 메모리 관리에 대한 영역에서는 어떠한 새로운 것도 제공하지 않아. 하지만 리소스 관리 부분에서는 C++/CLI가 실로 뛰어나지.

리소스 관리

리소스 관리에 관한한, 그 어떤 무엇도 네이티브 C++을 따라올 수가 없어. Bjarne Stroustrup의 "리소스 획득이 초기화"란 테크닉이 정의하는 바는, 각 리소스 타입은 반드시 생성자와 그 리소스를 해제하기 위한 소멸자가 갖춰진 클래스로 모델화되어야 한다는 것이야. 그러면 리소스 타입은 스택에서 지역 객체로 쓰이게 되거나, 복합 타입의 멤버로 쓰일 수 있어. 리소스 타입의 소멸자는 갖고 있던 리소스를 자동으로 해제하는 역할을 하지. 그렇기에 Stroustrup은 C++을 가리켜 "C++은 더 적은 양의 가비지(쓰래기)를 만들어내기 때문에, 근본적으로 가비지 콜렉션에 관한한 최고의 언어이다"라고 말하걸랑.

놀랍게도 CLR에는 리소스 관리에 대해선 런타임에 명시적으로 지원하는 것이 아무 것도 없어. 다시 말해서, CLR에는 C++의 소멸자에 해당하는 개념이 없단 말이야. 그 대신 .NET 프래임워크는 리소스 관리에 대한 한 패턴을 장려하는데, 이른바 IDisposible이란 이름의 핵심 인터페이스 타입이 바로 그것이야. 이 아이디어가 의미하는 바는, 리소스를 감싸는(encapsulate) 타입은 반드시 이 인터페이스의 Dispose 메소드를 구현한다는 것인데, 이로서 호출자는 그 리소스가 더이상 필요치 않을 때에 이 Dispose 메소드를 호출하면 된다는 것이지. 말할 필요도 없이 C++ 프로그래머들은 이를 일보 후퇴한 아이디어로 여길 터인데, 왜냐하면 이들은 리소스 청소(cleanup)가 문제없이 수행되는 것이 기본인 코딩 스타일에 익숙해있걸랑.

리소스 해제 메소드를 반드시 호출해야하는 이러한 어려움으로 인해, 예외에 대해 안전한 코드를 작성하기가 어려워지지. 단순히 코드 마지막 블록에 Dispose 메소드 호출문을 넣는다고 해결되는 것이 아닌데, 예외란 어느 순간에 나타날지 모르는 일이므로 그 객체가 소유한 리소스를 누출할 위험이 여전히 있걸랑. C#에서는 이를 try-finally블록과 using문으로 해결하는데, 이는 예외 상황에 처했을 경우 Dispose 메소드를 호출하는 것이야. 하지만, 이 방법은 더 나빠진 해결법이라, 프로그래머는 이들 문장을 작성하는 것을 반드시 기억해야만 하고, 만약 이를 잊었을 경우에는 코드가 컴파일될지는 몰라도 기본적으로 잠재적인 오류를 갖게되지. try-finally 블록과 using문의 필요성은 소멸자가 없음으로 인해 파생된 귀찮은 부수물이 되는 셈이야.

using (SqlConnection connection = new SqlConnection("Database=master; Integrated Security=sspi"))
{
    SqlCommand command = connection.CreateCommand();
    command.CommandText = "sp_databases";
    command.CommandType = CommandType.StoredProcedure;

    connection.Open();

    using (SqlDataReader reader = command.ExecuteReader())
    {
        while (reader.Read())
        {
            Console.WriteLine(reader.GetString(0));
        }
    }
}
이 이야기는 Managed C++에서도 마찬가지야. 너는 try-finally문을 사용해야 할 터인데, 이 문장은 MS가 C++에 확장시킨 것이지. MC++에는 C#의 using문에 해당하는 놈이 없지만, 간단하게 Using 템플릿 클래스를 작성함으로써 손쉽게 대처할 수 있어. 이 클래스는 GCHandle을 감싸고, 소멸자에서 managed 객체의 Dispose 메소드를 호출하지.

Using<SqlConnection> connection(new SqlConnection
    (S"Database=master; Integrated Security=sspi"));

SqlCommand* command = connection->CreateCommand();
command->set_CommandText(S"sp_databases");
command->set_CommandType(CommandType::StoredProcedure);

connection->Open();

Using<SqlDataReader> reader(command->ExecuteReader());

while (reader->Read())
{
    Console::WriteLine(reader->GetString(0));
}
C++가 전통적으로 리소스 관리에 대해 강력하게 지원해왔다는 점을 고려하면, C++/CLI가 리소스를 가볍게 처리할 수 있도록 설계한 것은 당연한 일이야. 먼저, 리소스를 관리하는 클래스 작성에 대해 생각해보기로 하지. CLR을 타깃으로한 대부분의 언어가 갖는 부담은 Dispose 패턴을 정확하게 구현해야 한다는 점이야. 이를 구현하는 것은 네이티브 C++에서 소멸자를 구현하는 것 만큼 쉬운 일이 아니지. Dispose 메소드를 작성할 때에는 기반 클래스에 Dispose 메소드가 있다면, 반드시 그 메소드를 호출해야되. 게다가, 그 클래스의 Finalize 메소드를 구현하기로 맘먹었다면, 동시(concurrent) 접근에 대해서도 신경써야 하는데, 왜냐하면 Finalize 메소드는 독립된 쓰래드에서 호출되걸랑. 뿐만 아니라, managed 리소스를 해제하는 데에도 신경써줘야 하는데, 특히나 일반 애플리케이션 코드가 그러듯 Dispose 메소드가 실질적으로 Finalize 메소드에서 호출될 경우에는 말야.

C++/CLI라고 이런 모든 부담을 날려버리는 것은 아냐. 하지만 C++/CLI에는 이를 위해 도움이 될만한 요소을 제공하지. 이놈이 무엇을 제공하는지 살펴보기 전에, 먼저 오늘날의 C#과 MC++이 사용하는 접근법에 대해서 간단하게 복습해보기로 하자구. 이 예제는 BaseIDisposable에서 파생된다고 가정하고 있어. 만약 그렇지 않다면, Derived 클래스는 Dispose 메소드를 구현할 필요가 없을거야.

class Derived : Base
{
    public override void Dispose()
    {
        try
        {
            
// managed 리소스와 unmanaged 리소스를 해제한다.
        }
        finally
        {
            base.Dispose();
        }
    }

    ~Derived() 
// Object.Finalize 메소드를 구현/오버라이드한다.
    {
        
// 오직 unmanaged 리소스만 해제한다.
    }
}
Managed C++에서도 별반 다를 바가 없어. 소멸자는 실제론 Finalize 메소드처럼 보이지. 컴파일러는 try-finally 블록을 적절하게 넣고 기반 클래스의 FInalize 메소드를 호출하기 때문에, C#과 MC++에서는 상대적으로 Finalize 메소드를 작성하기가 쉽지만, 분명 훨씬 더 중요한 Dispose 메소드를 작성하는 데 있어서는 아무 도움도 주지 않아. 프로그래머는 종종 오직 리소스를 해제하는 목적만이 아닌, 어떤 스코프의 끝에서 실행될 모종의 코드를 갖기 위해, Dispose 메소드를 의사-소멸자(pseudo-destructor)로 사용하지.

C++/CLI는 Dispose 메소드를 참조 타입에 대한 지역 "소멸자"로 만듦으로써, 이 메소드를 중요히 여겨.

ref class Derived : Base
{
    ~Derived() 
// IDisposable::Dispose 메소드를 구현/오버라이드한다.
    {
        
//managed 리소스와 unmanaged 리소스를 해제한다.
    }

    !Derived() 
//Object::Finalize 메소드를 구현/오버라이드한다.
    {
        
//오직 unmanaged 리소스만 해제한다.
    }
};
이로써 C++ 프로그래머는 좀더 자연스러움을 느끼게 될꺼야. 이제는 언제나 그래왔던 것처럼, 소멸자에서 리소스를 해제할 수 있게 되지. 컴파일러는 IDisposable::Dispose 메소드를 구현하기 위해, 이에 필요한 IL 코드를 만들 것인데, 여기에는 가비지 콜렉터가 그 객체에 해당하는 Finalize 메소드 호출을 막는 일도 포함되. 사실, C++/CLI에서 명시적으로 Dispose 메소드를 구현하는 일은 문법적으로 옳은 일이 아니야. IDisposable에서 상속받는 행동은 컴파일 오류를 유발시킬 거야. 물론, 한번 이 타입이 컴파일되면, 이를 사용할 모든 CLI 언어는, 그 타입을 구현한 언어가 어떤 방법을 사용했던지 간에, Dispose 패턴을 만나게 될거야. C#에서는 Dispose 메소드를 직접 호출할 수 있거나, 마치 그 타입이 C#에 정의되어있는 것처럼, using문을 사용할 수 있어. 하지만 C++에서는 어떨까? 어떻게 하면 객체에 기반한 힙의 소멸자를 호출할 수 있을까? 물론 delete 연산자를 이용해서야! delete 연산자를 핸들에 적용하면 그 객체의 Dispose 메소드가 호출될꺼야. 객체의 메모리는 가비지 콜렉터에 의해서 관리된다는 사실을 떠올려봐. 우리는 지금 논하는 요지는 메모리를 해제하는 데에 있는 것이 아니라, 오직 객체가 담고 있는 리소스를 해제하는 데에 있어.

Derived^ d = gcnew Derived();
d->SomeMethod()
delete d;
해서, delete 연산자에 넘겨지는 표현식이 핸들이면, 그 객체의 Dispose 메소드가 호출된다. 만약 그 참조 타입에 연결된 루트(root)가 더이상 없다면, 가비지 콜렉터는 특정 시점에 그 객체의 메모리를 모으는(콜렉션) 데에 자유로워진다. 그 표현식이 네이티브 C++ 객체라면, 메모리가 힙에 반환되기 전에 그 객체의 소멸자가 호출된다.

분명 객체의 수명 관리 구문은 더욱 네이티브 C++에 가까워졌지만, delete 연산자를 호출하는 데 있어 기억해야할 오류를 유발하기 쉬운 점이 남아 있긴 하지. C++/CLI를 이용하면 참조 타입에 대해서도 스택의 의미론(semantic)을 채용할 수 있어. 이 말이 의미하는 바는, 스택에 객체를 할당하는데 이용하는 구문을 참조 타입에 대해서도 사용할 수 있다는 것이야. 컴파일러는 네가 C++에서 기대할 의미론을 제공하는 데에도 신경쓸거야. 뿐만 아니라, 컴파일러는 CLR의 요구, 즉 실질적으론 그 객체가 managed 힙에 할당되야 한다는 것도 만족시켜야 해.

Derived d;
d.SomeMethod();
여기서, d가 스코프를 벋어나게 되면, dDispose 메소드가 호출되어 자신의 리소스를 해제할꺼야. 또한 이 객체는 실제론 managed 힙에 할당되기 때문에, 가비지 콜렉터는 알아서 적당한 시간에 그 리소스를 해제하겠지. 위의 ADO.NET 예제는 C++/CLI로 다시 작성하면 아래처럼 되.

SqlConnection connection("Database=master; Integrated Security=sspi");

SqlCommand^ command = connection.CreateCommand();
command->CommandText = "sp_databases";
command->CommandType = CommandType::StoredProcedure;

connection.Open();

SqlDataReader reader(command->ExecuteReader());

while (reader.Read())
{
    Console::WriteLine(reader.GetString(0));
}