C++/CLI: .NET 프레임워크 프로그래밍을 위한 가장 강력한 언어(1/2)에서 이어지는 글입니다.
타입 되돌아보기
박싱(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;
C++
복사
우리가 이미 배웠다시피, 문제는 박싱이 단순한 업캐스트(upcast)가 아니라는 데 있어. 그보다는 박싱이란 잠재적으로 값비싼 연산인, 값에서 객체로의 변환이야. 이러한 이유로, MC++에서는 __box 키워드를 이용하여 박싱을 명시적으로 행하지.
int i = 123;
Object* o = __box(i);
C++
복사
물론 MC++에서는 값을 박싱할 때에 정적 타입 정보를 버리지 않아도 돼. 하지만 C#에는 이러한 기능이 없지.
int i = 123;
int __gc* o = __box(i);
C++
복사
강력히 타입에 묶여 박싱된(strongly-typed boxed) 값에는 dynamic_cast를 사용하지 않고 단순히 그 객체를 역참조함으로써 값 타입으로의 재변환(unboxing)을 이룰 수 있다는 이점이 있지.
int c = *o;
C++
복사
물론 MC++에서의 명시적 변환으로 인한 구문적(syntactic) 부하는 대부분의 경우에서 너무 크다고 증명되었어. 이러한 이유로, C++/CLI 언어 설계 중 이 부분이 변경되어, 묵시적으로 변환하는 C#의 그것으로 바뀌었지. 이와 동시에, C++/CLI는 다른 .NET 언어가 표현할 수 없는 강력히 타입에 묶여 박싱된(strongly-typed boxed) 값을 직접적으로 표현하는 타입 안정성(type-safety)을 간직했어.
int i = 123;
int^ hi = i;
int c = *hi;
hi = nullptr;
C++
복사
물론 이 것이 암시하는 바는 객체를 가리키지 않는 핸들을 0으로 초기화할 수 없다는 것인데(포인터와는 달리), 왜냐하면 이렇게 하면 단순히 값 0를 박싱하는 결과를 가져오걸랑. 바로 이것이 nullptr 상수이 존재하는 이유야. 이 상수는 어떤 핸들에건 대입될 수 있어, C#의 null 키워드와 동일한 역할을 하지. 비록 nullptr가 C++/CLI 언어에 새로이 도입된 키워드이긴 하지만, Herb Sutter와 Bjarne Stroustrup이 표준 C++에서조차 포인터에 사용하라고 추천하는 놈이기도 해.
참조 타입과 값 타입 작성하기
다음의 몇몇 절에서는 CLR 타입을 작성하는 데 필요한 몇몇 세부 사항에 대해 다룰거야.C#에서의 class 키워드는 참조 타입을 선언하기 위해 사용하고, struct 키워드는 값 타입을 선언하기 위해 사용하지.
class ReferenceType {}
struct ValueType {}
C++
복사
C++에는 이미 class와 struct 키워드에 대해 잘 정의된 의미가 붙어있기 때문에, C#에서의 사용법은 C++에는 해당되지 않아. 원래의 언어 설계중에는, 참조 타입을 나타내기 위해 class 앞에 __gc 키워드를, 값 타입을 위해 __value 키워드를 놓았었지.
__gc class ReferenceType {};
__value class ValueType {};
C++
복사
C++/CLI에서는 사용자 식별자와 충돌을 일으키지 않을 장소에 새로운 키워드를 제공하지. 참조 타입의 경우 class나 struct 앞에 ref를 붙이는 것이고, 비슷한 방법으로 값 타입을 선언할 때는 value를 붙이는 것이야.
ref class RefrerenceType {};
ref struct ReferenceType {};
C++
복사
class와 struct중 무엇을 선택할지는 멤버들의 가시성에 대한 기본 값을 무엇으로 하느냐에 달려있어. 주된 차이는 CLR 타입이 오직 public 상속만 지원한다는 것이야. 상속에 private이나 protected 키워드를 사용하면 컴파일 오류를 일으킬꺼야. 그러므로 상속에 public 키워드를 사용하는 것은 문법적으로 문제는 없지만, 코드만 장황하게 보일 뿐이지.
접근성(accessibility)
CLR은 많은 양의 접근성 수정자(accessibility modifier)를 제공하는데, 그 수는 클래스 멤버 함수와 변수에 적용키 위해 네이티브 C++에서 제공된 접근성 수정자의 개수를 넘어서지. 이 뿐만 아니라, CLR에서는 중첩된 타입 외에도 네임스페이스 타입의 접근성도 정의할 수 있어. 최하위 수준의 언어가 되어야 한다는 목표를 위해서, C++/CLI는 CLR을 타깃으로 한 그 어떤 상위 수준 언어보다도 더 세밀히 접근성을 제어할 수 있도록 설계되었지.
네이티브 C++에서의 접근성과 CLR에 의해 정의된 접근성간의 차이를 알아보자구. 네이티브 C++의 경우, 접근성 수정자는 동일한 프로그램 범위 안의 다른 코드에서 멤버에 접근하는 것을 제한하기 위해 사용되는 반면, CLR의 경우에는 타입 자체의 접근성도 정의해야 하고, 그 타입의 멤버를 동일한 어셈블리 안의 다른 코드에서뿐만 아니라, 다른 어셈블리에서도 참조하는 것에 대한 제한을 두기 위해 사용해.
네임스페이스나 class와 delegate 타입같은 중첩되지 않은 타입은, 타입 정의부 앞에 public이나 private를 붙임으로써, 자신이 속한 어셈블리 외부에 대한 그 자신의 가시성을 지정할 수 있어.
public ref class ReferenceType {};
C++
복사
이 가시성을 명시적으로 지정해주지 않는다면, 이 타입은 자신이 속한 어셈블리에서만 보이게 될거야.멤버에 적용되는 접근 지정자는 확장되어 두 개의 키워드를 한번에 쓸 수 있는데, 이렇게 함으로써 그 지정자 뒤에 붙을 이름에 대해 내부와 외부 접근을 동시에 지정할 수 있지. 두 개를 지정함으로써 접근 제한을 좀더 세밀히 할 수 있게된 이 방법은, 어셈블리 외부에서의 접근성과 어셈블리 내부에서의 접근성을 정의하기 위한거야. 만일 키워드 하나만 사용한다면, 내부와 외부 접근성 모두에 적용될거야. 이러한 설계로 인해, 타입과 멤버의 접근성을 훨씬 더 유연하게 정의할 수 있게 되지.
public ref class ReferenceType
{
public:
//어셈블리 내부와 외부 모두에서 보인다.
private public:
//어셈블리 내부에서만 보인다.
protected public:
//어셈블리 외부에서는 파생 타입만, 내부에서는 모두 볼 수 있다.
};
C++
복사
속성(property)
중첩된 타입과는 별개로, CLR 타입은 오직 메소드와 필드만을 담을 수 있어. 프로그래머가 좀더 명확하게 그의 의도를 담으려면, 메타데이터를 이용할 수 있는데, 이로써 특정 메소드들을 다른 프로그래밍 언어가 속성(property)으로 다루도록 지정할 수 있지. 사실, CLR 속성이란 그 속성을 담은 타입의 멤버야. 하지만, 속성은 저장 공간을 할당받지 않기에, 단지 그 속성을 구현한 각각의 메소드에 대한 이름붙은 참조에 불과해. 각기 다른 컴파일러들은 소스 코드에서 속성에 해당하는 구문을 마주쳤을 때, 그에 맞는 메타데이터를 만들어내야 하지. 이로써 그 타입의 사용자는 자신이 사용하는 언어에 해당하는 속성 구문을 이용하여 그 속성을 구현하는 get과 set 메소드를 사용할 수 있어. 네이티브 C++과는 달리, C#은 속성에 대한 지원이 매우 뛰어나지.
public string Name
{
get
{
return m_name;
}
set
{
m_name = value;
}
}
C++
복사
C# 컴파일러는 각기 해당하는 get_Name과 set_Name 메소드뿐만 아니라, 연관성을 지시하는 메타데이터도 포함시킬 것이야. MC++에서는 __property 키워드를 도입하여, 메소드가 속성(property semantic)을 구현한 것이라는 것을 나타내.
__property String* get_Name()
{
return m_value;
}
__property String* set_Name(String* value)
{
m_value = value;
}
C++
복사
이 모습은 분명 이상적이지가 못해. 추해보이는 __property 키워드를 사용해야할 뿐만 아니라, 이들 두 멤버 함수가 실제로는 함께 묶여있다는 어떠한 표시도 없단말야. 이 방법은 유지보수 시에 잡아내기 힘든 버그를 유발시킬 수도 있지. 하지만 속성에 대한 C++/CLI에서의 설계는 훨씬 더 간결하고, C#의 그것에 훨씬 더 비슷해졌어. 이제 보겠지만 그 뿐만 아니라, 더 강력하기 조차 해.
property String^ Name
{
String^ get()
{
return m_value;
}
void set(String^ value)
{
m_value = value;
}
}
C++
복사
대단히 향상된 모습이지. 컴파일러는 get_Name과 set_Name 메소드뿐만 아니라, 이를 속성이라고 선언하는 메타데이터도 함께 만들어낼꺼야. 또 하나 좋은 점은 이 속성 값을 네 어셈블리 밖의 코드에서도 읽어낼 수 있는 반면, 쓸때는 네 어셈블리 안의 코드에서만 가능하게끔 만들 수도 있다는 것이야. 이렇게 하려면, 속성 이름 뒤에 붙는 중괄호 안에서 접근 지정자를 사용하면 되지.
property String^ Name
{
public:
String^ get();
private public:
void set(String^);
}
C++
복사
속성 지원에 대해 마지막으로 알아둘 만한 점은 C++/CLI는 속성을 얻고 쓰는 데 특별한 처리가 필요없는 곳을 위한 약식 구문도 지원한다는 것이야.
property String^ Name;
C++
복사
여기서도 컴파일러는 get_Name과 set_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++
복사
C++에서는 이 쓰래드 풀을 사용하는 것이 단순하기도 하고 자연스럽게 느껴지지.
class Service
{
public:
void AsyncRun()
{
ThreadPool::QueueUserWorkItem(Run, this);
}
void Run()
{
// 긴 연산이 들어갈 자리
}
}
C++
복사
분명 ThreadPool 클래스는 특정 시그니쳐로 이루어진 함수 포인터가 있어야만 동작한다는 점에서 매우 제한적이야. 하지만 이 제한성은 단지 이 예제에 해당하는 것이지, C++ 그 자체에 해당하는 것이 아니야. 일반화된 펑터에 대한 자세한 설명은 Andrei Alexandrescu가 쓴 Modern C++ Design을 참고하면 될거야.C++ 프로그래머가 비동기 프로그래밍을 구현해야 하거나 비동기 프로그래밍에 관한 풍부한 라이브러리를 얻고자 한다면, 이에 대한 지원을 내장한 CLR이 그야말로 적합하지. 대리자는 함수 포인터와 비슷하지만, 대리자가 특정 메소드에 묶일 수 있는지 여부를 타깃 객체라던가 그 메소드가 속한 타입이 결정 못한다는 점이 달라. 시그니쳐가 일치하는 한, 메소드는 나중에 불러들이기 위해 대리자에 추가시킬 수 있어. 이 특징은 C++ 템플릿을 이용하여 어떤 클래스의 멤버 함수라도 사용될 수 있도록 만든 위 예제와 적어도 의미상으로는 비슷하지. 물론 대리자는 이보다 훨씬 더 많은 것들을 제공하는데, 특히 간접 메소드 호출(invocation)에 대해선 극도로 유용한 메카니즘이라고 할 수 있지. 다음은 C++/CLI를 사용하여 대리자 타입을 정의하는 예제야.
delegate void Function();
C++
복사
대리자 사용법은 간단해.
ref struct ReferenceType
{
void InstanceMethod() {}
static void StaticMethod() {}
};
// 대리자를 생성하고 인스턴스 멤버 함수를 묶는다(bind).
Function^ f = gcnew Function(gcnew ReferenceType,
ReferenceType::InstanceMethod);
// 또한 대리자를 결합하는 방법으로 정적 멤버 함수를 묶어서
// 대리자 사슬(chain)을 형성한다.
f += gcnew Function(ReferenceType::StaticMethod);
//두 개의 함수 모두를 불러낸다.
f();
C++
복사
결론
물론, Visual C++ 2005 컴파일러 말고도 C++/CLI만해도 논할 사항은 더 많지만, 이 칼럼이 CLR을 타깃으로 하는 프로그래머를 위해 C++/CLI가 무엇을 제공하는지에 대한 좋은 소개글이 되었으면 좋겠어. 이 새로운 언어를 사용하면, 생산성, 간결함, 성능을 희생시키지 않고도 .NET 애플리케이션을 C++로 작성하는 데 있어 유래가 없던 강력함과 우아함을 느끼게 될것이라 믿어 의심치 않아.아래의 테이블은 일반적으로 사용되는 대부분을 빠르게 참조할 수 있도록 요약한 것이야.
설명 | C++/CLI | C# |
참조 타입 할당 | ReferenceType^ h
= gcnew ReferenceType; | ReferenceType h
= new ReferenceType(); |
값 타입 할당 | ValueType v(3, 4); | ValueType v
= new ValueType(3, 4); |
참조 타입의 스택 의미론 | ReferenceType h; | N/A |
Dispose 메서드 호출 | ReferenceType^ h
= gcnew ReferenceType;
delete h; | ReferenceType h
= new ReferenceType();
((IDisposable)h).Dispose(); |
Dispose 메서드 구현 | ~TypeName {} | void IDisposable.Dispose() |
Finalize 메서드 구현 | !TypeName {} | ~TypeName {} |
박싱 | int^ h = 123; | object h = 123; |
언박싱 | int^ h = 123;
int c = *hi; | object h = 123;
int i = (int)h; |
참조 타입 정의 | ref class ReferenceType {};
ref struct ReferenceType {}; | class ReferenceType {} |
값 타입 정의 | value class ValueType {};
value struct ValueType {}; | struct ValueType {} |
속성 사용하기 | h.Prop = 123;
int v = h.Prop; | h.Prop = 123;
int v = h.Prop; |
속성 정의하기 | property String^ Name
{
String^ get()
{
return m_value;
}
void set(String^ value)
{
m_value = value;
}
} | string Name
{
get
{
return m_value;
}
void set
{
m_value = value;
}
} |