13가지 주의 사항: Visual C++ .NET 프로그램을 Visual Studio 2005로 이식하기 전에 알아야 할 13가지 내용
Stanley B. Lippman
Microsoft Corporation
적용 대상:
Microsoft Visual C++ .NET
Microsoft Visual C++ 2005
Microsoft Visual Studio 2005
Microsoft Visual Studio .NET
요약: 개발자가 응용 프로그램을 Microsoft Visual Studio 2005로 이식할 때 주의해야 할 내용에 대해 Stan Lippman이 소개합니다.
C++ 개발자들은 마치 동생이 새로 생긴 형과 같은 기분을 느낄 때가 있습니다. 새로 태어난 아기는 모두의 주목을 끌고, 서로 안아 보려고 다툴 정도로 귀여움을 독차지합니다. 그러나 형이 되면 관심을 받기는커녕 머리를 쓰다듬어 주는 사람이 있는 것도 다행일 것입니다. 이런 상황이 닥치면 머리카락이 쭈뼛 서겠지요. 갑자기 사람들의 관심에서 멀어지면 마음의 상처를 입을 테니까요. 실제로 기술 분야에서는 이런 현상이 더욱 심합니다. 항상 변화가 일어나는 분야이므로 흐름에 뒤처지지 않는 것이 생존의 비결입니다.
물론 현재 .NET은 새로운 기술입니다. 모든 사람이 C#에 환호하고 있을 정도입니다. C++ 프로그래머들이 C#을 배워야 하지 않나 걱정하는 것도 당연한 일입니다. 실제로 .NET에 대해 언급할 때 C++를 언급하는 경우는 거의 없습니다. 그나마도 새로운 기술의 비교 대상으로 사용되는 경우입니다. 기술 분야에서 한 발 뒤처지는 것은 생존 경쟁에서의 낙오를 뜻합니다.
그렇다면 C++ 프로그래머는 이제 시대의 낙오자일까요? 절대 그렇지 않습니다! 이 기사에서 이번 Microsoft Visual Studio .NET 릴리스의 새로운 기능에 대해 소개하고, 향후 발표될 보다 새로운 기술에 대한 몇 가지 아이디어를 알려 드리겠습니다. 저희 Visual C++ 팀원 모두는 여러분이 이 기사를 읽고 깜짝 놀랄 것이라 믿습니다.
위 내용은 Microsoft Visual Studio.NET에 대한 Visual C++ 작업을 소개하는 MSDN 기사에서 제가 작성한 글입니다. Microsoft Visual Studio 2005 베타 릴리스의 발표는 위에서 말한 제 약속이 실현 가능성이 있는지 확인할 수 있는 좋은 기회입니다. 이 문제는 Visual C++ 프로그래머에게는 결코 근거 없는 질문이 아닙니다. 또한 지금은 .NET 환경에서 Visual C++의 '미래'가 어떨지는 중요하지 않습니다. 오히려 지금 '당장'의 존재 가치조차 위협받고 있기 때문입니다. 따라서 우리는 이러한 내용을 기준으로 하여 Visual C++의 CLI 바인딩을 다시 디자인했습니다.
사실을 터놓고 말하자면 Visual C++를 .NET에 통합하는 원래 작업의 성과는 그다지 만족스럽지 못했습니다. 기존 기본 코드를 .NET 응용 프로그램에 통합하기 위한 브리지를 제공하고자 하는 기본 목적은 성공적이었습니다. IJW(It Just Works)라 불리는 이 기술은 매우 뛰어났습니다. 하지만 불행히도 이 언어의 다른 면은 그다지 성공적이지 못했습니다. 개인적으로는 Managed Extensions에서 코드를 작성할 때보다 C# 코드를 작성하는 것이 C++와 더 흡사하다고 느꼈습니다. 그래서 그 내용은 수정해야 했습니다.
여기서 주목할 만한 긍정적인 측면은 언어를 단지 수정한 것이 아니라 재창조했다는 것입니다. 명확한 종료, 자동 멤버 단위 복사 및 초기화 지원, 강력한 연산자 오버로드 지원을 추가했습니다. 또한 STL 지원은 물론 템플릿 및 CLI 일반 매개 변수화 형식 메커니즘에 대한 지원도 모두 추가했습니다. 그래서 이제 .NET에서 프로그래밍할 때 저는 C++를 가장 선호합니다. 여러분들에게도 선호되는 언어가 되기를 바랍니다.
또한, 주목할 만한 부정적 측면 역시 언어를 단지 수정한 것이 아니라 재창조했다는 것입니다. A에서 B로 바꾸는 것, 이전 언어 바인딩에서 새 언어 바인딩으로 바꾸는 것은 마치 현실에서 환상 속으로 날아가는 것과 마찬가지입니다. 단지 기술적인 변화뿐 아니라 약간의 마법이 필요할 정도입니다.
좀 더 쉬운 전환을 위해 다음과 같은 세 가지 기본 영역에서 작업을 진행했습니다.
컴파일러는 특수 스위치(\clr:old_syntax)를 통해 원래 구문을 계속 받아들입니다.
현재는 작업 능률이 약 80% 정도인 비공식 변환 도구를 사용하고 있습니다. 두 번째 베타가 발표될 때쯤이면 이 도구를 테스트할 수 있을 것으로 예상합니다.
우리가 제공하고 있는 이 문서와 동봉 변환 가이드, 그리고 Moving Your Programs from Managed Extensions for C++ to C++/CLI 에는 각 언어의 변경 내용 목록이 원래 코드 및 수정된 코드 스니펫과 더불어 자세하게 소개되어 있습니다. 각 언어를 변경한 이유에 대한 내용도 포함되어 있습니다.
이 기사는 개발자를 위해 요약된 내용이며, 안전하고 원활한 이식을 위해 고려해야 할 13가지 문제를 집중적으로 다루고 있습니다. 차이점을 쉽게 확인할 수 있도록 부록에 문제점이 표 형식으로 나열되어 있습니다.
CLI에 대한 원래 언어 바인딩의 이름은 Managed Extensions to C++입니다. 수정된 언어 바인딩은 C++/CLI라고 하며, 이 바인딩에 대한 ECMA 표준도 현재 개발 중입니다. 편의를 위해 이 기사에서는 원래 바인딩을 V1이라고 하고 수정된 바인딩을 V2라고 하겠습니다.
다음과 같은 일반 범주에서 언어를 수정했습니다.
구문. 보다 수준 높고 재미있는 프로그래밍을 위해 CLI 형식을 정의 및 조정하는 방법을 변경했습니다. 이는 특히 CLI 배열과 스칼라 및 인덱스 속성의 사양에서 그러합니다. 이러한 변화는 포괄적인 것이지만, 기계 분야에 폭넓게 적용된 것이라고 볼 수도 있습니다.
CLI 준수. CLI 개체 모델은 C++ 모델과는 몇 가지 면에서 크게 다릅니다. V1 바인딩은 문자열 리터럴 처리, CLI 열거형 처리, 값 형식 정의 등의 작업에서 간혹 정상적으로 수행되지 않는 경우가 있었습니다. 이 변화를 통해 동적 프로그래밍 모델의 충실도를 높일 수 있을 것으로 확신합니다. 불행히도 V1에서 V2로의 기계적인 변환이 불가능한 경우도 있습니다. 예를 들어 값 형식은 더 이상 기본 생성자를 지원하지 않으며(이 기사의 10번 항목), CLI 열거형을 더 이상 전방 선언할 수 없습니다(이 기사의 7번 항목).
CLI 향상. C++ 프로그래밍에서는 복사 생성 지원 기능이 없고 참조 형식에서 소멸자가 자동으로 호출되므로 작업이 까다로울 뿐 아니라 오류가 발생하기도 쉽습니다. 이러한 요소는 프로그램의 무결성을 향상시키는 개체 관리의 유용한 패턴을 보여 줍니다. 이러한 패턴과 ISO-C++의 기타 디자인 패턴이 V2의 CLI 바인딩에 통합되었습니다. 이렇게 긍정적인 측면도 있는 반면 부정적인 측면도 있습니다. 즉, V1에서 V2로의 기계적인 변환이 불가능한 경우도 있습니다. 예를 들어 명확한 종료가 지원됨으로써 V1과 V2 간에 클래스 소멸자의 의미가 달라졌으며(이 기사의 6번 항목), 인터페이스 멤버의 명시적인 재정의는 이제 가상 함수 재정의 메커니즘에 통합되었습니다(이 기사의 12번 항목).
새로운 구문
사용자들은 불평을 할 때를 제외하고는 프로그래밍 언어의 구문에 대해 신경조차 쓰지 않습니다. 대부분의 경우 개발자가 할 수 있는 일이라고는 프로그래밍 언어가 사용자에게 혼동을 일으키지 않도록, 그리고 사용자가 프로그래밍 언어를 사용하여 프로그램을 구현하고 배포하는 데 너무 많은 시간을 소비하지 않도록 조금이라도 더 쉽게 작성하는 것뿐입니다. 언어 구문에서 변명의 여지가 없는 가장 큰 문제는 프로그래머조차도 프로그램의 의미가 무엇인지 확신할 수 없는 경우입니다. 이는 소프트웨어의 품질은 물론 프로그래머로서의 자격까지 의심받을 수 있습니다. 새로운 언어 바인딩은 복잡한 소프트웨어를 열심히 개발할 수 있는 좀 더 나은 환경을 제공합니다. .NET을 사용하여 개발 중인 Visual C++ 프로그래머라면 언어가 크게 향상되었다는 것에 동의하리라 믿습니다.
1. 문맥 키워드로 __(이중 밑줄) 대체
이중 밑줄(__)이 없어졌다는 것이 가장 크게 드러나는 차이점입니다. V1에서 이중 밑줄이 사용된 이유는 크게 두 가지였습니다. 즉, (a) 언어 확장을 설정하는 ISO 정책을 준수하고, (b) 새 키워드를 사용할 때의 혼란 방지 전략을 제공하기 위함이었습니다. 합당하고 합리적인 동기라 할 수 있었죠. V2에서 이중 밑줄이 없어진 이유는 이로 인해 구문이 지저분해져서 복잡하고 한 눈에 보기 어려웠기 때문입니다. (b)에 대한 V2의 해결책은 문맥 키워드를 사용하는 것입니다. (a)에 대한 해결책은 좀 더 까다로우며 전체 변환 가이드에서 수행됩니다.
문맥 키워드는 특정 프로그램 컨텍스트에서 특수한 의미를 가집니다. 예를 들어 일반 프로그램 내에서 sealed는 일반 식별자로 취급됩니다. 그러나 관리되는 참조 클래스 유형의 선언 부분에 있을 경우에는 해당 클래스 선언의 컨텍스트에서 키워드로 취급됩니다. 그러므로 언어에서 새 키워드를 사용할 때 발생할 수 있는 혼란이 최소화됩니다. 이는 기존 코드 기반 사용자에게는 매우 중요한 요소입니다. 또한 이 새 기능의 사용자는 최고의 추가 언어 기능을 활용할 수 있습니다. 이러한 추가 기능은 원래 언어 디자인에서 부족하다고 생각되어 이번에 보강한 것입니다.
표 1.1에는 CLI 형식을 선언하는 구문의 변경 사항 목록이 있습니다.
표 1.1 CLI 형식 구문 변경 사항
CLI 형식 Managed Extension C++/CLI
참조 클래스 __gc class R ref class R
값 클래스 __value class V value class V
추상 클래스 __gc __abstract class R ref class R abstract
봉인 클래스 __gc __sealed class R ref class R sealed
인터페이스 클래스 __gc __interface IBar interface class IBar
CLI 열거형 __value enum E enum class E
대리자 형식 __delegate void CallBack() delegate void CallBack()
2. 추적 핸들(^)로 포인터(*) 대체
V1에서는 포인터 구문을 사용하여 참조 형식의 개체를 선언합니다. 수정된 언어 디자인에서는 공식적인 이름은 추적 핸들이며 비공식적으로는 햇(hat)이라고 하는 새로운 선언 토큰(^)을 사용하여 참조 클래스 형식 개체를 선언합니다. '추적'이란, 참조 형식이 CLI 힙 내에 배치되므로 가비지 수집 힙 압축 중에 해당 위치를 손쉽게 이동할 수 있다는 의미입니다. 추적 핸들은 런타임 동안 쉽게 업데이트할 수 있습니다. (a)추적 참조(%) 및 (b)내부 포인터(interior_ptr<>)는 서로 유사한 개념입니다. 이 변경 내용에 대해서는 주 문서를 참고하십시오. 예를 들면 다음과 같습니다.
// CLI 참조 형식의 V1 선언
String * ps = S"a string literal";
// CLI 참조 형식의 V2 선언
String ^ ps = "a string literal";(참고: 프로그래머 코멘트는 샘플 프로그램 파일에는 영문으로 제공되며 기사에는 설명을 위해 번역문으로 제공됩니다.)
System::String 유니코드 표시로 수준이 올라가는 문자열 리터럴의 처리도 정리했습니다. 이 예제에서 프로그래머는 더 이상 문자열 리터럴이 System 리터럴이 되도록 직접 확인할 필요가 없습니다.
따라서 CLI 힙에 배치한 참조 형식과 값 형식 모두를 통해 단일 구문을 제공할 수 있게 되었습니다. C# 및 Microsoft Visual Basic .NET에서와는 달리, CLI에 대한 C++ 바인딩으로 인해 프로그래머는 값 형식의 boxed 인스턴스를 직접 조정할 수 있습니다. 이는 훨씬 효율적인 방법입니다. 다음은 V1과 V2에서 작업이 처리되는 방식입니다.
double result = 3.14159;
// V1 구문
__box double * br = __box( result );
// V2 구문
double^ br = result;
포인터 구문의 사용은 다음과 같은 두 가지 주요 영역에서 문제가 있었습니다. 변경 이유에 대한 좀 더 자세한 설명은 함께 제공되는 변환 가이드에서 찾아볼 수 있습니다.
포인터 구문을 사용하는 경우에는 오버로드된 연산자를 참조 개체에 직접 적용할 수 없었습니다. 보다 직관적인 r1+r2 대신 r1->op_Addition(r2) 같은 내부 이름을 통해 연산자를 호출해야 했습니다.
캐스팅 및 포인터 산술 등과 같이 가비지 수집된 힙에 저장된 개체에는 허용되지 않는 포인터 연산이 다수 있습니다. 이로 인해 사용자들이 혼란스러워할 수 있습니다. 별도의 토큰을 통해 CLI 참조 형식을 보다 잘 이해할 수 있을 것입니다.
구문이 R*에서 R^로 변경됨에 따라 두 가지 사항이 부수적으로 바뀌었습니다. 즉, 연산자 new가 CLI 고유의 힙 연산자로 대체되었고 null 추적 핸들을 나타내는 특수 토큰을 사용하게 되었습니다.
새 CLI 힙 할당 연산자는 gcnew입니다. 예를 들면 다음과 같습니다.
// V1 구문
StreamReader *ifile = new StreamReader( fileName );
NativeClass * pnc = new NativeClass( args );
// V2 구문
StreamReader ^ifile = gcnew StreamReader( file );
NativeClass * pnc = new NativeClass( args );
V1에서는 아무런 개체도 처리하지 않는 참조 형식을 다음과 같이 초기화합니다.
// V1: 확인... obj가 다른 개체를 참조하지 않도록 설정
Object * obj = 0;
// V1: 오류... 명시적인 boxing 없음
Object * obj2 = 1;
V2에서는 값 형식을 Object로 초기화하거나 할당하면 해당 값 형식에 대한 암시적인 boxing이 발생합니다. 따라서 V2에서는 obj 및 obj2 둘 다 각각 0 및 1이라는 값을 보유하는 처리된 boxed Int32 개체로 초기화됩니다. 예를 들면 다음과 같습니다.
// V2: 확인... 0 및 1에 대해 모두 암시적인 boxing 부여
// 그러나 obj에는 부여하지 않음!
Object ^ obj = 0;
Object ^ obj2 = 1;
따라서 추적 핸들의 명시적인 초기화, 할당 및 비교에서 아무런 개체도 참조하지 않도록 하기 위해 nullptr이라는 새 키워드를 도입했습니다. 이는 각각의 0 인스턴스를 대체하게 됩니다. 따라서 V1 예제가 올바르게 수정된 내용은 다음과 같습니다.
// V2: 확인... obj가 다른 개체를 참조하지 않도록 설정
Object ^ obj = nullptr;
// V2: 확인... obj2를 Int32^로 초기화
Object ^ obj2 = 1;
3. CLI 배열 구문의 단순화
V1에서 CLI 배열 개체의 선언은 표준 배열 선언의 다소 비직관적인 확장이었습니다. 이 표준 배열 선언에서는 __gc 키워드가 배열 개체의 이름, 그리고 쉼표가 들어갈 수 있는 부분 사이에 위치합니다. 예를 들면 다음과 같습니다.
// V1 구문
void PrintValues( Object* myArr __gc[]);
void PrintValues( int myArr __gc[,,]);
V2에서는 STL 벡터 선언을 제시하는 템플릿 형식 선언을 사용하여 이를 단순화했습니다. 첫 번째 매개 변수는 요소 형식을 나타냅니다. 두 번째 매개 변수는 배열 차원을 지정합니다. 기본값은 1이므로, 배열이 다차원인 경우에만 두 번째 인수가 필요합니다. 배열 개체 자체는 추적 핸들이므로 햇(hat)이 필요합니다. 요소 형식도 참조 형식인 경우에는 요소 형식에도 햇(hat)이 필요합니다. 예를 들면 다음과 같습니다.
// V2 구문
void PrintValues( array<Object^>^ myArr );
void PrintValues( array<int,3>^ myArr );
4. 속성 통합
V1에서는 각각의 set 또는 get 속성 접근자가 독립 멤버 함수로 지정되어 있습니다. 각 메서드의 선언에는 __property 키워드가 접두어로 붙습니다. 메서드 이름은 set_ 또는 get_으로 시작하며 그 뒤에 실제 속성 이름이 옵니다. 예를 들면 다음과 같습니다.
이 방법은 속성과 연결된 기능을 확장하며 사용자가 관련 set 및 get을 어휘적으로 통합해야 하므로 혼란스러웠습니다. 더구나 어휘가 장황해서 깔끔한 느낌도 없습니다. 수정된 언어 디자인에서는 속성 키워드 뒤에 속성 형식과 간단한 이름이 옵니다. set 및 get 액세스 메서드는 속성 이름 뒤의 블록 안에 배치됩니다. C#과 달리 액세스 메서드의 서명이 지정되어 있습니다. 예를 들면 다음과 같습니다.
// V2 구문
public ref class Vector sealed{
float _x;
public :
property double x
{
double get(){ return _x; }
void set( double newx ){ _x = newx; }
} // 참고: 세미콜론 없음.
};
인덱스 속성
V1의 인덱싱된 속성에서 가장 큰 단점은 클래스 수준의 하위 스크립팅 기능이 없다는 것입니다. 그보다는 심각하지 않지만, 두 번째 단점은 일반 속성과 인덱싱된 속성을 시각적으로 구분하기가 어렵다는 것입니다. 매개 변수의 수가 유일한 구분 수단입니다. V1의 인덱싱된 속성에는 스칼라 속성과 동일한 문제가 있습니다. 즉, 접근자가 원자 단위로 취급되지 않고 개별 메서드로 분리됩니다. 예를 들면 다음과 같습니다.
// V1 구문
public __gc class Vector;
public __gc class Matrix
{
float mat[,]; // V1 배열 구문...
public :
__property void set_Item( int r, int c, float value);
__property int get_Item( int r, int c );
__property void set_Row( int r, Vector* value );
__property int get_Row( int r );
};
V2에서는 인덱스 속성이 인덱서 이름 뒤에 오는 대괄호([,])로 구분됩니다. 이 대괄호는 각 인덱스의 수와 형식을 나타냅니다. 다음은 새 구문으로 다시 캐스팅한 매트릭스 선언입니다. 전방 CLI 클래스 선언은 더 이상 벡터 클래스의 전방 선언에 나타나 있는 것과 같이 공개 및 개인 액세스 수준을 나타낼 수 없습니다.
// V2 구문
// 이제 여기서 공개를 지정할 수 없음...
ref class Vector;
public ref class Matrix {
private :
array<float, 2>^ mat; // V2 배열 구문...
public :
property int Item[int,int]
{
int get( int r, int c );
void set( int r, int c, float value );
}
property int Row[int]
{
int get( int r );
void set( int r, Vector^ value );
}
};
클래스 수준 인덱서를 나타내기 위해 default 키워드를 다시 사용하여 명시적인 이름으로 대체합니다. 예를 들면 다음과 같습니다.
public ref class Matrix {
private :
array<float, 2>^ mat;
public :
// 확인. 이제 클래스 수준 인덱서 지정
// Matrix mat ...
// mat[ 0, 0 ] = 1;
// 기본 인덱서의 set 접근자 호출...
property int default[int,int]
{
int get( int r, int c );
void set( int r, int c, float value );
}
};
5. 연산자와 ISO-C++의 통합
아마도 V1에서 가장 놀라운 점은 연산자 오버로드 지원이 매우 비효율적이라는 것입니다. 예를 들어 참조 형식을 선언할 경우 기본 operator+ 구문을 사용하는 대신 연산자의 기본 내부 이름(예: op_Addition)을 명시적으로 작성해야 합니다. 그러나 이보다 더욱 어려운 점은 해당 이름을 통해 연산자를 명시적으로 호출해야 하기 때문에 (a) 직관적인 구문, (b) 새 형식을 기존 형식과 혼합할 수 있는 기능이라는 연산자 오버로드의 두 가지 주요 이점을 사용할 수 없게 된다는 것입니다. 예를 들면 다음과 같습니다.
Vector^ pa = gcnew Vector( 0.231, 2.4745, 0.023 ),
Vector^ pb = gcnew Vector( 1.475,4.8916,-1.23 );
Vector^ pc1 = pa + pb;
Vector^ pc2 = pa - pc1;
Vector^ pc3 = pc1 / pc2->x();
if ( pc1 == p2 ) // ...
}
의미의 변화
모든 전문 지식을 갖추고 있는 프로그래밍 패러다임을 버리고 친숙하지 않아 초보적인 오류를 범하기 쉬운 새로운 패러다임으로 바꾼다는 것은 어렵고 다소 두렵기까지 한 일입니다. V2에서는 다양한 C++ 고유의 확장 기능을 통해 CLI 바인딩의 영역을 넓힘으로써 동적 프로그래밍 패러다임으로 좀 더 친숙하게 이전할 수 있도록 했습니다. 멤버 단위 복사 구문과 수명이 다할 때 참조 형식 소멸자를 자동 호출하는 기능에 대한 지원이 여기에 포함됩니다.
6. 소멸자가 IDisposable::Dispose가 됨
가비지 수집기가 개체와 연결된 메모리를 확보하기 전에 연관된 Finalize() 메서드(있는 경우)가 호출됩니다. 이 메서드는 개체의 프로그램 수명에 구애받지 않으므로 슈퍼 소멸자 정도로 생각하면 됩니다. 이를 종료(finalization)라고 합니다. Finalize() 메서드의 호출 시기, 심지어는 호출 여부도 정의되어 있지 않습니다. 그렇기 때문에 가비지 수집은 명확하지 않은 종료를 나타낸다고 하는 것입니다.
명확하지 않은 종료는 동적 메모리 관리에 적합합니다. 사용 가능한 메모리가 어느 정도 부족해지면 가비지 수집기가 실행되며 모든 것이 정상적으로 작동합니다. 가비지 수집된 환경에서는 메모리를 확보하기 위한 소멸자가 필요하지 않습니다.
그러나 개체가 데이터베이스 연결이나 일종의 잠금 같은 중요한 리소스를 유지하고 있는 경우에는 명확하지 않은 종료가 정상적으로 작동하지 않습니다. 이 경우에는 해당 리소스를 최대한 빨리 해제해야 합니다. 원시 환경에서는 생성자/소멸자 쌍을 맞춤으로써 이러한 작업을 수행합니다. 개체의 수명이 끝나는 즉시, 개체를 선언한 로컬 블록의 완료 또는 throw된 예외로 인한 스택 해제를 통해 소멸자가 실행되어 리소스가 자동으로 해제됩니다. 이는 훌륭하게 작동합니다. 원래 언어 디자인에서는 이 기능이 작동하지 않아 매우 불편했습니다.
CLI에서 제공하는 해결책은 클래스가 IDisposable 인터페이스의 Dispose() 메서드를 구현하도록 하는 것입니다. 여기서 문제는 Dispose()에 사용자의 명시적 호출이 필요하다는 것입니다. 이 작업은 오류가 발생하기 쉬우므로 시대에 뒤처진 방식이라 할 수 있습니다. C# 언어는 특수한 using 문을 통해 간단한 형태의 자동화를 제공합니다. V1에서는 특수한 지원을 제공하지 않습니다.
V1에서는 참조 클래스의 소멸자가 다음의 두 단계를 통해 구현됩니다.
사용자가 제공한 소멸자의 이름은 내부에서 Finalize()로 바뀝니다. 클래스에 기본 클래스(CLI 개체 모델에서는 단일 상속만 지원됨)가 있는 경우 컴파일러는 사용자가 제공한 코드를 실행한 후에 해당 파이널라이저를 호출합니다. 예를 들어 V1 언어 사양에서 다음과 같이 일반적인 계층 구조를 가져온다고 가정해 봅시다.
__gc class A {
public :
~A() { Console::WriteLine(S"in ~A"); }
};
__gc class B : public A {
public :
~B() { Console::WriteLine(S"in ~B"); }
};
두 소멸자 모두 Finalize()로 이름이 바뀝니다. B의 Finalize() 메서드에는 WriteLine() 호출 뒤에 A의 Finalize() 메서드 호출이 추가되어 있습니다. 이것이 가비지 수집기가 종료 시 기본적으로 호출하는 내용입니다. 이 내부 변환은 다음과 같습니다.
// V1에서 소멸자의 내부 변환
__gc class A {
// ...
void Finalize() { Console::WriteLine(S"in ~A"); }
};
__gc class B : public A {
// ...
void Finalize() {
Console::WriteLine(S"in ~B");
A::Finalize();
}
};
두 번째 단계에서 컴파일러는 가상 소멸자를 합성합니다. V1 사용자 프로그램에서는 이 소멸자를 직접적으로 또는 delete 식을 적용하여 호출합니다. 이 소멸자가 가비지 수집기에 의해 호출되는 경우는 없습니다.
그러면 이 합성된 소멸자 내에는 어떤 내용이 들어갈까요? 두 개의 문이 포함됩니다. 하나는 더 이상 Finalize()가 호출되지 않도록 하는 GC::SuppressFinalize()에 대한 호출입니다. 그리고 다른 하나는 실제 Finalize() 호출입니다. 이 재호출은 해당 클래스에 대해 사용자가 제공한 소멸자를 나타냅니다. 다음은 이 내용을 표시한 것입니다.
__gc class A {
public :
virtual ~A()
{
System::GC::SuppressFinalize(this);
A::Finalize();
}
};
__gc class B : public A {
public :
virtual ~B()
{
System::GC:SuppressFinalize(this);
B::Finalize();
}
};
이 구현에서는 사용자가 클래스 Finalize() 메서드를 이제 그 어느 때보다 명시적으로 호출할 수 있지만, Dispose() 메서드 솔루션과 실제로 연결되는 것은 아닙니다. 수정된 언어 디자인에서 이 사항이 변경되었습니다.
V2에서는 소멸자의 이름이 내부에서 Dispose() 메서드로 바뀌고 참조 클래스는 자동으로 확장되어 IDispose 인터페이스를 구현합니다.
소멸자가 V2에서 명시적으로 호출되거나 추적 핸들에 delete가 적용되는 경우 기본 Dispose() 메서드가 자동으로 호출됩니다. 파생 클래스의 경우에는 기본 클래스의 Dispose() 메서드 호출이 합성된 메서드 근처에 삽입됩니다.
하지만 이것으로 명확한 종료가 완료되는 것은 아닙니다. 완료에는 로컬 참조 개체의 추가 지원이 필요하기 때문입니다. V1에는 이와 유사한 지원이 없으므로 이는 변환에 관련되는 문제는 아닙니다. 베타1 릴리스에서도 이러한 지원은 없습니다. 자세한 내용은 전체 변환 가이드를 참고하십시오.
앞서 살펴보았듯이 V2에서는 소멸자가 Dispose() 메서드로 합성됩니다. 즉, 소멸자가 명시적으로 호출되지 않는 경우 가비지 수집기는 종료 동안 이전처럼 개체에 대해 관련 Finalize() 메서드를 찾지 않습니다. 소멸과 종료를 모두 지원할 수 있도록 V2에서는 파이널라이저 제공을 위한 특수 구문을 도입했습니다. 예를 들면 다음과 같습니다.
// V2 구문
public ref class R {
protected:
!R() { Console::WriteLine( "I am the R::finalizer()!" ); }
};
! 접두어는 클래스 소멸자를 사용하는 유사한 물결표(~)를 제안하기 위한 것입니다. 즉, 두 수명 후 메서드 모두 클래스 이름 앞에 토큰이 있습니다. 합성된 Finalize() 메서드가 파생 클래스 내에서 발생하는 경우 기본 클래스 Finalize() 메서드의 호출은 끝에 삽입됩니다. 소멸자가 명시적으로 호출되면 파이널라이저가 표시되지 않습니다. 파이널라이저는 보호된 상태이자 공개 멤버가 아닌 상태로 선언되어야 합니다.
이는 참조 클래스에 특별한 소멸자가 포함될 때마다 V1 프로그램의 런타임 동작이 V2에서 컴파일할 때 약간 변경됨을 의미합니다. 필수 변환 알고리즘에서는 다음을 수행해야 합니다.
소멸자가 있는 경우에는 클래스 파이널라이저가 되도록 다시 작성하십시오.
Dispose() 메서드가 있는 경우에는 클래스 소멸자로 다시 작성하십시오.
원래 코드에 클래스 소멸자의 명시적인 호출 또는 형식 인스턴스에 대한 delete 연산자 적용이 포함된 경우, V1 동작을 복제하기 위해 파이널라이저를 호출할 때 사용할 공개 메서드도 제공해야 합니다. 예를 들면 다음과 같습니다.
public ref class R {
public :
void callFinalizer()
{
System::GC::SuppressFinalize(this);
This->!R();
}
protected:
!R() { Console::WriteLine( "I am the R::finalizer()!" ); }
};
예를 들어 다음과 같은 V1 코드가 있다고 가정해 봅시다.
void f( R* r )
{
r->Dispose(); // 1
delete r; // 2
};
이 코드는 다음의 V2 코드로 변환됩니다.
void f( R^ r )
{
delete r ; // 1에 해당
r->callFinalizer(); // 2
};
7. CLI 열거형
V1 CLI 열거형 선언 앞에는 __value 키워드가 있습니다. 이는 System::ValueType으로부터 파생되는 CLI 열거형의 기본 열거형과 기능은 비슷하지만 이 둘은 서로 다릅니다. 예를 들면 다음과 같습니다.
// V1 구문
__value enum e1 { fail, pass };
public __value enum e2 : unsigned short
{ not_ok = 1024, maybe, ok = 2048 };
V2에서는 값 형식 루트보다 CLI 열거형의 클래스 특성을 강조하는 방법으로 기본 열거형과 CLI 열거형을 구분하는 문제를 해결합니다. 따라서 __value 키워드는 삭제되고 enum class의 공백이 있는 키워드 쌍으로 대체됩니다. 이는 참조, 값 및 인터페이스 클래스의 선언에 대해 대칭되는 키워드 쌍을 제공합니다. V2에서 열거형 쌍 e1 및 e2의 변환은 다음과 같습니다.
// V2 구문
enum class e1 { fail, pass };
public enum class e2 : unsigned short
{ not_ok = 1024, maybe, ok = 2048 };
이 작은 구문상의 변화 외에도 CLI 열거형 형식의 동작이 다양하게 변경되었습니다.
V2에서는 CLI 열거형의 전방 선언이 더 이상 지원되지 않습니다. 매핑도 없으며 컴파일 시간 오류 플래그가 지정되어 있을 뿐입니다.
기본 제공 산술 형식과 개체 클래스 계층 구조 간의 오버로드 확인은 V1과 V2에서 반대로 바뀌었습니다. 그로 인해 V2에서 CLI 열거형은 더 이상 V1에서처럼 산술 형식으로 암시적으로 변환되지 않습니다. 예를 들어 다음과 같은 코드 조각이 있다고 가정해 봅시다.
// V1 구문
__value enum status { fail, pass };
void f( Object* ){ cout << "f(Object)\n"; }
void f( int ){ cout << "f(int)\n"; }
int main()
{
status rslt;
f( rslt ); // 어떤 f가 호출됩니까?
}
Native C++ 프로그래머라면 오버로드된 f()의 어떤 인스턴스가 호출되는지를 묻는다면 당연히 f(int)라고 대답할 것입니다. 열거형은 상징적인 정수 계열 상수이며, 이 경우 우선적으로 발생하는 표준 정수 계열 수준 올리기가 적용됩니다. V1에서 이것은 호출이 확인되는 인스턴스입니다.
그러나 이러한 확인 방식은 Native ++C 방식에서 사용하는 경우가 아니라, 열거형이 개체로부터 간접적으로 파생된 클래스인 기존 기본 클래스 라이브러리 프레임워크와 상호 작용해야 하는 경우에는 여러 가지 문제를 야기했습니다. V2에서는 호출된 f()의 인스턴스가 f(Object^)의 인스턴스입니다.
그로 인해 수정된 언어에서는 CLI 열거형 형식과 산술 형식 간의 암시적인 변환을 지원하지 않습니다. 산술 형식이 필요한 위치에 CLI 열거형을 사용했던 코드에는 이제 명시적인 캐스트가 필요합니다.
V2에서는 관리되는 열거형이 CLI 개체 모델을 준수하면서 자체 범위를 유지하지만, 이는 Native C++ 프로그래머에게 직관적이지 못합니다. V1에서는 기본 열거형 내에 범위가 없다는 결점을 보완하기 위해 CLI 열거형의 열거자에 대해 확실하게 주입되지 않은 이름을 정의하기 위한 시도가 있었습니다. 그러나 이는 그다지 성공적이지 못했습니다. 열거자가 전역 네임스페이스로 흩어져서 이름 충돌을 관리하기 어려워지는 문제가 발생했기 때문입니다. 따라서 V2에서는 관리되는 열거형 내의 범위를 지원하기 위해 다른 CLI 언어를 준수했습니다.
즉, V1에서는 CLI 열거형의 열거자가 열거형의 포함 범위 내에 표시되지만 V2에서는 열거자가 열거형의 범위 내에 캡슐화됩니다. 따라서 V2에서는 CLI 열거형의 열거자를 정규화하지 않고 사용하면 인식되지 않습니다. 예를 들면 다음과 같습니다.
// 약한 삽입을 지원하는 V1
__gc class XDCMake {
public :
__value enum _xdc {
UNDEFINED, OPTION_USAGE, XDC4_XML_LDFAIL = 4 };
XDCMake() {
// _xdc 열거자의 비정규화된 사용...
opList->Add( __box(UNDEFINED)); // (1)
opList->Add( __box(OPTION_USAGE)); // (2)
itagList->Add( __box(XDC4_XML_LDFAIL)); // (3)
}
};
열거자 이름의 세 가지 비정규화된 사용((1), (2) 및 (3))은 V2로 변환될 때 각각 정규화해야 합니다. 예를 들면 다음과 같습니다.
// V2 구문 - 범위를 표시하는 CLI 열거형
ref class XDCMake {
public :
enum class _xdc {
UNDEFINED, OPTION_USAGE, XDC4_XML_LDFAIL = 4
};
XDCMake()
{ // 명시적 정규화 필요...
opList->Add( _xdc::UNDEFINED); //(1)
opList->Add( _xdc::OPTION_USAGE); //(2)
itagList->Add( _xdc::XDC4_XML_LDFAIL); //(3)
}
};
8. 고정 포인터
가비지 수집기는 압축 단계 동안 CLI 힙에 상주하는 개체를 힙 내의 다른 위치로 선택적으로 이동할 수 있습니다. 이렇게 개체를 이동해도 추적 핸들, 추적 참조 및 내부 포인터에는 문제가 되지 않으며, 이를 통해 이러한 엔터티를 손쉽게 업데이트할 수 있습니다. 그러나 사용자가 런타임 환경 외부의 주소를 전달한 경우에는 문제가 됩니다. 이 경우에는 개체의 일시적인 이동이 런타임 오류를 일으킬 수 있습니다. 이러한 개체가 이동하지 않도록 하려면 외부 사용의 범위에 대해 이들의 위치를 로컬에서 고정해야 합니다.
V1에서는 __pin 키워드를 사용하여 포인터 선언을 정규화함으로써 고정 포인터를 선언했습니다. V2에서는 의사 템플릿 구문을 사용하여 고정 포인터를 선언합니다. 고정 포인터의 원래 제약 조건은 그대로 유지됩니다. 예를 들어 고정 포인터를 메서드의 매개 변수나 반환 형식으로 사용할 수 없습니다. 대신 로컬 개체에서만 선언할 수 있습니다. V2에서는 다음과 같은 몇 가지 제약 조건이 더 추가되었습니다.
고정 포인터의 기본값은 0이 아니라 nullptr입니다. pin_ptr<>을 초기화하거나 0을 할당할 수 없습니다. 0에 대한 모든 할당 및 명시적 비교는 nullptr로 변경해야 합니다. 이는 모든 참조 형식에 적용됩니다.
V1에서는 고정 포인터가 전체 개체에 적용될 수 있습니다. 그러나 V2에서는 전체 개체 고정이 지원되지 않습니다. 대신 내부 멤버의 주소만 고정해야 합니다. 예를 들면 다음과 같습니다.
// V1 구문
__gc struct H { int j; };
__gc class G { ... };
void f( G * g )
{
// V1: 전체 개체 고정
H __pin * pH = new H;
g->incr(& pH -> j);
};
이 경우 실제로 고정해야 하는 멤버는 H::j입니다. V2에서 프로그램이 컴파일되도록 수정한 것은 고정 소스의 대상을 해당 멤버로 다시 지정하기 위함입니다. 예를 들면 다음과 같습니다.
// V2 구문
ref struct H { int j; };
ref class G{ ... };
void f( G^ g )
{
H ^ph = gcnew H;
// V2: 내부 멤버 고정...
pin_ptr<int> pj = &ph->j;
g->incr( pj );
}
9. Static Const 멤버가 리터럴이 됨
V2에서도 static const 정수 계열 멤버가 계속 지원되기는 하지만 연결 특성이 V1에서와는 달리 변경되었습니다. V1 연결 특성은 이제 V2에서 literal 정수 계열 멤버에서 전달됩니다. 예를 들어 V1에서 다음 클래스가 선언된다고 가정해 봅시다.
// V1 구문
public __gc class Constants {
public :
static const int LOG_DEBUG = 4;
// ...
};
이 선언은 필드에 대해 다음과 같은 기본 CIL 특성을 생성합니다. 리터럴 특성은 굵게 표시되어 있습니다.
더 이상 literal 특성을 내보내지 않으므로 CLI 런타임에서 상수로 표시하지 않습니다.
.field public static int32 modopt([Microsoft.VisualC]Microsoft.VisualC.IsConstModifier) STANDARD_CLIENT_PRX = int32(0x00000004)
동일한 언어 간 리터럴 특성을 얻으려면 이 선언을 새롭게 지원되는 literal 데이터 멤버로 변경해야 합니다. 예를 들면 다음과 같습니다.
// V2 구문
public ref class Constants {
public :
literal int LOG_DEBUG = 4;
// ...
};
이러한 변경 사항은 정수 계열 형식의 static const 멤버에만 적용되어야 합니다. 다른 모든 형식은 이전과 동일합니다.
V1과 V2 간에 정확히 대응하지는 않는 변경 사항
오랫동안 C++를 디자인해 오면서 Bjarne Stroustrup은 몇 가지 실수를 범했습니다. 이는 그도 인정하는 사실입니다. 예를 들어 이 언어의 초기 버전에서는 생성자 내에서 가상 함수를 호출하면 인스턴스가 가장 많이 파생되었습니다. 그러나 시간이 지남에 따라 이는 일반적으로 잘못된 동작이었음이 명백해졌기 때문에 이 동작을 1980년대 cfront의 릴리스 1.2로 다시 바꿨습니다. 우리도 몇 가지 실수를 범했습니다. 어쩌면 지금도 범하고 있는 중일지도 모릅니다. 물론 이 언어에서만 이러한 실수가 있었던 것은 아니지만 현재는 이 언어에 대해서만 논의하는 중이므로 다른 언어의 잘못된 점은 굳이 들추지 않겠습니다. 새로운 재정의 기능을 사용하는 13번 항목을 제외하면 이 기사의 항목들은 V1에서 사용되는 것을 무효화한 취소 내용입니다.
10. 생성자가 암시적으로 명시됨
V1에서는 단일 인수 생성자가 클래스 개체의 변환을 두 번째 형식의 개체로 정의합니다. 클래스의 개체가 필요한 경우 제공되는 값이 해당 클래스의 단일 인수 생성자와 일치하는 형식이면 컴파일러는 생성자를 자동으로 호출하여 해당 클래스의 임시 개체를 만든 다음 이를 식에 적용합니다. 이는 초기화, 할당, 함수 및 연산자 오버로드 확인에 영향을 줍니다. V2에서는 단일 인수 생성자가 마치 명시적으로 선언된 것처럼 작동합니다. 즉, 필요한 변환을 수행하기 위해 컴파일러가 생성자를 자동으로 적용하는 경우는 절대 없습니다. 더구나 V2에서는 생성 캐스트와 변환 캐스트 사이에 차이가 있습니다. 다음과 같은 형식의 생성 캐스트가 있다고 가정해 봅시다.
// 생성 캐스트... 생성자 호출...
Buffer( 128 );
이 생성 캐스트는 V1에서와 같이 관련 클래스 생성자를 호출합니다. 그러나 다음과 같은 형식의 변환 캐스트는 다릅니다.
// 변환 캐스트... 이에 대해 생성자가 절대로 호출되지 않음
( Buffer ) 128;
( Buffer )( 128 );
static_cast< Buffer >( 128 );
이 변환 캐스트는 절대 관련 생성자를 호출하지 않습니다. 변환 캐스트는 클래스가 적절한 변환 연산자를 정의하는 경우에만 성공합니다.
V2 동작을 수행하도록 V1 코드를 변환하려면 명시적인 캐스트를 삽입해야 할 뿐만 아니라 적절한 변환 연산자도 정의해야 합니다.
11. 값 클래스 기본 생성자 없음
V1과 V2에서 모두 값 클래스는 복사 생성자, 복사 할당 연산자, 소멸자 등의 특수 SMF(클래스 멤버 함수)를 지원하지 않습니다. 그러나 V1에서는 값 클래스가 기본 생성자, 즉 아무런 인수가 없는 생성자의 정의를 허용했습니다. V2에서는 이러한 권한이 없어졌습니다. 값 클래스는 더 이상 기본 생성자를 제공할 수 없습니다.
런타임 시 관련 기본 생성자의 호출을 보장할 수 없는 경우가 있다는 문제가 있었으므로, 해당 기능이 응용 프로그램에서 제대로 작동함을 보장할 수 없는 이런 상황에서는 차라리 지원하지 않는 것이 낫다고 판단한 것입니다.
일반적으로 값 클래스를 제한된 방식으로 사용하면 이러한 특수 멤버 함수가 없어도 문제가 되지는 않습니다. 제한된 방식이란 비트 단위의 복사를 지원하도록 값 멤버만 포함할 수 있게 하는 경우를 뜻합니다. 집계 유형이 비트 단위 복사를 지원하는 경우에는 복사 생성자나 복사 연산자가 필요하지 않습니다. 마찬가지로 집계 유형의 상태가 값 의미를 표시하는 경우에는 소멸자가 없어도 됩니다. 마지막으로 런타임은 모든 상태를 기본적으로 0으로 처리하므로 기본 생성자가 필요하지 않습니다. C++에서 기본 데이터 형식은 자동으로 0으로 처리되지 않으므로 대부분(전부는 아님)의 기본 생성자는 개체를 초기화되지 않은 상태로 만드는 데 사용됩니다.
물론 문제는 V1 값 클래스가 기본 생성자를 사용하여 0으로 처리되지 않는 연산을 수행하는 경우입니다. 이 경우에는 생성자 내의 코드가 명명된 초기화 함수로 마이그레이션되어야 합니다. 물론 이 메서드는 프로그래머가 명시적으로 호출해야 하기 때문에 오류가 발생할 가능성이 있습니다.
12. 개인 가상 함수의 재정의
V1에서는 가상 함수의 액세스 수준으로 인해 파생 클래스 내에서 재정의되는 해당 기능이 제한되지 않습니다. ISO-C++에서도 마찬가지입니다. V2에서 가상 함수는 직접 액세스할 수 없는 기본 클래스 가상 함수를 재정의할 수 없습니다. 예를 들면 다음과 같습니다.
__gc class My {
private :
virtual void g();// 파생 클래스에 액세스 불가능
};
__gc class File : public My {
public :
// V1에서는 가능 g() overrides My::g()
// V2에서는 오류. 재정의 불가: My::g()inaccessible ...
void g();
};
V2에서 가장 확실한 해결책은 개인 기본 클래스 멤버를 개인 상태가 아닌 것으로 만드는 것입니다. 상속된 메서드는 동일한 액세스 권한을 가질 필요가 없습니다. 단지 액세스가 가능하면 되는 것입니다. 이 예제에서 혼란을 최소화하는 변경 사항은 My 멤버를 보호하는 것입니다. 이러한 방식으로 My를 통한 일반 프로그램의 메서드 액세스는 여전히 금지됩니다.
ref class My {
protected:
virtual void g();
};
ref class File : My {
public :
void g();
};
수정된 언어에서는 기본 클래스에 명시적 virtual 키워드가 없기 때문에 경고 메시지가 나타납니다. 가상 특성을 명시적으로 지정하는 책임감 있는 프로그래머가 될 때까지는 경고 메시지를 지겹도록 보게 될 것입니다.
13. 명시적 인터페이스 함수 재정의
인터페이스를 구현하는 클래스 내에서는 인터페이스 멤버의 두 인스턴스를 제공하는 것이 좋은 경우가 많습니다. 두 인스턴스 중 하나는 인터페이스 핸들을 통해 클래스 개체를 조정할 때 사용되며, 다른 하나는 클래스 인터페이스를 통해 클래스 개체를 사용할 때 사용됩니다. 예를 들면 다음과 같습니다.
// V1 구문
public __gc class R : public ICloneable
{
// Icloneable을 통해 사용
Object* ICloneable::Clone();
// R을 통해 사용
R* Clone();
};
V1에서는 인터페이스 메서드의 명시적 선언에 인터페이스 이름으로 정규화된 메서드 이름을 제공함으로써 이러한 작업을 수행합니다. 클래스 고유의 인스턴스는 비정규화 상태입니다. 이로 인해 Clone()의 반환 값을 downcast하지 않아도 됩니다. 이 예제에서는 R의 인스턴스를 통해 명시적으로 호출될 때입니다.
V2에서는 이전 구문을 대체하는 일반적인 재정의 메커니즘이 도입되었습니다. 예제는 다음과 같이 다시 작성되어야 합니다.
// V2 구문
public ref class R : public ICloneable
{
// ICloneable을 통해 사용
Object^ InterfaceClone() = ICloneable::Clone;
// R을 통해 사용
virtual R^ Clone() new;
};
이 수정 내용은 명시적으로 재정의되는 인터페이스 멤버가 클래스 내에서 고유한 이름을 가지도록 합니다. 여기서는 InterfaceClone()이라는 좀 이상한 이름을 사용했습니다. 동작은 계속 그대로입니다. 즉, ICloneable 인터페이스를 통한 호출이 이름이 바뀐 InterfaceClone()을 호출하고, R 형식의 개체를 통한 호출은 두 번째 Clone() 인스턴스를 호출합니다.
관련 서적
STL Tutorial and Reference Guide , David Musser/Gillmer Derge/Atul Saini 공저, Addison-Wesley, 2001
C++ Standard Library , Nicolai Josuttis 저, Addison-Wesley, 1999
C++ Primer , Stanley Lippman/Josee Lajoie 공저, Addison-Wesley, 1998
감사의 말
이 문제에 대해 도움을 주고 자세히 설명해 준 Visual C++ 팀원들에게 감사의 말을 전합니다. Arjun Bijanki, Artur Laksberg, Brandon Bray, Jonathan Caves, Siva Challa, Tanveer Gani, Mark Hall, Mahesh Hariharan, Jeff Peil, Andy Rich, Alvin Chardon, Herb Sutter에게 감사드립니다. 모두 적극적으로 도와 주셔서 큰 도움이 되었습니다. 이들의 전문 지식이 없었다면 이 문서를 작성하지 못했을 것입니다.
저자 소개
Stanley Lippman은 Microsoft Corporation의 Visual C++ 개발자입니다. 1984년 Bell Laboratories에서 C++의 발명자인 Bjarne Stroustrup과 함께 작업을 시작했습니다. Disney와 DreamWorks에서 애니메이션 작업을 담당했으며 Fantasia 2000의 소프트웨어 기술 책임자였습니다.