34-1.네임스페이스
34-1-가.명칭의 충돌
C/C++ 소스를 구성하는 7가지 요소중의 하나인 명칭(Identifier)은 변수, 함수, 타입 등 다양한 요소를 정의할 때 사용한다. 몇 가지 간단한 규칙만 지키면 자유롭게 정의할 수 있으며 그래서 가급적이면 기억하기 쉽고 대상을 명확하게 표현할 수 있는 이름을 붙인다. 명칭 작성 규칙 중 가장 당연하면서도 자연스러운 것은 같은 범위의 명칭끼리 중복되면 안된다는 것이다. some이라는 이름으로 변수를 선언했으면 같은 이름으로 다른 변수를 선언할 수 없음은 물론이고 함수나 타입의 이름으로도 사용할 수 없다.
짧고 간단한 프로그램에서 명칭을 작성하는 것은 그리 어려운 일이 아니다. 명칭이 그다지 많이 필요하지도 않아서 충돌이 발생할 확률이 거의 없으며 설사 우연히 충돌했다 하더라도 수정하기 어렵지도 않다. 소스 편집툴이 워낙 편리한 기능들을 많이 제공하므로 소스의 특정 문자열을 죄다 뒤져 원하는 다른 문자열로 일괄 대체할 수도 있다.
그러나 프로그램이 복잡해지고 규모가 커질수록 더 많은 명칭이 필요해져서 고유한 이름을 붙이는 일이 점점 더 어려워지고 있다. 게다가 팀 단위로 작업할 때는 혼자서 명칭을 다 만드는 것이 아니며 외부 라이브러리를 가져다 쓰는 일도 흔해져서 우연히 명칭이 충돌하는 일이 잦아졌다. 예를 들어 Count라는 변수를 쓰고 싶은데 팀의 다른 개발자가 이미 이 명칭을 전역변수로 쓰고 있다거나 외부 라이브러리에서 다른 의미로 사용중이라면 이 이름을 쓸 수 없다. 개발자에게 명칭 작성의 자유가 부여되어 있고 중앙 통제 센터가 없으므로 명칭끼리 충돌할 가능성은 항상 존재한다. 이런 경우는 명칭에 일종의 접두를 다는 방식이 사용되기도 하나 완벽하지도 못했고 불편하기도 하다.
예를 들어 철수와 영희가 어떤 프로그램을 공동으로 개발한다고 해 보자. 이 두 사람은 각자 맡은 모듈을 개발하기 위해 전역변수, 함수 등을 따로따로 만들 것이다. 그런데 우연히 같은 명칭을 사용하게 되면 두 명칭이 충돌하므로 전체 프로젝트가 제대로 컴파일되지 않는다. 이런 문제를 방지하기 위해 철수는 자신이 만드는 모든 명칭앞에 cs_를 붙이고 영희는 yh_를 붙이기로 했다.
둘 다 Count라는 전역변수를 쓰고 있지만 한쪽은 cs_Count이고 한쪽은 yh_Count이므로 명칭이 좀 길어지기는 하지만 적어도 충돌은 방지할 수 있다. 이 상태로 개발을 잘 진행하다가 외부 그래픽 라이브러리가 필요해져서 창수가 만든 그래픽 라이브러리를 사용했다고 해 보자. 창수가 만든 라이브러리에 우연히 cs_Count라는 변수가 또 있을 수도 있는데 이렇게 되면 철수가 이 변수의 이름을 바꾸는 식으로 문제를 해결해야 한다. 또 이번에는 칠성이가 만든 라이브러리를 포함했는데 여기에도 cs_Count가 존재할 수 있다. 외부 라이브러리끼리 명칭이 충돌하면 수정할 수도 없어 한쪽 라이브러리의 사용을 포기해야 하는 곤란한 상태가 되기도 한다.
물론 이렇게까지 접두어가 충돌하는 경우는 그리 흔하지 않지만 그렇다고 이런 상황이 발생하지 않는다는 보장도 없다. 프로젝트가 커지면 명칭 충돌은 불가피하게 발생할 수밖에 없다. 그래서 명칭 충돌 문제를 언어 차원에서 좀 더 근본적으로 해결할 수 있는 방법이 필요해졌고 이것이 바로 네임 스페이스가 필요해진 이유이다. 네임 스페이스는 비교적 최근에 C/C++ 언어에 추가된 기능이므로 아직까지 완벽하게 지원하지 못하는 컴파일러도 많이 있다.
네임 스페이스(Name Space)는 말 뜻 그대로 명칭들이 기억되는 영역이며 명칭의 소속 공간이다. 이름을 담는 통이라고 생각하면 이해하기 쉽다. 일정한 선언 영역을 만들고 이 영역안에 명칭을 그룹화하여 넣어 두면 충돌 가능성이 대폭 감소된다. 예를 들어 한 학급에 김철수가 두 명이면 이름이 같기 때문에 서로 구분되지 않는다. 그러나 1반, 2반에 각각 김철수가 있다면 두 학생은 소속이 다르기 때문에 1반의 철수, 2반의 철수로 각각 호칭을 붙일 수 있어 별 문제가 되지 않을 것이다.
명칭도 마찬가지로 소속 네임 스페이스가 다르면 이름이 중복되어도 상관없다. 충돌할 가능성이 조금이라도 있는 명칭이라면 아예 처음부터 네임 스페이스안에 선언하는 것이 좋다. 네임 스페이스를 정의하는 기본 형식은 다음과 같다.
namespace 이름
{
여기에 변수나 함수를 선언한다.
}
키워드 namespace 다음에 네임 스페이스의 이름을 지정하고 { } 괄호안에 변수, 함수, 타입 등의 명칭을 선언한다. 네임 스페이스의 이름도 일종의 명칭이므로 명칭 작성 규칙에 맞게 만들어야 한다. 명칭 충돌의 예와 네임 스페이스로 충돌을 해결하는 간단한 코드를 살펴보자.
예 제 : namespace1
|
#include <Turboc.h>
int i;
double i;
void func()
{
i=123;
}
void main()
{
func();
}
i라는 명칭으로 두 개의 변수를 선언했는데 하나는 int 타입이고 하나는 double 타입이다. 이 코드가 에러임은 직관적으로 알 수 있으므로 논리적인 설명을 할 필요도 없을 것이다. 두 변수가 같은 이름을 쓰고 있으므로 func함수에서 칭하는 i는 어떤 i인지 애매해진다. 컴파일러는 애매한 상황을 처리할 수 없기 때문에 double i; 선언문에서 명칭이 중복되었다고 투덜거릴 것이다. 그러나 다음과 같은 중복은 가능하다.
double i;
void func()
{
int i;
i=123;
}
전역변수 i와 지역변수 i가 서로 다른 영역에 선언되어 있는데 지역, 전역의 명칭이 충돌할 경우는 지역변수가 우선권을 가진다. 그래서 func 함수내에서 i 명칭을 참조하면 이는 지역변수 i를 의미하며 모호하지 않다. 이 상태에서 만약 전역변수 i를 참조하고 싶다면 :: 연산자를 사용하여 ::i=1.2;라고 쓰면 된다. 지역변수는 사용 범위가 좁고 언제든지 이름을 바꿀 수 있기 때문에 명칭 충돌이 별 문제가 되지 않으며 주로 전역 명칭끼리의 충돌이 문제가 된다. 전역 명칭이 충돌할 경우 네임 스페이스를 각각 만들고 각 영역안에 명칭을 선언하면 된다.
예 제 : namespace2
|
#include <Turboc.h>
namespace A {
double i;
}
namespace B {
int i;
}
void func()
{
A::i=12.345;
B::i=123;
}
void main()
{
func();
}
A안에 double i가 선언되어 있고 B안에 int i가 같은 이름으로 선언되어 있다. 두 명칭은 이름이 같지만 소속이 다르므로 서로 구분 가능하다. 소속이 달라 구분된다는 얘기는 참조할 때 소속을 밝혀야 함을 의미하기도 한다. 네임 스페이스에 속한 명칭을 참조할 때는 :: 연산자앞에 네임 스페이스 이름을 붙여 어디에 속해 있는지를 밝힌다. A::i는 네임 스페이스 A에 소속된 명칭 i를 의미한다.
네임 스페이스를 별도로 정의하지 않아도 항상 존재하는 네임 스페이스가 있는데 이를 전역 네임 스페이스라고 한다. 이른바 디폴트 네임 스페이스라고 볼 수 있는데 흔히 전역변수를 선언하는 영역, 그러니까 함수의 바깥쪽이 바로 이 영역이다. 원래부터 존재하므로 별도의 이름은 없다. 다음 코드는 똑같은 이름으로 세 개의 변수를 선언하고 있다.
예 제 : namespace3
|
#include <Turboc.h>
int i; // 전역 네임 스페이스 소속
namespace A {
int i; // A 소속
}
void func()
{
int i;
i=1; // 지역변수 i
::i=2; // 전역 네임 스페이스의 i
A::i=3; // A 네임 스페이스의 i
}
void main()
{
func();
}
첫 줄의 i가 선언된 영역이 바로 전역 네임 스페이스 영역이며 이 변수는 소위 말하는 전역변수이다. A 네임 스페이스안에 i 변수가 같은 이름으로 선언되어 있으며 func 함수 내에 지역변수 i가 또 선언되어 있다. 이 때 func 내에서 세 변수를 모두 참조할 수 있는데 소속없이 그냥 i라고 하면 지역변수이고 네임 스페이스를 밝히면 해당 소속의 변수를 의미한다. 전역 네임 스페이스는 별도의 이름이 없으므로 :: 연산자만 사용한다.
짧은 소스에서 일부러 명칭이 충돌하는 상황을 만들고 네임 스페이스로 이 문제를 해결해 보았는데 이는 어디까지나 이해를 위한 예시 코드일 뿐이다. 한 모듈안에서 명칭이 충돌하는 경우는 무척 드물며 설사 실수로 충돌했다 하더라도 한쪽의 명칭을 바로 수정할 수 있으므로 문제가 되지 않는다. 예를 들어 int Time이라는 변수를 선언해 놓은 상태에서 SYSTEMTIME Time이라는 변수를 또 선언했다면 에러가 발생할 것이고 둘 중 하나의 이름을 바꾸면 된다.
명칭 충돌이 문제가 될 때는 외부 라이브러리를 쓰거나 직접 라이브러리를 작성할 때이다. 내가 만든 라이브러리에서 Count, Time, Status 같은 변수나 CStack, CArray 같은 타입을 정의한다고 해 보자. 이런 이름은 너무 일반적이기 때문에 이 라이브러리를 사용하는 클라이언트 모듈과 충돌할 확률이 아주 높다. 이럴 때 handsome_sanghyung 같은 긴 이름의 네임 스페이스안에 명칭을 선언하면 충돌을 걱정할 필요가 없다.
네임 스페이스의 기본적인 기능은 명칭이 작성되는 공간을 분리함으로써 명칭끼리 충돌하지 않도록 하는 것이다. 이 외에도 네임 스페이스는 명칭들의 논리적인 그룹을 만들어 소스 관리에도 상당한 도움을 준다. 예를 들어 그래픽과 관련된 명칭은 GR에 넣고 유저 인터페이스에 관련된 명칭은 UI에 넣어 놓으면 소속으로 두 그룹의 함수군을 나눌 수 있다. 팀별로, 개발자 개인별로 네임 스페이스를 정의하면 누가 만든 명칭인지도 쉽게 파악된다.
34-1-나.네임 스페이스 작성 규칙
다음은 네임 스페이스와 네임 스페이스 내부에 명칭을 작성하는 규칙에 대해 알아보자. 지극히 상식적인 내용들이므로 한 번씩 읽어 보기만 하면 된다.
네임 스페이스의 이름도 일종의 명칭이므로 다른 명칭과 중복되어서는 안된다. 다른 네임 스페이스와 구분되는 이름을 가져야 함은 물론이고 변수나 함수와도 같은 이름을 쓸 수 없다. 네임 스페이스가 명칭 충돌 문제를 해결하기 위한 장치인데 스스로의 이름이 중복될 수 있다는 재귀적인 문제가 있는 셈이다. 그러나 네임 스페이스는 한 프로그램에 많아야 한 두 개밖에 없으므로 다른 명칭보다는 충돌 가능성이 훨씬 더 작고 수정하기도 쉬운 편이다.
만약 외부 라이브러리와 네임 스페이스 이름이 중복된다면 변수나 타입이 중복되는 것과 같은 곤란한 상황이 될 것이다. 그래서 네임 스페이스의 이름은 가급적이면 길게 쓰고 또한 중복되지 않는 고유한 이름으로 작성해야 한다. 회사의 이름이나 홈페이지 주소, 개인 이메일 주소 등을 응용하여 네임 스페이스명을 작성하면 99.9999% 안전하다.
네임 스페이스는 반드시 전역 영역에 선언해야 한다. 함수안에 선언할 수 없다는 뜻이며 다음과 같은 지역 네임 스페이스는 허가되지 않는다.
void func()
{
namespace C {
int z;
}
....
불가능한 것은 아니겠지만 이런 문법을 제공할 필요가 없다고 해야 할 것이다. 지역변수는 함수 내부에만 알려지고 그 생명도 함수 내부로 국한되며 많아 봐야 몇 십개를 넘지 않으므로 이 함수 내에서만 고유한 이름을 가지면 된다. 게다가 함수 하나를 둘이서 만들지는 않으므로 충돌해 봤자 혼자 고치면 된다. 그래서 지역변수는 네임 스페이스 안에 넣을 필요도 없고 그런 문법도 허락되지 않는 것이다. 만약 지역변수간의 충돌이 너무 심해 함수를 유지하기 어려울 지경이라면 이 함수를 잘 못 만든 것이다. 함수는 너무 커서는 안되며 더 작은 기능 단위로 분할해야 한다.
네임 스페이스가 전역 영역에만 존재하기 때문에 네임 스페이스 내부에 선언되는 명칭들은 본질적으로 전역적이다. 주로 타입이나 함수 등 프로젝트 전반에 걸쳐 참조되어야 할 명칭들이 네임 스페이스에 선언된다.
네임 스페이스끼리 중첩 가능하다. 즉, 네임 스페이스안에 또 다른 네임 스페이스를 선언할 수 있다는 얘기인데 중첩의 단계에 대한 제한은 없다. 네임 스페이스는 명칭들을 논리적으로 그룹화하는 역할을 하는데 그룹을 나누는 세부 단계가 필요하다면 여러 단계로 중첩할 수 있다.
namespace Game {
namespace Graphic {
struct Screen { };
}
namespace Sound {
struct Sori { };
}
}
Game 네임 스페이스 안에 Graphic, Sound 네임 스페이스가 있고 각 중첩 네임 스페이스안에 함수, 타입 등 필요한 명칭을 선언한 예이다. 이 상태에서 중첩된 명칭을 참조하려면 :: 연산자를 중첩 회수만큼 사용하여 Game::Graphic::Screen 식으로 쓴다. 중간 규모 정도의 프로젝트에서는 네임 스페이스를 중첩 시킬 경우가 별로 없으며 초거대 규모의 프로젝트에서나 드물게 사용된다. 수백명이 한 프로젝트를 개발한다면 팀별로, 개인별로 네임 스페이스를 만들어야 할 것이다.
네임 스페이스는 항상 개방되어 있다. 그래서 같은 네임 스페이스를 여러 번 나누어 명칭을 선언할 수 있다. 꼭 한꺼번에 몰아서 네임 스페이스내의 모든 명칭을 일괄 선언해야 하는 것은 아니다.
namespace A {
double i;
}
namespace B {
int i;
}
namespace A {
char name[32];
}
네임 스페이스 A가 두 번 선언되어 있는데 두 번째 선언에 의해 새로운 네임 스페이스 A가 만들어지는 것이 아니라 기존 A 영역에 새로운 명칭이 추가된다. 그래서 변수 i와 name은 둘 다 네임 스페이스 A의 소속으로 병합된다. 네임 스페이스가 개방되어 있기 때문에 여러 모듈에서 한 네임 스페이스에 필요한 명칭을 언제든지 선언할 수 있으며 여러 사람이 같은 네임 스페이스를 쓰는 것도 가능하다. 이런 개방성의 예는 앞서 클래스의 액세스 지정에서도 본 바 있다.
네임 스페이스가 이름을 가지지 않을 수 있다. 키워드 namespace 다음에 { } 괄호를 바로 쓰고 괄호안에 명칭만 선언하면 된다.
namespace {
int internal;
}
이렇게 되면 internal은 사실상 일반적인 전역변수와 동일하다고 볼 수 있으며 소속을 밝히 필요없이 internal이라는 명칭만으로 참조할 수 있다. 다만 이 선언이 있는 파일 내에서만 사용 가능하며 외부로 알려지지 않는다는 점만 다르다. 7-2-가 절에서 논한 외부 정적변수와 성격이 동일하다고 할 수 있는데 static은 C의 방식이고 익명 네임 스페이스는 C++의 방식이다.
단일 모듈 프로젝트에서는 별 상관이 없지만 다중 모듈 프로젝트에서는 함수의 본체를 어디에 작성할 것인가 주의해야 한다. 여러 개의 모듈로 나누어진 프로젝트를 개발할 때는 보통 헤더 파일과 구현 파일을 따로 작성한다. 네임 스페이스안에 함수를 정의할 때 헤더 파일에 원형만 선언하고 구현 파일에 함수의 본체를 작성한다.
NsTest 프로젝트에 NsTest 메인 모듈과 이 모듈에 어떤 기능을 제공하는 Util 모듈이 있다고 하자. Util 모듈은 명칭 충돌 방지를 위해 네임 스페이스 안에 함수를 작성하고자 한다. 이때 함수의 원형 선언과 본체 정의는 다음과 같이 나누어져야 한다.
예 제 : NsTest
|
Util.h에는 네임 스페이스 A안에 func 함수의 원형이 기록되어 있다. 이 함수의 본체는 Util.cpp에 작성하되 본체 정의부의 함수명 앞에 소속 네임 스페이스인 A::을 반드시 적어야 한다. 아니면 네임 스페이스의 개방성을 활용하여 다음과 같이 할 수도 있다.
namespace A {
void func()
{
printf("I am func\n");
}
}
네임 스페이스가 반복되면 병합되므로 함수 선언과 본체 정의가 네임 스페이스 A에 합쳐질 것이다. 구현 파일을 따로 만들지 않고 헤더 파일에 함수의 본체를 바로 정의하는 것은 안된다. Util.cpp를 프로젝트에서 빼고 다음과 같이 코드를 수정해 보자.
일단은 컴파일되고 실행도 된다. 그러나 만약 제 3의 모듈에서 Util의 함수를 필요로 한다면 Util.h를 인클루드 할 때마다 func 함수의 본체가 따로 생성되므로 함수가 중복 정의되는 문제가 있다. 일반적으로 선언은 여러 번 반복할 수 있지만 정의는 단 한 번만 해야 한다.
이 점은 변수에 대해서도 마찬가지이다. 헤더 파일의 네임 스페이스안에 변수를 선언하면 이 헤더 파일을 인클루드하는 모든 모듈에 동일한 이름의 변수가 중복 생성될 것이다. 앞 부분에서 개념적인 이해의 편의를 위해 네임 스페이스에 포함되는 명칭으로 변수를 사용했지만 일반적으로 네임 스페이스에 변수나 함수 정의를 직접 하는 경우는 거의 없다. 주로 클래스나 구조체같은 타입 선언이 네임 스페이스에 배치된다. C++에서는 주로 클래스간의 충돌이 문제가 되며 변수나 함수는 클래스안에서 지역적이므로 문제가 되는 경우가 드물다.
34-1-다.네임 스페이스 사용
네임 스페이스는 명칭의 선언 영역을 분리하여 충돌을 방지한다. 그래서 네임 스페이스안에 명칭을 선언하면 이름을 붙일 때 충돌을 걱정하지 않고 자유롭게 이름을 붙일 수 있다. 그러나 이렇게 작성된 명칭을 사용하려면 매번 소속을 밝히고 참조해야 하므로 무척 번거롭다. 다음과 같이 선언된 네임 스페이스가 있다고 하자.
namespace MYNS {
int value;
double score;
void func() { printf("I am func\n"); }
}
MYNS 네임 스페이스 안에 변수 둘, 함수 하나가 포함되어 있는데 이 명칭들을 사용하려면 항상 앞에 MYNS::을 붙여야 한다.
void main()
{
MYNS::value=3;
MYNS::score=1.2345;
MYNS::func();
}
네임 스페이스의 이름이 길어지면 타이프하는 것도 힘들고 소스의 가독성도 떨어져 여러 모로 좋지 않다. 그래서 이런 불편함을 해소할 수 있는 세 가지 방법이 제공된다.
using 지시자(Directive)
using namespace 다음에 네임 스페이스를 지정하는 방식이다. 지정한 네임 스페이스의 모든 명칭을 이 선언이 있는 영역으로 가져와 소속 지정없이 명칭을 바로 사용할 수 있도록 한다.
예 제 : usingdirective
|
#include <Turboc.h>
namespace MYNS {
int value;
double score;
void func() { printf("I am func\n"); }
}
using namespace MYNS;
void main()
{
value=3;
score=1.2345;
func();
}
전역 영역에 using 지시자가 있고 MYNS를 이 영역에서 사용하겠다고 지시했다. 이후 전역 영역에서 MYNS에 속한 명칭은 MYNS::이 없어도 바로 사용할 수 있다. 컴파일러는 value, score 등의 명칭이 전역 네임 스페이스에 없을 경우 using 지시자에 의해 지정된 MYNS 네임 스페이스도 검색해 보고 여기서 명칭이 발견되면 이 네임 스페이스의 명칭을 참조하도록 코드를 컴파일할 것이다. using 지시자는 컴파일러가 일일이 MYNS::을 명칭앞에 붙이도록 한다.
using 지시자가 영향을 미치는 범위는 이 지시자가 있는 영역에 국한된다. 특정 함수나 블록 안에 using 지시자를 사용하면 이 블록에서만 지정한 명칭을 바로 사용할 수 있으며 그외의 영역에서는 여전히 소속 지정이 필요하다. 다음 코드를 보자.
void main()
{
using namespace MYNS;
value=5;
}
void subfunc()
{
MYNS::score=1.2;
}
using 지시자가 main 함수 내부에 있으므로 이 영역에 대해서만 MYNS의 명칭을 바로 사용할 수 있다. subfunc에서는 MYNS::을 꼭 붙여야 한다.
using 선언(Declaration)
using 지시자는 지정한 네임 스페이스의 모든 명칭을 가져 오지만 using 선언은 하나의 명칭만을 가져온다. 키워드 using 다음에 가져오고 싶은 명칭의 소속과 이름을 밝히면 이후 이 명칭은 소속을 다시 밝힐 필요없이 바로 사용할 수 있다.
예 제 : usingdecl
|
#include <Turboc.h>
namespace MYNS {
int value;
double score;
void func() { printf("I am func\n"); }
}
void main()
{
using MYNS::value;
value=3;
MYNS::score=1.2345;
MYNS::func();
}
void subfunc()
{
MYNS::value=3;
}
main 함수의 선두에서 MYNS::value에 대해서만 using 선언을 했다. 이후 main 함수에서 value를 참조할 때 MYNS::을 붙이지 않아도 된다. score나 func는 별도의 선언이 없으므로 여전히 MYNS::을 명칭앞에 붙여 정확한 소속을 밝혀야 한다.
using 선언도 using 지시자와 마찬가지로 이 선언이 있는 블록에 대해서만 영향을 미친다. MYNS::value에 대한 using 선언이 main 함수 내부에 있으므로 이 선언은 main 함수 내에서만 유효하며 subfunc에서 value를 참조할 때는 MYNS::을 붙여야 한다. using 선언을 main 함수 이전의 전역 영역으로 옮기면 subfunc에서도 value를 바로 참조할 수 있을 것이다.
using에 의한 충돌
using 지시자와 using 선언은 지정한 네임 스페이스 전체 또는 특정 명칭을 이 선언이 있는 영역으로 가져와 소속 지정없이 명칭을 바로 쓸 수 있도록 해 준다. 이 방법이 편리하기는 하지만 소속을 밝히지 않고 사용하다 보니 이 영역에 이미 존재하는 명칭과 충돌하는 경우가 있을 수 있다. 이 경우 컴파일러가 충돌을 어떻게 처리하는지 연구해 보자. 먼저 using 선언의 경우를 보자.
예 제 : usingdeclconflict
|
#include <Turboc.h>
namespace MYNS {
int value;
double score;
void func() { printf("I am func\n"); }
}
int value;
void main()
{
using MYNS::value;
int value=3; // 에러
value=1; // MYNS의 value
::value=2; // 전역변수 value
}
세 개의 value가 선언되어 있는데 이들은 모두 소속이 다르므로 일단 선언은 가능하다. value에 대한 using 선언이 main 함수의 선두에 있으며 MYNS::value가 main 함수의 지역 영역에 들어온다. 이렇게 되면 MYNS::value를 value라는 이름으로 참조할 수 있으므로 같은 이름의 지역변수를 선언할 수 없다. value라는 명칭이 main에서 선언한 지역변수인지 MYNS의 변수인지 구분되지 않으며 그래서 이 상황은 에러로 처리된다. 같은 이름의 전역변수가 있다면 이는 별 문제가 되지 않는데 전역 명칭은 지역 명칭에 의해 가려지며 :: 연산자로 전역 명칭을 참조할 수 있는 별도의 문법이 제공되기 때문이다.
using 선언에 의해 지정한 명칭을 이 영역에서 사용할 수 있게 되었으므로 같은 이름의 명칭을 사용할 수 없다. 이 문제를 해결하려면 지역변수 value의 이름을 바꾸든가 아니면 using 선언을 취소하고 MYNS::value로 써야 한다. 다음은 동일한 코드로 using 지시자의 경우를 보자.
예 제 : usingdireconflict
|
#include <Turboc.h>
namespace MYNS {
int value;
double score;
void func() { printf("I am func\n"); }
}
int value;
void main()
{
using namespace MYNS;
int value=3; // 지역변수 선언
value=1; // 지역변수 value
::value=2; // 전역변수 value
MYNS::value=3; //
}
using 지시자의 경우 MYNS의 명칭 전체를 main 블록에서 참조할 수 있도록 한다. using 선언과 다른 점은 지정한 네임 스페이스 소속의 명칭과 같은 이름의 지역변수를 선언할 수 있다는 점이다. 이 경우 main의 지역변수 value에 의해 MYNS::value가 가려지며 main 내에서 value를 단독으로 사용하면 지역변수 value를 의미한다. 지역변수에 의해 같은 이름의 전역변수가 가려지는 것과 동일하다. 물론 MYNS의 value를 꼭 참조하려면 MYNS::value 형식으로 계속 참조할 수 있다.
요약하자면 using 선언은 명칭이 충돌할 경우 에러로 처리하는데 비해 using 지시자는 네임 스페이스의 명칭에 대한 가시성이 제한될 뿐 에러나 경고를 내지 않는다. 언뜻 생각하기에 using 지시자가 더 관대한 것 같지만 사실 이런 상황이 골치 아픈 에러의 원인이 될 수도 있다. 개발자는 자신이 어떤 명칭을 액세스하는지 정확히 모르는 상태에서 위험한 코드를 계속 작성하게 될 것이다. 일반적으로 애매한 상황보다는 명확하게 에러 처리를 하는 것이 훨씬 더 바람직하다. 그래서 가급적이면 using 지시자로 네임 스페이스의 전체 명칭을 가져 오는 것보다 using 선언으로 꼭 필요한 것만 선별적으로 가져오는 것이 더 좋다.
모호한 상황
using 지시자에 의해 코드가 모호해지는 다른 경우를 보도록 하자. 다음 코드는 명백한 에러로 처리된다.
namespace A {
double i;
}
namespace B {
int i;
}
void main()
{
using namespace A;
using namespace B;
i=3; // 모호하다는 에러 발생
}
i라는 명칭을 두 네임 스페이스에 동시에 선언하는 것은 분명히 가능하다. main에서 using 지시자로 A, B의 네임 스페이스 명칭을 가져오도록 했는데 이렇게 될 경우 main에서 참조하는 i가 어떤 네임 스페이스의 i인지가 모호해진다. A, B의 수준이 같아서 지역, 전역의 경우처럼 한쪽의 가시성을 제한하는 것도 불가능하다. A::i, B::i로 소속을 명확하게 밝히든지 아니면 한쪽의 using 지시자를 제거해야 한다. 다음은 using 선언에 의해 모호해지는 상황을 보자.
void main()
{
using A::i;
using B::i; // 중복된 선언이라는 에러 발생
i=3;
}
using 선언은 지정한 명칭을 블록으로 가져 오는데 A::i를 가져오는 것은 성공하지만 B::i는 실패한다. 왜냐하면 A::i가 이미 main 함수 영역에 들어와 있기 때문에 같은 이름의 i를 또 가져올 수 없는 것이다. 두 선언 중 하나를 취소하고 한쪽은 :: 연산자로 소속을 밝히는 수밖에 없다.
네임 스페이스에 속한 명칭을 참조할 때는 소속을 밝히는 것이 원칙적이며 이렇게 하면 아무런 문제가 없을 것이다. 그러나 매번 그렇게 하기에는 너무 번거롭기 때문에 using 선언이나 using 지시자를 사용하는데 이 두 방법은 어디까지나 명칭의 소속을 찾는 임시 방편일 뿐 완벽할 수가 없다. 조금 편해 보고자 이런 애매한 방법을 쓰는 것보다는 차라리 일일이 소속을 밝히고 쓰는 것이 가장 완벽한 방법이다.
네임 스페이스는 이름 충돌을 제거하기 위해 도입된 것인데 충돌을 해결하는 목적은 달성할 수 있지만 쓰기에 너무 불편하다. 그래서 using 지시자나 선언으로 네임 스페이스의 명칭을 참조하는 조금 편리한 방법이 제공되는데 이들은 구분해 놓은 소속을 다시 합치는 반대 동작을 하기 때문에 다소 부작용이 있다. 대개의 경우 별 문제가 없지만 가끔 말썽을 부리는 경우가 있다. 그래서 using 선언은 큰 부작용없이 불편하지 않는 정도의 적당한 수준에서만 사용해야 한다.
별명
네임 스페이스는 우연한 충돌을 방지하기 위해 보통 긴 이름을 주는데 이름이 너무 길면 입력하기에 번거롭고 코드도 지저분해진다. 이럴 경우 namespace 키워드 다음에 A=B; 형태로 긴 이름 대신 짧은 별명을 정의할 수 있다. 별명은 동일한 대상에 대한 다른 이름이므로 이후 B라는 이름 대신 A를 사용하면 된다. 다음 예를 보자.
namespace VeryVeryLongNameSpaceName {
struct Person { };
}
void main()
{
namespace A=VeryVeryLongNameSpaceName;
A::Person P;
}
아주 긴 이름의 네임 스페이스 이름을 A라는 짧은 별명으로 정의했다. 이후 이 네임 스페이스에 속한 명칭을 참조할 때 A::을 대신 붙이면 된다. 이런 용도라면 #define 매크로 상수를 사용할 수도 있는데 매크로는 전역적이라는 점이 불편하다. 별명 선언문은 이 선언문이 있는 블록에서만 효력을 발휘한다. 여러 단계로 중첩된 네임 스페이스를 사용할 때는 별명이 특히 유용하다.
namespace MRG=MyCompany::Research::GameEngine;
이상으로 C/C++에 최근 추가된 네임 스페이스에 대해 알아보았다. 명칭 충돌을 해결하기 위한 근본적인 방법으로서 제시된 것이기는 하지만 문법의 복잡성에 비해 실용성이 높다고 보기는 어렵다. 범용 라이브러리를 작성할 때나 한 번 써 볼만하며 또 남이 만들어 놓은 라이브러리를 활용할 때도 네임 스페이스에 대한 사용법을 알고 있어야 한다. C++ 표준 라이브러리는 모두 std 네임 스페이스에 선언되어 있다. 그래서 C++ 프로그램은 통상 using namespace std;로 시작한다.
34-2.그 외의 문법
네임 스페이스까지 공부했으면 이제 C++의 기본 문법을 모두 마쳤다. 그러나 여기까지 공부를 했다고 해서 C++ 문법을 모두 다 살펴본 것은 아니며 아직까지도 일부 빠진 문법들이 있다. 이 책은 자습서이며 학습의 순서를 중요시하다보니 중간 중간에 일부 고급 문법들을 의도적으로 누락했다. 이 문법은 어렵기도 하거니와 난이도에 비해 실용성이 높지 않아 처음 공부하는 사람에게는 오히려 혼란만 가중시키며 흥미를 떨어뜨리고 체력을 소진케하여 전투력에 큰 방해가 된다. 독자들이 이런 내용을 알아서 선별할 수 있다면 좋겠지만 처음 배우는 사람이 문법의 중요성을 판단하기 어려우므로 기본 문법을 익힌 후에 심화 학습 단계에서 볼 수 있도록 뒷부분에 따로 정리했다.
잘 사용되지 않는 문법들이기는 하지만 그렇다고 해서 전혀 쓸 데가 없는 문법은 아니다. 때로는 이런 문법들이 요긴하게 활용되는 곳도 있고 알아 두면 C++언어와 객체 지향에 대해 더 깊게 이해할 수 있는 재미있는 내용들도 많다. 다만 사용 빈도가 낮아 C++을 처음부터 순서대로 공부하는 사람에게는 어려워 보이고 필요성을 느끼지 못하므로 학습의 흐름을 방해하지 않도록 별도로 정리해 놓은 것 뿐이다. C++에 대한 개념을 익힐 때는 이런 고급 문법을 무시하는 것이 좋으며 어느 정도 경험이 쌓이면 그때 내공 향상을 위해 읽어 보도록 하자.
34-2-가.객체의 자기 방어
실제 세상에 존재하는 모든 사물들은 자신이 가질 수 있는 적법한 속성 범위를 가지고 있으며 범위를 지나치게 벗어나는 사물은 제대로 된 사물이 아니다. 예를 들어 사람의 나이가 2000살일 수는 없고 모니터의 크기가 380인치라거나 -17인치가 될 수는 없다. 실세계의 사물을 모델링하는 객체도 마찬가지로 적절한 값을 가질 때만 의미있는 객체가 될 수 있으며 값이 틀리면 객체는 무효해진다. 예를 들어 다음과 같은 선언문으로 객체를 생성했다고 해 보자.
Position Where(120,-100,'Z');
Person Grand(NULL,4900);
콘솔 화면은 가로로 80, 세로로 25의 범위를 가지며 (120, -100)이라는 좌표는 존재하지 않으므로 이런 좌표를 나타내는 Position 클래스의 객체 Where는 무효하다. 또한 사람을 표현하는 Person 클래스의 Grand 객체는 이름이 없으며 나이가 무려 4900살이므로 실제로 존재하는 사람일 수 없다. 마찬가지로 날짜 객체가 13월 38일로 초기화된다거나 마우스 객체의 버튼 수가 101개가 된다면 이 역시 무효한 객체들이다.
무효한 객체는 논리적으로 잘못되었을 뿐만 아니라 치명적인 에러의 원인이 되기도 한다. 위 예의 Grand 객체의 경우 이름이 NULL인데 이 객체의 이름을 출력한다거나 길이를 조사한다거나 또는 이름 버퍼를 변경하고자 한다면 어떻게 되겠는가? 프로그램의 모든 논리가 정확하다면 이런 말도 안되는 객체들이 만들어질 리가 없겠지만 현실적으로 실수나 또는 불가피한 예외로 이런 객체가 만들어질 가능성은 항상 있다.
따라서 클래스는 이런 잘못된 상태의 객체가 만들어지지 않도록 스스로 방어해야 할 필요가 있다. 객체가 초기화되는 시점은 생성자가 호출될 때이므로 생성자에서 인수의 값을 보고 과연 규칙에 맞는 객체인지 아닌지를 점검할 수 있다. 구조체는 외부에서 주는 값을 선택의 여지없이 저장하기만 하는데 비해 객체는 생성자가 직접 초기화하므로 스스로의 무결성을 지킬 수 있다. 객체를 무효하게 만들 가능성이 있는 인수가 전달되었을 때 생성자는 여러 가지 조치를 취할 수 있는데 어떤 식으로 자신을 방어할 수 있는지 가능한 방법들을 열거해 보자. 아래의 코드들은 selfdefence 예제로 작성되어 있으므로 하나씩 주석을 풀어가며 테스트해 보아라.
① 가장 쉬운 방법은 시키는대로 하고 별도의 조치를 취하지 않는 것이다. 좀 이상하게 들리겠지만 때로는 이런 방법이 가장 현명할 수도 있다. 왜냐하면 이런 객체를 만든 곳에서 잠시 후 객체의 이상 동작을 확인하고 틀렸다는 것을 알 수 있으며 따라서 곧 모종의 조치를 취할 수 있기 때문이다. 이런 원칙을 GIGO(Garbage In Garbage Out:굳이 번역하자면 "니가 잘못했잖아")라 하는데 입력이 틀렸으니 틀린대로 동작하도록 내버려둔다는 뜻이다.
② 조건이 만족되지 않을 경우 초기화를 거부하고 쓰레기값을 가지도록 내 버려둔다. Position 객체의 경우 세 입력값 중 문자는 아무값이나 허용하고 좌표값은 콘솔 화면 안에 있는지를 점검할 수 있다. 생성자의 코드를 수정한다면 다음과 같아질 것이다.
Position(int ax, int ay, char ach) {
if (ax >=0 && ax < 80 && ay >=0 && ay < 25) {
x=ax; y=ay; ch=ach;
}
}
이렇게 되면 값이 유효할 때만 초기화되며 그렇지 않을 경우는 무슨 값일지도 모르는 쓰레기값을 가지게 된다. 이 방법은 첫 번째 방법보다 오히려 더 무책임한 방법이다. 쓰레기값보다는 차라리 입력된 틀린값을 가지도록 하는 것이 더 낫다.
③ 틀린 값이 입력되었을 때 무난한 값으로 바꿔서 초기화한다. 좌표의 경우 원점인 (0,0)이 가장 무난하며 글자는 공백으로 초기화하면 될 것이다.
Position(int ax, int ay, char ach) {
if (ax >=0 && ax < 80 && ay >=0 && ay < 25) {
x=ax; y=ay; ch=ach;
} else {
x=y=0;
ch=' ';
}
}
이렇게 되면 일단 객체 자체는 유효해지므로 이상 동작을 할 위험은 없어진다. 그러나 이 객체를 만든 사람은 자신이 객체를 잘못 만들었다는 것을 확인하기 어려우며 틀린지도 모르고 실행될 것이다. GIGO의 원칙에 어긋나므로 바람직하지는 않지만 정말로 무난한 값이 존재하는 클래스라면 이 방법을 쓸 수도 있다.
④ 틀린 입력에 대해 적극적인 에러 처리를 한다. 이 방법이 가장 좋아 보이겠지만 안타깝게도 생성자에서 할 수 있는 에러 처리에는 한계가 있다. 생성을 거부한다거나 스스로를 파괴하는 것은 불가능한데 왜냐하면 컴파일러가 생성자를 호출했다는 것은 생성된다는 신호를 보낸 것이지 생성해도 되느냐는 질문을 한 것이 아니기 때문이다. 게다가 생성자는 리턴값이 없기 때문에 에러를 보고할 방법도 없고 설사 있다 하더라도 생성한 곳에서 이 값을 점검하기도 어렵다. 기껏해야 오류가 있다는 메시지를 출력하는 정도만 할 수 있다.
Position(int ax, int ay, char ach) {
if (ax >=0 && ax < 80 && ay >=0 && ay < 25) {
x=ax; y=ay; ch=ach;
} else {
puts("야! 값이 틀렸잖아. 니 코드를 점검해 봐.");
}
}
실행중에 갑자기 이런 에러 메시지가 출력되면 사용자는 당황스러워하겠지만 생성자가 에러에 대해 취할 수 있는 가장 좋은 대책이 바로 에러를 냉큼 알리는 것이다. 개발자가 이 메시지를 본다면 자신의 실수를 즉시 수정할 수 있을 것이다. 어차피 개발자에게 버그는 피할 수 없는 숙명이라면 그 버그를 가급적이면 빨리, 정확하게 알 수 있도록 하는 것이 최선의 해결책이다. 실행중에 에러를 보고하는 좀 더 공식적이고 권장되는 방법이 바로 assert 함수이다.
Position(int ax, int ay, char ach) {
assert(ax >=0 && ax < 80 && ay >=0 && ay < 25);
x=ax; y=ay; ch=ach;
}
assert 함수는 괄호안의 조건이 만족되지 않을 경우 프로그램을 즉시 종료하고 어디가 어떻게 왜 틀렸다는 것을 출력한다. 그래서 개발자에게 실수를 확실하게 알려 최대한 신속하게 버그를 고칠 수 있도록 한다. 틀린 코드를 가지고 나중에 말썽을 부릴 바에야 차라리 지금 죽어 버리라는 지시인 것이다. 잘 만들어진 클래스의 내부를 들여다 보면 여기저기에 assert(또는 ASSERT)문이 있는 것을 볼 수 있는데 이는 클래스 개발자가 스스로를 방어하기 위해 쳐 놓은 일종의 버그 트랩이다. 여러분들도 실제 프로젝트를 한다면 assert를 가급적 많이 활용해야 한다.
그러나 이 책의 예제들은 assert를 사용하지 않으며 틀린 입력에 대해 아무런 조치도 취하지 않는데 이는 예제로서의 본분을 충실히 수행하기 위해서일 뿐이다. 원론적인 예제에 발생 빈도가 희박한 에러 처리문을 여기저기 삽입하는 것은 설명하고자 하는 논리에 집중하는데 방해가 된다. 그러나 실전에서는 assert를 꼭, 그것도 가급적이면 많이 써야 한다는 것을 명심하도록 하자. 흔히 하는 말로 assert로 도배를 해 놔야 하며 이 도배짓이 위기의 순간에 정말 큰 힘이 된다.
⑤ C++이 언어 차원에서 가장 권장하는 방법은 예외를 던지는 것이다. 생성자는 리턴을 할 수 없지만 예외를 던질 수는 있다.
Position(int ax, int ay, char ach) {
if (ax < 0 || ax >= 80) {
throw ax;
}
if (ay < 0 || ay >= 25) {
throw ay;
}
x=ax; y=ay; ch=ach;
}
void main()
{
try {
Position Where(120,-100,'Z');
Where.OutPosition();
} catch(int a) {
printf("%d는 화면 바깥의 좌표입니다.\n",a);
}
}
좌표가 원하는 범위 바깥일 경우 이 값을 예외로 던졌다. 좀 더 상세한 예외 정보를 전달하고 싶으면 내부에 예외 클래스를 정의하고 이 클래스의 객체를 던지면 된다. 예외를 일으킬 수 있는 객체 생성문은 try catch 블록으로 감싸야 하므로 다소 번거로운 면이 있기는 하다.
생성자에서 자신을 초기화할 때 뿐만 아니라 실행중에 객체의 상태를 변경하는 멤버 함수들도 잘못된 값으로부터 자신을 방어할 수 있다. 사용자들이 정확한 사용방법을 숙지하지 못한 상태로 객체를 부주의하게 다룰 수도 있기 때문에 안전성을 높이기 위해 객체는 섬세한 에러 처리를 해야 한다. 멤버 함수는 값을 리턴할 수도 있고 객체를 파괴할 수도 있으므로 생성자보다 훨씬 더 다양한 방법으로 에러에 대처할 수 있다.
34-2-나.생성자의 활용
생성자와 파괴자는 함수이면서도 자동으로 호출된다는 점에 있어서 일반 함수와는 좀 다르게 취급된다. 맡은 일이 특수하다 보니 적용되는 문법도 일반 함수와 다른 면이 많고 그래서 독특한 활용처가 있다. 객체를 만들거나 파괴하기만 하면 컴파일러가 알아서 호출하도록 되어 있어 객체 선언문이 어떤 동작을 하도록 할 수 있다. 이 점을 잘 활용하면 생성자와 파괴자를 아주 특수한 용도로 활용할 수 있는데 프로그램 전역적인 초기화와 종료 처리에 아주 유용하다.
다음 클래스는 객체 자체가 별다른 기능을 가지지는 않지만 생성자에 난수 발생기를 초기화하는 코드가 작성되어 있다. 따라서 이 클래스형의 객체를 하나라도 생성하기만 하면 srand 함수가 자동으로 호출되어 난수 발생기가 초기화될 것이다.
예 제 : RandInit
|
#include <Turboc.h>
class RandomInitializer
{
public:
RandomInitializer() { srand(GetTickCount()); }
};
RandomInitializer R;
void main()
{
int i;
for (i=0;i<10;i++) {
printf("%d\n",random(100));
}
}
더 재미있는 것은 이 객체가 전역일 경우 프로그램과 함께 생성되므로 생성자가 main 함수보다도 더 빨리 호출된다는 점이다. 그래서 main의 선두에 있는 어떤 코드보다도 실행 우선 순위가 높아 전역적인 초기화에 적합하다. R 객체 선언문이 있기만 하면 프로그램 시작 직후부터 난수 발생기는 벌써 초기화되며 main에서 곧바로 rand를 호출해도 이 함수가 리턴하는 값은 완전한 난수이다. 예제를 실행할 때마다 전혀 다른 난수들이 생성될 것이다.
생성자와 파괴자는 프로그램이 동작하는 특수한 환경을 설정하고 프로그램이 종료될 때 원래대로 돌려 놓는데도 아주 유용하다. 가령 어떤 프로그램은 특정한 해상도에서만 실행되는 제약이 있다고 하자. CD-ROM 타이틀이나 게임같은 경우 디자인적인 조화나 게임의 속도를 위해 최적의 해상도를 가지는 경우가 많다. 이럴 때 응용 프로그램이 실행될 때마다 일일이 해상도를 맞추는 것보다 해상도를 변경하는 객체를 만드는 것이 편리하다. 다음 가상의 클래스는 해상도를 원하는 상태로 변경할 뿐만 아니라 원래대로 복구하는 기능까지 가진다.
class ScreenRes
{
private:
int oldwidth,oldheight;
public:
BOOL bSuccess;
ScreenRes(int w,int h) {
// 이전 해상도를 조사해 놓는다.
oldwidth=640;
oldheight=480;
// 화면 해상도를 w, h로 변경하는 가상의 코드
// bSuccess=SetHwaMyunHaeSangDo(w, h);
}
~ScreenRes() {
if (bSuccess) {
// 원래 해상도대로 돌려 놓는다.
// SetHwaMyunHaeSangDo(oldwidth,oldheight);
}
}
};
ScreenRes SR(1024,768);
생성자에서 인수로 전달받은 w, h로 화면 해상도를 변경하되 복구를 위해 기존 해상도를 자신의 프라이비트 멤버 변수에 미리 조사해 놓는다. 생성자에서 필요한 처리를 하므로 클라이언트 프로그램은 이 클래스형의 객체를 생성하면서 원하는 해상도를 생성자로 전달하기만 하면 된다. 예제 코드에서는 SR 객체를 선언하면서 1024, 768을 생성자로 전달했으므로 화면 해상도가 이 크기로 바뀔 것이다.
SR 객체가 전역으로 선언되었으므로 이 프로그램이 실행중인 동안에는 계속 존재하며 프로그램이 종료되기 직전에 파괴될 것이다. 또는 main 함수의 지역 객체로 선언해도 마찬가지이다. 파괴자는 저장해 놓았던 원래 크기대로 화면 해상도를 다시 돌려 놓는다. exit 등의 함수로 프로그램이 강제 종료되는 특수한 상황을 제외하고는 파괴자가 항상 정확하게 호출된다. 따라서 이 객체가 존재하는동안 화면 해상도는 계속 1024*768이라는 것을 보장할 수 있다.
생성자와 파괴자는 실패를 리턴할 수 없으므로 해상도 변경 성공 여부를 bSuccess라는 별도의 멤버 변수에 저장하도록 했다. 클라이언트는 필요할 경우 이 멤버를 읽어 해상도가 제대로 변경되었는지 살펴보고 실행 계속 여부를 결정할 수 있다. 외부에서 이 값을 읽을 수 있어야 하므로 bSuccess는 public 액세스 속성을 가진다. 파괴자도 이 값을 참조하는데 생성자가 해상도 변경에 실패했다면 원래 해상도로 되돌릴 필요가 없다.
이 기법은 여러 가지 상황에 이용 가능한데 특정한 라이브러리가 실행되어 있어야 한다거나 하드웨어가 원하는 상태여야만 하는 조건이 있다면 생성자와 파괴자가 프로그램 실행을 위한 환경을 만들고 종료될 때 알아서 정리하도록 한다. 재활용성도 뛰어난데 이 클래스를 복사해 붙여 넣고 원하는 해상도를 밝히는 것만으로 화면의 초기 상태를 지정할 수 있다.
앞의 두 예는 전역 객체를 활용하여 프로그램 전역적인 초기화를 하는 예인데 이보다 더 좁은 범위에서도 이런 기법이 유용한 경우가 많다. 특정 함수가 실행중인동안만 프로그램의 상태를 잠시 변경하고 싶다면 상태를 변경하는 지역 객체를 선언하기만 하면 된다. 다음 예제의 WaitCursor 클래스는 윈도우즈 환경에서 시간이 오래 걸리는 작업을 할 때 모래 시계 커서를 표시한다. LongCalc 함수가 복잡한 연산을 하는데 최소 2초가 걸린다고 가정하는 것이다. 이 예제는 콘솔 프로젝트가 아니라 Win32 응용 프로그램이므로 프로젝트를 만들 때 Win32 옵션을 선택해야 한다.
예 제 : WaitCursor
|
class WaitCursor
{
public:
WaitCursor() { SetCursor(LoadCursor(NULL,IDC_WAIT)); }
~WaitCursor() { SetCursor(LoadCursor(NULL,IDC_ARROW)); }
};
void LongCalc()
{
WaitCursor C;
Sleep(2000);
}
LRESULT CALLBACK WndProc(HWND hWnd,UINT iMessage,WPARAM wParam,LPARAM lParam)
{
switch(iMessage) {
case WM_LBUTTONDOWN:
LongCalc();
return 0;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return(DefWindowProc(hWnd,iMessage,wParam,lParam));
}
WaitCursor의 생성자에서 커서를 IDC_WAIT, 즉 모래 시계 모양으로 바꾸는데 이 객체를 생성하는 시점에서 커서가 모래 시계로 바뀐다. 그리고 파괴자에서 IDC_ARROW 커서로 다시 바꾸는데 함수가 끝날 때 객체가 사라지면서 원래 커서로 돌려 놓는다. 예제의 편의상 가장 흔하게 사용하는 커서를 디폴트로 가정했는데 사실 원래 커서 모양이 반드시 IDC_ARROW라는 법은 없으므로 좀 더 정교하게 조사할 필요가 있다.
긴 작업을 요하는 함수로 들어왔을 때 모래 시계 커서가 필요하다면 언제든지 WaitCursor C; 식으로 변수만 선언하면 된다. 다시 원래대로 커서를 복구하는 작업은 함수가 끝나는 시점에서 자동으로 수행되므로 별도의 코드를 작성할 필요도 없고 신경쓸 필요도 없다. 함수가 작업을 마칠 때 지역 객체의 파괴자가 자동으로 호출된다는 점을 이용하는 것이다. 객체를 사용하지 않는다면 다음과 같이 작성해야 한다.
void LongCalc()
{
SetCursor(LoadCursor(NULL,IDC_WAIT));
Sleep(2000);
SetCursor(LoadCursor(NULL,IDC_ARROW));
}
일단 길어서 보기 좋지 않고 종료될 때 원래대로 돌려 놓는 것도 수동이라 불편하다. 이 기법은 굉장히 유용해서 MFC같은 고수준 라이브러리에도 그대로 사용된다. MFC에 CWaitCursor라는 클래스가 정의되어 있는데 이 클래스가 위 예제의 WaitCursor와 거의 같으며 사용 방법도 동일하다. 다만 좀 더 고수준이고 라이브러리의 부속 클래스이다 보니 응용 프로그램 객체 등과 더 긴밀한 연관을 맺고 있다는 정도만 다르다.
C에서는 진입점인 main함수가 항상 제일 먼저 실행되지만 C++에서는 그렇지 않을 수도 있다. 위에서 봤다시피 전역 객체의 생성자가 더 우선적으로 실행되는데 이런 식으로 프로그램 시작 후에 초기화되는 것을 동적 초기화(runtime initialize)라고 한다. 동적 초기화는 클래스에만 국한되지 않고 일반 변수에도 사용할 수 있다.
C에서는 int i=3; 식으로 전역변수를 상수로만 초기화할 수 있었지만 C++에서는 수식이나 함수 호출로도 전역변수를 초기화할 수 있다. 전역변수 초기식에 함수 호출이 있다면 컴파일러는 main을 실행하기 전에 이 전역변수를 초기화하기 위해 초기식의 함수를 먼저 호출한다. 이 점을 이용하면 전역변수 초기화 함수에서 원하는 전역 초기화를 할 수 있다. 다음 예제를 보자.
예 제 : runtimeinit
|
#include <Turboc.h>
int randinit()
{
randomize();
// 기타 하고 싶은 초기화 처리
return random(100);
}
int g_r=randinit();
void main()
{
int r=random(100);
printf("g_r=%d,r=%d\n",g_r,r);
}
전역변수 g_r의 초기화식에서 randinit 함수를 호출하는데 여기서 원하는 초기화를 하면 된다. randinit가 일단 제어를 받으면 main에 앞서 원하는 초기화를 할 수 있는데 예제에서는 난수 발생기만 초기화했다. g_r도 물론이고 main에서 만드는 난수는 완전 무작위로 생성될 것이며 실행할 때마다 결과는 달라진다. 별도의 초기화 함수를 만드는 것이 번거롭다면 다음과 같이 콤마 연산자를 활용할 수도 있다.
int g_r=(randomize(),random(100));
전역변수의 동적 초기식을 활용하는 방법은 초기화만 할 수 있을 뿐이지 종료 처리는 할 수 없다는 한계가 있다. 이에 비해 객체는 자동으로 호출되는 파괴자를 가지므로 종료 처리까지도 깔끔하게 처리할 수 있어 역시 생성자, 파괴자가 한 수 위이다. 참고로 위 예제는 동적 초기화를 지원하지 않는 C 컴파일러에서는 컴파일되지 않는다.
34-2-다.초기화 순서
초기화 리스트의 초기식들은 리스트에 나타난 순서가 아니라 멤버의 선언 순서대로 실행된다. 대개의 경우 어떤 멤버가 먼저 초기화되든지 상관없지만 멤버끼리 종속적인 관계에 있을 때는 초기화 순서가 중요한 의미를 가질 수도 있다. 다음 예제를 실행해 보자.
예 제 : InitOrder
|
#include <Turboc.h>
class Test
{
private:
int First;
int Second;
public:
Test(int a) : First(a),Second(First*2) { }
void OutMember() {
printf("First=%d, Second=%d\n",First,Second);
}
};
void main()
{
Test t(4);
t.OutMember();
}
Test 클래스는 두 개의 정수형 멤버를 가지고 있으며 생성자에서 인수 a를 받아 First를 초기화하고 Second는 먼저 초기화된 First의 2배 값으로 초기화한다. main에서 Test형의 객체를 선언하면서 생성자로 4를 전달했으므로 First는 4가 되고 Second는 8이 될 것이다.
First=4, Second=8
너무 너무 당연한 결과처럼 보이지만 선언 순서가 바뀌면 다른 결과가 나올 수도 있다. Test 클래스의 멤버 선언 순서를 다음과 같이 바꿔 보자.
class Test
{
private:
int Second;
int First;
....
이렇게 바꾼 후 다시 테스트해 보면 First는 4가 되지만 Second는 쓰레기값을 가질 것이다. 실행 결과는 "First=4, Second=-1717986920" 이렇다. 왜 이렇게 되는가 하면 초기화 리스트의 순서에 상관없이 앞쪽에 선언되어 있는 Second가 먼저 초기화되고 다음으로 First가 초기화되는데 Second가 초기화될 때 First는 아직 초기화되지 않아 쓰레기값을 가지고 있었기 때문이다. 문제를 해결하려면 먼저 초기화되어야 하는 First를 앞쪽에 선언해야 한다.
이런 순서가 일반 단순 멤버에서는 그리 중요하지 않을 수도 있다. 그러나 포인터가 개입되거나 중요한 크기 정보 등을 초기화할 때는 굉장히 민감한 문제를 일으킨다. 의도적으로 만들기는 했지만 다음 예제는 초기화 순서의 중요함을 단적으로 보여준다.
예 제 : InitOrder2
|
#include <Turboc.h>
class Test
{
private:
int *pi;
int *pi2;
public:
Test(int *p) : pi(p),pi2(pi) { }
void OutMember() {
printf("*pi=%d, *pi2=%d\n",*pi,*pi2);
}
};
int g=1234;;
void main()
{
Test t(&g);
t.OutMember();
}
생성자가 정수 번지 하나를 받으면 pi에 이 번지를 대입하고 pi2도 똑같은 번지로 초기화했다. 아주 정상적인 프로그램이다. 그러나 pi와 pi2의 선언 순서를 바꾸면 십중팔구 실행되자 마자 사망하는 문제 프로그램이 되고 만다. 이럴 경우 클래스 선언문의 멤버 순서를 주의깊게 작성할 필요가 있다. 멤버 순서만 제대로 되어 있다면 Test(int *p) : pi2(pi), pi(p) { } 처럼 초기화 리스트는 아무렇게나 순서를 정해도 상관없다.
언뜻 보기에는 초기화 리스트에 선언되어 있는 순서대로 멤버를 초기화하는 것이 상식적이고 개발자가 초기화 순서를 필요에 따라 통제할 수 있으므로 더 쉬워 보인다. 그리고 리스트의 순서대로 초기화하는 것은 그리 어려운 부탁도 아니다. 그러나 컴파일러 구현상 그렇게 할 수가 없는데 왜냐하면 생성자는 여러 개 존재할 수 있는데 비해 파괴자가 하나밖에 없기 때문이다. 다음과 같이 초기화 순서가 다른 생성자가 두 개 존재한다고 해 보자. 복잡한 클래스의 경우는 10개가 넘을 수도 있다.
Test(int a) : First(a),Second(First*2) { }
Test(int af,as) : Second(as), First(af) { }
리스트의 순서대로 초기화한다면 첫 번째 버전은 First, Second 순으로 초기화하고 두 번째 버전은 Second, First 순으로 초기화를 해야 할 것이다. 이 경우 파괴자는 생성된 역순으로 멤버들을 파괴해야 논리상 합당하다. 생성 순서가 중요한 것처럼 파괴 순서도 마찬가지로 중요하다. 그러자면 객체 스스로 어떤 생성자가 자신을 초기화했는지, 각 멤버들이 어떤 순서대로 초기화되었는지를 기억해야 한다는 얘기인데 이것은 일반적으로 불가능하다.
그래서 파괴자는 무조건 선언된 역순으로 멤버를 파괴하며 이 순서에 맞추기 위해 생성자는 무조건 선언된 순서대로 초기화할 수밖에 없는 것이다. 생성자와 파괴자가 초기화 및 파괴하는 순서를 정하는 가장 쉽고도 명백한 기준은 선언된 순서이며 따라서 초기화 리스트의 초기식 순서는 아무 고려 대상이 되지 못한다. 마찬가지 이유로 다중 상속의 선언문에 나타난 기반 클래스 순으로 초기화된다. 29장의 MultiInherit 예제의 상속문을 보자.
class Now : public Date, public Time
{
Now(int y,int m,int d,int h,int min,int s,int ms,bool b=FALSE)
: Date(y,m,d), Time(h,min,s) { milisec=ms; bEngMessage=b; }
Date, Time순으로 상속을 받았고 초기화 리스트에도 이 순서대로 기반 클래스를 초기화했다. 기반 클래스의 생성자 호출 순서는 상속문의 순서인 Date 먼저, 그리고 Time이다. 초기화 리스트도 우연히 이 순서로 되어 있지만 이 순서는 초기화 순서에는 아무 영향을 미치지 못한다.
Now 클래스의 경우 초기화 순서는 별 의미가 없고 어떤 식으로 초기화되든지 문제가 없지만 기반 클래스의 초기화 순서가 중요할 경우는 클래스 선언문을 주의깊게 작성해야 한다. 먼저 초기화되어야 하는 기반 클래스를 앞에 적고 나중에 초기화될 기반 클래스를 뒤쪽에 적어야 한다. 파괴자는 생성자의 역순으로 호출된다.
34-2-라.비트맵 클래스
Win32 환경의 기본 그래픽 포맷인 비트맵은 내부 구조가 복잡해서 직접 다루기는 무척 어렵고 신경써야 할 것들이 많다. 이런 것들도 클래스로 잘 포장해 놓으면 쓰기 쉽고 재사용하기도 무척 편해진다. Win32의 비트맵을 표현하는 Bitmap 클래스를 만들어 보도록 하자. 비트맵의 보편적인 속성과 일반적인 동작을 추출하여 추상화된 클래스를 디자인하고 한 클래스에 캡슐화하면 된다. 다음은 이 클래스를 선언하는 헤더 파일이다. 이 프로젝트는 비트맵을 출력해야 하므로 콘솔 환경에서는 실행할 수 없으며 Win32 프로젝트로 만들어야 한다.
예 제 : Bitmap.h
|
class Bitmap
{
private:
HBITMAP hBit;
int width,height;
void PrepareSize();
public:
Bitmap() { hBit=NULL; }
Bitmap(int ID) { Load(ID); }
Bitmap(TCHAR *Path) { Load(Path); }
Bitmap(int width,int height);
~Bitmap() { UnLoad(); }
void Load(int ID);
void Load(TCHAR *Path);
void UnLoad() { if (hBit) DeleteObject(hBit); }
void Save(TCHAR *Path);
void Draw(HDC hdc,int x,int y);
void Draw(HDC hdc,int x,int y,int w,int h,int sx=0,int sy=0);
void Draw(HDC hdc,int x,int y,COLORREF Mask);
void Stretch(HDC hdc,int x,int y,int w,int h,int sx,int sy,
int sw=-1,int sh=-1);
int GetWidth() { return width; }
int GetHeight() { return height; }
HBITMAP GetBitmap() { return hBit; }
};
비트맵은 폭과 높이를 가지고 핸들로 표현되므로 이런 값들이 속성으로 선언되어 있다. 비트맵을 표현하는 멤버의 타입이 HBITMAP이므로 이 클래스로 다룰 수 있는 비트맵은 일단은 DDB로 국한된다. 그러나 생성자에서 DIB를 DDB로 변환하는 서비스를 하고 있으므로 비트맵 파일로부터 DDB를 만들어 출력하는 것도 가능하다.
생성자는 모두 4개가 준비되어 있는데 일단 비트맵 객체를 만들 수 있어야 하므로 디폴트 생성자가 있고 리소스로부터 읽을 때, 파일로부터 읽을 때의 생성자가 각각 준비되어 있다. 또한 메모리 DC와 함께 백그라운드 화면으로 사용되는 비트맵을 위해 래스터 데이터없이 크기만을 가지는 비트맵도 만들 수 있다. 리소스와 파일로부터 비트맵을 읽는 생성자들은 직접 작업을 하지 않고 Load 함수를 호출하여 필요한 변환을 수행하도록 한다.
이 함수들이 별도로 분리되어 있는 이유는 디폴트 생성자로 만든 객체로 실행중에 비트맵을 읽을 수도 있어야 하기 때문이다. 파괴자는 비트맵을 파괴하는데 직접 파괴하지 않고 UnLoad 함수를 호출한다. 이것도 마찬가지 이유인데 비트맵 객체를 쓰다가 다른 파일을 로드하면 이전에 사용하던 비트맵을 해제해야 하기 때문이다. 따라서 객체 파괴없이 비트맵만 삭제하는 멤버 함수가 필요하다. Save 함수는 비트맵을 파일로 저장하는데 이때 내부적으로 유지하고 있는 DDB를 DIB로 변환한 후 파일로 저장할 것이다.
제일 중요한 Draw 함수는 모두 4개 정의되어 있다. 지정한 위치에 출력만 하는 함수, 비트맵의 일부를 원하는 부분에 출력하는 함수, 그리고 투명 출력하는 함수들이 Draw라는 같은 이름으로 오버로딩되어 있으며 확대 출력하는 Stretch 함수도 마련되어 있다. 이 외에 비트맵의 크기를 구하는 함수와 비트맵의 핸들을 구하는 함수들이 선언되어 있다.
이 함수들의 구현 코드는 Bitmap.cpp에 작성되어 있는데 소스 리스트는 생략하기로 한다. 이 코드들을 이해하려면 Win32 비트맵에 대해 상당히 많은 공부를 해야 하는데 사용만을 목적으로 한다면 굳이 이 코드를 지금 분석할 필요는 없다. 비트맵을 공부한 후 따로 분석해 보기 바라되 어려운 코드라기 보다는 매번 다시 짜기 귀찮은 코드일 뿐이다. Bitmap 클래스가 완성되었으면 이제 이 객체를 사용하여 비트맵을 자유 자재로 다룰 수 있다. 다음이 테스트 코드이다.
예 제 : BitmapClass
|
void MakeEllipseBitmap()
{
Bitmap Bit(640,480);
HBITMAP OldBitmap;
HDC hdc,MemDC;
int i;
hdc=GetDC(NULL);
MemDC=CreateCompatibleDC(hdc);
ReleaseDC(NULL,hdc);
OldBitmap=(HBITMAP)SelectObject(MemDC,Bit.GetBitmap());
PatBlt(MemDC,0,0,640,480,WHITENESS);
SelectObject(MemDC,GetStockObject(NULL_BRUSH));
for (i=0;i<240;i+=10) {
Ellipse(MemDC,320-i,240-i,320+i,240+i);
}
Bit.Save("c:\\ellipse.bmp");
SelectObject(MemDC,OldBitmap);
DeleteDC(MemDC);
}
LRESULT CALLBACK WndProc(HWND hWnd,UINT iMessage,WPARAM wParam,LPARAM lParam)
{
HDC hdc;
PAINTSTRUCT ps;
static Bitmap Bit;
Bitmap tBit;
TCHAR *Mes="A:파일에서 비트맵 읽기, B:비트맵 저장, C:동심원 비트맵 생성";
switch(iMessage) {
case WM_CREATE:
Bit.Load(IDB_BITMAP1);
return 0;
case WM_KEYDOWN:
switch (wParam) {
case 'A':
hdc=GetDC(hWnd);
if (tBit.Load("test.bmp")) {
tBit.Draw(hdc,10,260);
} else {
MessageBox(hWnd,"test.bmp 파일이 없습니다.","알림",MB_OK);
}
ReleaseDC(hWnd,hdc);
break;
case 'B':
Bit.Save("c:\\save.bmp");
MessageBox(hWnd,"c:\\save.bmp 파일로 저장했습니다","알림",MB_OK);
break;
case 'C':
MakeEllipseBitmap();
MessageBox(hWnd,"c:\\ellipse.bmp 파일로 저장했습니다","알림",MB_OK);
break;
}
return 0;
case WM_PAINT:
hdc=BeginPaint(hWnd, &ps);
TextOut(hdc,10,10,Mes,lstrlen(Mes));
Bit.Draw(hdc,10,50);
Bit.Draw(hdc,210,50,50,50,25,25);
Bit.Stretch(hdc,410,50,Bit.GetWidth()*2,Bit.GetHeight()*2,0,0);
Bit.Draw(hdc,210,150,RGB(0,255,0));
EndPaint(hWnd, &ps);
return 0;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return(DefWindowProc(hWnd,iMessage,wParam,lParam));
}
리소스에 작성되어 있는 비트맵을 읽어와 전체 출력, 일부 출력, 투명 출력, 확대 출력을 하며 비트맵 파일로부터 그림을 읽어들이기도 하고 리소스의 비트맵을 파일로 저장하기도 한다. 또한 메모리 DC를 사용하여 백그라운드에서 그림을 그린 후 파일로 출력하는 테스트도 해 보는데 실행 후에 C 드라이브의 루트 디렉토리를 보면 save.bmp, ellipse.bmp 라는 파일이 생성되어 있을 것이다.
Bitmap 클래스를 범용성있고 안정적으로 만드는데는 꽤 많은 노력과 시간이 든다. 그러나 이 클래스를 사용하는 사용자는 공개된 멤버 함수들의 목록과 짧은 도움말 정도만 있으면 얼마든지 비트맵을 자신의 프로그램에 활용할 수 있다. 이런 식이라면 비트맵뿐만 아니라 훨씬 더 복잡한 구조를 가지는 Jpg나 Png, Mpg 같은 파일들도 어렵지 않게 다룰 수 있을 것이다. 이것이 바로 객체 지향의 혜택이다.
34-2-마.멤버별 복사
클래스가 복사 생성자나 대입 연산자를 정의하지 않을 경우 컴파일러가 디폴트를 만들어 주는데 이 함수들은 멤버별 복사를 수행하여 우변 객체의 멤버를 순서대로 좌변 객체에 대입한다. 그래서 Time이나 Position같이 기본 타입의 멤버만 가진 객체는 컴파일러가 만드는 디폴트 대입 연산자만으로도 충분하다. 그렇다면 디폴트 복사 생성자, 디폴트 대입 연산자가 하는 멤버별 복사라는 것은 어떤 동작인지 연구해 보자.
멤버별로 대입한다는 것은 메모리끼리 복사한다는 것과는 다른데 말 뜻 그대로 클래스의 멤버를 1:1로 서로 대입하는 것이다. int나 double, char 배열처럼 기본 타입은 별도의 처리없이 대입 가능하므로 비트 단위로 메모리 복사되지만 객체는 대입을 위해 대입 연산자가 호출된다. 다음 예제는 멤버별 복사가 단순한 메모리 복사와 어떻게 다른지 보여 준다.
예 제 : DefAssign
|
#include <Turboc.h>
=============== Person 클래스의 정의는 생략 ================
class Book
{
private:
char Title[32];
Person Author;
public:
Book() { strcpy(Title,"제목 미정"); }
Book(const char *aName,int aAge,const char *aTitle) :
Author(aName,aAge) { strcpy(Title,aTitle); }
void OutBook() {
printf("책 제목 : %s\n",Title);
printf("저자 정보 => ");Author.OutPerson();
}
};
void main()
{
Book Hyc("김상형",29,"혼자 연구하는 C/C++");
Hyc.OutBook();
Book CPrg;
CPrg=Hyc;
CPrg.OutBook();
}
Book 클래스는 책을 표현하는데 책 제목과 저자에 대한 정보를 가진다. 저자 정보는 Person 클래스로 표현할 수 있으므로 Book은 Person 타입의 객체 Author를 멤버로 가지며 Book의 생성자에서 Author의 이름과 나이를 초기화한다. main에서는 Book 객체 Hyc를 선언하여 출력해 보았다. 그리고 CPrg라는 다른 객체를 선언한 후 Hyc를 대입하여 출력해 보았는데 결과는 아주 정상적이며 별다른 이상은 없다.
책 제목 : 혼자 연구하는 C/C++
저자 정보 => 이름 : 김상형 나이 : 29
책 제목 : 혼자 연구하는 C/C++
저자 정보 => 이름 : 김상형 나이 : 29
Book은 별도의 대입 연산자를 정의하지 않으므로 컴파일러가 디폴트 대입 연산자를 만들 것이며 이 대입 연산자는 Book의 멤버를 1:1로 대입한다. Title은 단순 타입이므로 메모리 복사되며 Author는 Author의 대입 연산자를 호출하여 깊은 복사를 하도록 할 것이다. 멤버별 복사는 멤버가 대입 연산자를 정의할 때 이 연산자를 통해 대입한다는 면에서 단순한 메모리 복사와는 다르다. 포함된 객체가 대입 연산자를 잘 정의하고 있으므로 Book은 별도의 대입 연산자를 정의할 필요가 없다.
Person이 대입 연산자를 정의하지 않으면 이 객체는 디폴트 대입 연산자에 의해 얕은 복사를 하게 되므로 다운될 것이다. 별도의 대입 연산자를 정의하지 않으면 컴파일러가 알아서 만들되 단 예외가 있다. 멤버 중에 값을 변경할 수 없는 레퍼런스나 상수가 포함되어 있다면 이때는 멤버별 대입을 할 수 없으므로 컴파일러는 대입 연산자를 정의하지 않는다. 다음 예제를 보자.
예 제 : ConstRef
|
#include <Turboc.h>
class ConstRef
{
public:
int value;
int &ri;
const int ci;
ConstRef(int av,int &ari,const int aci) : value(av),ri(ari),ci(aci) { }
};
void main()
{
int i,j;
ConstRef t1(1,i,2);
ConstRef t2(3,j,4);
t2=t1;
}
ConstRef 클래스는 일반 멤버 value와 레퍼런스 멤버 ri, 상수 멤버 ci를 가지고 있다. 레퍼런스와 상수 멤버는 생성자가 인수로 전달받아 초기화 리스트에서 초기화한다. main의 테스트 코드에서는 두 개의 객체 t1,t2를 생성하고 t2를 t1에 대입했다. ConstRef가 별도의 대입 연산자를 정의하지 않으므로 t1의 모든 멤버가 t2로 대입될 것이다.
그러나 이런 대입은 허가될 수 없다. 왜냐하면 레퍼런스는 대상체가 한 번 정해지면 변경할 수 없기 때문이다. t2.ri는 생성될 때부터 j에 대한 별명으로 정의되었으므로 죽을 때까지 j만 가리켜야 한다. 그런데 객체끼리의 대입에 의해 t1.ri인 i를 새로운 대상체로 지정하려고 했으므로 이는 레퍼런스의 정의에 맞지 않다. 상수 멤버도 마찬가지 규칙이 적용된다. t2.ci는 생성할 때 4로 초기화되었으므로 언제까지고 4의 값만 가져야 하는데 멤버 대입에 의해 갑자기 2의 값으로 변경되어야 하므로 이 또한 상수의 정의에 어긋난다.
그래서 컴파일러는 레퍼런스 멤버나 상수 멤버를 가진 클래스에 대해서는 설사 대입 연산자가 없다 하더라도 디폴트 대입 연산자를 정의하지 않는다. 이런 클래스는 사실상 대입을 할 수 없으므로 대입 연산자를 숨겨 대입을 금지하든가 아니면 대입 가능한 멤버만 선별적으로 대입하도록 별도의 대입 연산자를 정의해야 한다. 다음 코드를 추가하면 컴파일된다.
ConstRef &operator=(const ConstRef &Other) {
if (this != &Other) {
value=Other.value;
}
return *this;
}
상수와 레퍼런스는 그대로 두고 변경할 수 있는 value의 값만 Other로부터 대입받았다. 이 연산자 본체에 ri=Other.ri를 쓰면 좌변 객체의 ri 대상체 값으로 우변 객체의 ri 대상체의 값을 대입받는 코드가 된다. rc=Other.rc 대입문을 작성하면 당연히 에러로 처리될 것이다.
34-2-바.오버로딩과 오버라이딩
상속받은 멤버 함수를 재정의하는 기법을 오버라이딩(Overriding)이라고 하는데 인수 목록이 다른 함수를 같은 이름으로 중복 정의하는 오버로딩(Overloading)과는 용어가 비슷하므로 잘 구분하도록 하자. 둘 다 함수의 이름을 동일하게 작성한다는 점에서는 비슷하지만 오버로딩은 이미 있는 함수에 하나를 더 추가하는 것이고 오버라이딩은 이미 있는 함수를 무시하고 새로 만드는 것이다. 오버로딩은 한국말로 중복 정의이고 오버라이딩은 한국말로 재정의이다.
이 두 용어는 발음과 뜻이 비슷해서 혼란스럽기도 하지만 상속 계층에서 동시에 적용될 때의 효과가 다소 비상식적이어서 헷갈리기도 한다. 오버로딩이란 클래스와는 직접적인 상관이 없어서 전역 함수끼리도 오버로딩될 수 있다. 이에 비해 오버라이딩은 클래스간의 관계, 그 중에서도 상속된 부모와 자식 관계에서만 적용되며 전역 함수에 대해서는 적용되지 않는다.
클래스에서는 오버로딩과 오버라이딩이 동시에 일어날 수 있다. 클래스의 멤버 함수들끼리 중복 정의가 가능하고 또 파생 클래스에서 상속받은 멤버를 재정의하는 것도 가능하다. 그런데 파생 클래스에서 상속된 멤버 함수와 인수 목록이 다른 함수를 같은 이름으로 재정의하면 이때는 오버로딩이 적용되지 않는다. 즉, 인수 목록이 아무리 달라도 파생 클래스가 같은 이름으로 함수를 재정의하면 동일한 이름을 가지는 부모의 모든 함수들이 가려진다. 다음 예제로 이를 테스트해 보자.
예 제 : InheritOverload
|
#include <Turboc.h>
class B
{
public:
void f(int a) { puts("B::f(int)"); }
void f(double a) { puts("B::f(int)"); }
};
class D : public B
{
public:
void f(char *a) { puts("D::f(char *)"); }
};
void main()
{
D d;
d.f(""); // 가능
d.f(1); // 에러
d.f(2.3); // 에러
}
B::f(int), B::f(double) 함수가 있는데 파생 클래스인 D에서 D::f(char *)를 재정의했다. 이 함수들은 모두 인수 목록이 다르므로 같은 이름으로 중복 정의될 수 있지만 상속 관계에서는 이것이 허용되지 않는다. D에서 f(char *)를 재정의하는 순간 기반 클래스의 f(int), f(double)은 모두 가려진다. D의 객체에서 f 함수를 호출하면 이는 자신의 멤버 함수 f(char *)를 의미하며 설사 d.f(1)로 정수 인수를 주어도 상속받은 f(int)가 호출되지 않는다. 그러나 가려질 뿐이지 상속은 되므로 범위 연산자를 사용하여 d.B::f(1), d.B::f(2.3)으로 호출하면 재정의된 f에 의해 가려진 부모의 멤버 함수를 호출할 수도 있다.
오버로딩과 오버라이딩이 양립할 수 없기 때문에 상속받은 멤버 함수를 재정의할 때는 부모의 멤버 함수와 완전히 같은 원형으로 재정의해야 한다. 부모의 f(int)를 그대로 상속받으면서 여기에 추가로 f(char *)를 오버로딩할 수는 없다는 얘기이다. 만약 원형이 다르다면 아예 함수 이름을 다르게 작성하는 것이 바람직하다. 또한 기반 클래스에 여러 개의 함수가 중복 정의되어 있다면 이 함수들을 모두 재정의하거나 아니면 아예 재정의하지 말아야 한다. 하나만 재정의하면 이 함수에 의해 원형이 다른 함수들은 모두 가려질 것이다.
이렇게 된 이유는 다소 복잡한 사정이 있다. 만약 오버로딩된 함수들의 일부를 상속받으면서 그 중 원하는 것만 재정의할 수 있도록 한다면 오버로딩된 함수를 결정하는 메커니즘이 과다하게 정교해져야 한다. 또한 부모 클래스가 오버로딩된 함수의 원형을 하나 더 추가할 때 자식 클래스가 호출하는 함수가 뜻하지 않게 다른 함수로 바뀌어 버릴 위험도 있다. 다음 예제를 보자.
예 제 : OverrideOverload
|
#include <Turboc.h>
class Base
{
public:
void f(char *) { puts("B::f(char *)"); }
void f(long) { puts("B::f(long)"); }
};
class Derived : public Base
{
public:
void f(double) { puts("D::f(double)"); }
};
void main()
{
Derived d;
d.f(1234);
}
Base가 f(long), f(char *)를 중복 정의해 놓았고 이를 상속한 Derived가 f(double)을 재정의하고 있다. 오버로딩된 멤버 함수의 일부만 재정의했으므로 부모의 f 함수는 모두 가려진다. main에서 d객체의 f(1234) 함수를 호출했는데 실행해 보면 D::f(double) 함수가 호출될 것이다. 알다시피 1234는 정수 상수이지 실수 상수가 아니므로 f(long)이 더 가깝지만 이 함수가 가려지기 때문에 가장 가까운 함수가 호출되는 것이다. 만약 Derived가 부모의 오버로딩된 함수들을 전부 상속받는다면 f(1234)는 f(long)이 호출되는 것이 옳다. D::f(double) 함수를 잠시 주석 처리해 버리면 전부 상속받으므로 이때는 B::f(long)이 호출될 것이다.
클래스 계층은 반드시 한 사람이 다 개발하지 않으며 팀을 이루어 계층을 만드는 경우가 많다. 핵심 기반 클래스는 연구소나 공통 라이브러리 개발 부서에서 만들고 응용 프로그램 제작팀에서 이 클래스를 상속받아 재사용하는 것이 일반적인 개발 형태이다. Base를 만든 개발자가 어느날 자신의 필요에 의해 다음 함수를 추가했다고 해 보자.
class Base
{
public:
void f(int) { puts("B::f(int)"); }
};
f(1234)의 1234는 double보다 long에 가깝고 long보다 int와 더 일치하므로 d.f(1234)가 어느날 갑자기 부모의 새로 생긴 함수를 호출하게 될 것이다. 오버로딩된 함수들은 비슷한 동작을 하는 것이 원칙적이지만 때로는 내부 구현이 완전히 다를 수도 있다. Derived 개발자는 잘 동작하던 클래스가 갑자기 영문도 모른채 엉뚱한 함수를 호출해 대는 테러를 당하게 될 것이다.
오버로딩된 함수가 하나 더 늘어나는 것은 클래스의 인터페이스가 바뀌는 것이 아니기 때문에 컴파일 에러가 발생하지 않는다. 클래스 계층이 깊다면 부모 클래스의 함수 목록 변화가 어디까지 영향을 미칠지 그 파급 효과를 예측하기가 어렵다.
컴파일러는 오버로딩된 함수를 결정하기 위해 인수의 타입을 검사하는데 이 과정에서 완전히 일치하는 함수가 없을 경우 암시적인 타입 변환을 통해 최대한 일치하는 함수를 찾는다. 이런 복잡하고 직관적이지 못한 변환에 의해 호출될 함수가 결정되는데 여기에 상속 계층까지 추가되면 더욱 혼란스러워질 것이다. 그래서 C++은 오버로딩된 함수들 중 하나를 재정의할 경우 나머지 함수들을 아예 숨겨 버림으로써 이런 사고를 미연에 방지한다.
다소 복잡한데 결론을 내려보면 이렇다. 오버로딩된 멤버 함수들은 특정 동작을 다양한 방식으로 처리할 수 있는 함수의 묶음이다. 그래서 파생 클래스는 이 함수들을 전부 재정의하든지 아니면 부모의 함수들을 고스란히 상속받아 그대로 쓰든지 둘 중 하나를 선택해야 한다. 실제로 오버로딩과 오버라이딩이 충돌하는 경우는 발생 빈도가 극히 희박하다.
34-2-사.문법의 예외
부모와 자식간의 포인터 관계에 대한 아주 특수한 예외 하나를 연구해 보자. 앞에서 알아 봤다시피 부모 포인터가 자식 객체를 가리키는 것은 항상 안전하다. 그러나 이 당연한 법칙에도 예외가 존재하는데 문법적으로는 가능하지만 실질적으로는 문제가 있는 경우도 있다. 언제인가 하면 부모 타입의 포인터가 자식 타입 객체의 배열을 가리킬 때이다. 다음 예제를 보자.
예 제 : ObjArrayPtr
|
#include <Turboc.h>
class Base
{
private:
int bnum;
public:
virtual void OutMessage() { printf("Base Class\n"); }
};
class Derived : public Base
{
private:
int dnum;
public:
virtual void OutMessage() { printf("Derived Class\n"); }
};
void main()
{
Base arB[5];
Derived arD[5];
int i;
Base *pB=arD;
for (i=0;i<5;i++) {
pB->OutMessage();
pB++;
}
}
Base 타입의 포인터 pB가 자식 타입의 배열 arD의 번지를 대입받았는데 이 문장은 적법하다. pB로부터 참조되는 모든 멤버 변수와 멤버 함수가 존재하기 때문이다. 그러나 pB++로 다음 객체로 이동할 때는 정확한 위치를 찾지 못한다. 왜냐하면 포인터에 대한 ++ 연산은 sizeof(대상체)만큼의 이동인데 Base와 Derived의 크기가 다르기 때문이다.
컴파일러는 pB가 가리키는 대상체의 크기가 멤버 변수와 vptr의 크기를 더한 8바이트라고 생각하는데 arD의 요소들은 12바이트의 크기를 가지기 때문이다. ++연산으로 증가한 곳에 있는 객체에는 반드시 vptr이 있어야 하는데 그렇지 못한 상황이 벌어지게 되고 엉뚱한 가상 함수 테이블을 찾아 그 내용대로 점프해 버리므로 다운되는 것이다.
다음은 또 하나의 예외인데 부모 타입의 포인터가 자식을 가리킬 수 있지만 private 상속한 경우는 정확한 부모 자식 관계라고 보기 어려우므로 이 정의가 성립되지 않는다. 앞장에서 만들었던 PrivateInherit 예제의 끝에 다음 테스트 코드를 작성해 보자.
class Product : private Date
{
....
void main()
{
Product S("새우깡","농심",2009,8,15,900);
S.OutProduct();
Date *pD;
pD=&S; // 에러
}
Date를 private 상속하여 Product를 파생시켰다. 이 상태에서 Date 타입의 포인터 pD가 Product 객체 S의 번지를 대입받았는데 이 대입문은 에러로 처리된다. 왜냐하면 private 상속은 인터페이스를 상속받지 않으므로 S에는 OutDate라는 인터페이스가 존재하지 않기 때문이다. pD에 &S를 대입하는 것을 허락하면 pD->OutDate()도 가능해야 하는데 S 객체에 OutDate는 숨겨져 있으므로 외부에서 이 함수를 호출할 수 없다. 사실 private 상속은 구현만 상속하므로 기반 클래스와 파생 클래스는 부모 자식간이라고 보지 않는 것이 타당하다. 물론 public 상속으로 바꾸면 위 코드는 잘 동작한다.
34-2-아.C언어에서의 다형성
다형성은 객체 지향 개발 방식의 큰 특징이자 꽃이라고 할만큼 훌륭한 기능이다. 객체 지향이란 특정 언어의 고유 기능이 아니라 프로그래머가 문제를 푸는 사고 방식이므로 C++언어로만 다형성을 구현할 수 있는 것은 아니다. C나 파스칼같은 구조적인 언어는 물론이고 어셈블리같은 저급 언어에서도 다형성을 얼마든지 구현할 수 있다. C++ 컴파일러가 함수를 동적으로 결합하는 방식을 그대로 흉내내기만 한다면 말이다.
C에서는 객체의 종류를 나타내는 타입 필드를 작성하고 공용체로 이들 객체를 묶어 다형성을 구현할 수 있다. 다형성이 C++의 고유 기능이 아니라는 것을 증명해 보기 위해 30장에서 만들었던 GraphicObject 예제를 C언어로 다시 작성해 보자. 물론 고기능의 객체 지향 언어가 넘쳐나는 요즘 세상에 C언어로 다형성을 구현해야 할 실용적 이유는 없지만 기존의 C언어에 대한 지식을 바탕으로 다형성의 기본 원리를 이해하는데 도움이 될 것이다. 다음 예제는 터보 C 2.0으로도 잘 컴파일되고 다형적으로도 동작한다.
예 제 : CPolymorphism
|
#include <Turboc.h>
typedef enum { LINE,CIRCLE,RECTANGLE } Shape;
typedef struct {
int x1,y1,x2,y2;
} Line;
typedef struct {
int x,y,r;
} Circle;
typedef struct {
int left,top,right,bottom;
} Rect;
typedef struct {
Shape Type;
union {
Line L;
Circle C;
Rect R;
} Data;
} Graphic;
void Draw(Graphic *p)
{
switch (p->Type) {
case LINE:
puts("선을 긋습니다.");
break;
case CIRCLE:
puts("동그라미 그렸다 치고.");
break;
case RECTANGLE:
puts("요건 사각형입니다.");
break;
}
}
void main()
{
Graphic ar[5]={
{LINE,1,2,3,4},
{CIRCLE,5,6,7,0},
{RECTANGLE,8,9,10,11},
{LINE,12,13,14,15},
{CIRCLE,16,17,18,0},
};
int i;
for (i=0;i<5;i++) {
Draw(&ar[i]);
}
}
실행 결과는 다음과 같다. C++로 만든 예제와 동일하다.
선을 긋습니다.
동그라미 그렸다 치고.
요건 사각형입니다.
선을 긋습니다.
동그라미 그렸다 치고.
각 도형에 대한 정보를 가지는 Line, Circle, Rect 등의 구조체들을 먼저 선언한다. 이 구조체에는 직선, 원, 사각형에 대한 좌표 정보가 멤버로 포함되어 있다. 그리고 이 모든 도형을 포괄할 수 있는 Graphic이라는 구조체를 선언하는데 이 구조체에는 도형의 종류를 표현하는 Type 필드와 각 도형의 정보가 공용체 멤버로 포함되어 있다. 도형의 종류가 제한적이므로 Type은 열거형으로 선언했다.
Graphic 타입의 변수 하나는 하나의 도형을 표현하는데 한 도형이 원이면서 동시에 사각형일 수는 없으므로 도형에 대한 정보들을 공용체로 선언하는 것이 메모리 관리 측면에서 유리하다. 단, 이렇게 할 경우 이 변수가 어떤 도형을 표현하는지를 알 수 없으므로 별도의 Type 필드가 필요하다. 공용체는 기억 장소를 공유하도록 할 뿐이므로 어떤 정보를 가지고 있는지 스스로 기억해야 한다. 일련의 구조체 선언문에 의해 다음과 같은 개념적인 계층이 형성된다.
Draw 함수는 도형들을 대표하는 Graphic 타입의 포인터를 받아 이 구조체의 정보대로 도형을 그린다. 캡슐화 기능이 없으므로 Draw 함수는 외부의 전역 함수로 존재할 수 밖에 없으며 그리고자 하는 도형의 정보를 인수로 전달받아야 한다. 이때 인수로 전달되는 Graphic *타입은 모든 도형의 대표 타입이 되는데 상속의 개념이 없으므로 공용체로 모든 정보를 포함하는 구조체를 만들어 대표 타입으로 사용할 수밖에 없다.
Draw 함수는 Graphic 타입의 인수로부터 Type 멤버를 읽어 도형을 어떤 식으로 그릴지를 결정한다. Type 멤버는 모든 도형에 반드시 존재해야 하며 자주 사용하는 정보이므로 가급적 첫 번째 멤버로 배치하는 것이 유리하다. Type은 도형을 그릴 방법을 실행중에 결정하는 정보이므로 C++의 vptr에 비유할 수 있다. Draw 함수는 switch 문으로 실행시에 호출할 함수를 선택하므로 실제 도형을 그리는 함수가 동적으로 결합되는 것과 효과가 동일하다.
main에서는 크기 5의 Graphic 배열을 선언하는데 공용체는 첫 번째 멤버에 대해서만 초기값을 줄 수 있으므로 Type 다음의 초기값들은 모두 Line의 멤버들과 대응된다. 이 예제의 경우 Circle, Rect의 멤버와 Line의 멤버가 타입, 개수가 거의 일치해서 별 문제가 없지만 Line에 없는 멤버는 초기식에 쓸 수 없고 초기화 후 직접 대입해야 한다. 실제 예에서는 사용자가 도형을 그릴 때 이 정보들이 채워질 것이다. ar 구조체 배열의 모양은 다음과 같다.
도형의 집합을 표현하는 배열에 대해 루프를 돌며 Draw(&ar[i])를 반복적으로 호출하면 배열 내의 모든 도형을 그릴 수 있다. 이 문장이 바로 다형적으로 동작하는데 똑같은 코드이지만 ar[i]에 저장된 정보에 따라 직선을 긋기도 하고 원을 그리기도 한다. Draw안에서 무슨 동작을 하든 밖에서 보기에는 Graphic 객체의 포인터와 함께 이 함수를 부르기만 하면 도형이 제대로 그려지는 것이다.
C언어로도 다형성을 구현할 수 있다는 것을 증명했는데 C++에 비해서는 여러 모로 불편한 점이 많다. 도형들간의 관계를 한 눈에 파악하기도 어렵고 새로운 도형이 늘어날 때마다 Draw와 관련 함수들을 매번 수정해야 한다는 점에서 코드를 유지하기도 아주 어렵다. 역시 C의 다형성은 C++의 다형성에 비해서는 한 수 아래이다.
다형성은 C++언어의 창시자가 독자적으로 만든 기법이 아니라 객체 지향 개념이 소개되기 훨씬 전부터 이미 사용되어 왔던 기술이다. 프로그램의 규모가 커지면 누구나 이런 기법의 필요성을 느끼게 되며 필요한 기법들은 누군가에 의해 만들어지고 다듬어지기 마련이다. C++은 이를 좀 더 쓰기 쉽고 안전하도록 언어 차원에서 다형성을 지원할 뿐이다. 정보를 포괄하는 공용체는 상속이라는 기법이 되고 Type 필드는 vptr을 통한 동적 결합으로 구체화되었다.
34-2-자.using 선언
using 선언은 다른 네임 스페이스의 명칭을 이 선언이 있는 곳으로 가져 오는 문장인데 클래스 계층 사이에서도 사용할 수 있다. 클래스 자체도 일종의 국지적인 네임 스페이스로 볼 수 있으므로 using 선언으로 원하는 명칭을 가져올 수 있다. 클래스에서 using 선언을 사용하면 기반 클래스 멤버의 액세스 속성을 원하는대로 변경하는 역할을 한다. 다음 예제를 보자.
예 제 : classusing1
|
#include <Turboc.h>
class B
{
private:
void p() { puts("Base private function"); };
protected:
void f() { puts("Base protected function"); }
public:
void u() { puts("Base public function"); }
};
class D : public B
{
protected:
// using B::u;
public:
// using B::f;
void f() { B::f(); }
};
void main()
{
D d;
d.f();
d.u();
}
기반 클래스 B에 액세스 속성별로 세 개의 멤버 함수가 선언되어 있다. D는 이 클래스를 public 상속받았으므로 f 함수가 protected 속성으로 상속되며 u는 public 속성으로 상속된다. u는 외부에서도 호출가능하지만 protected 속성을 가지는 f는 D에서는 호출할 수 있지만 외부에서는 호출할 수 없다. 만약 f를 외부에서 호출할 수 있도록 하고 싶다면 두 가지 방법을 사용할 수 있다.
우선 이 함수를 public 영역에 같은 이름으로 재정의하는 방법을 쓸 수 있는데 이렇게 되면 B::f는 가려진다. 재정의된 f가 B::f를 대리 호출하면 외부에서도 이 함수를 호출할 수 있다. protected 액세스 속성을 가지는 멤버 함수가 재정의에 의해 public으로 이동한 것이다. 위 예제를 실행하면 main에서 d.f를 호출하는 것이 허가된다. D의 f 함수를 주석 처리하면 물론 호출할 수 없다.
두 번째 방법으로 using 선언을 사용하여 protected 영역에 있는 f 함수를 public 영역으로 명칭을 가져올 수 있다. 이렇게 되면 상속받은 f가 public 영역에 선언된 것과 같아져 외부에서도 이 함수를 호출할 수 있다. 재정의된 f 함수를 주석 처리하고 using 선언만 남겨 두어도 이 예제는 잘 실행된다. 둘 다 주석 처리하면 외부에서 f를 호출할 수 없다. using 선언은 public 영역에 있는 명칭을 protected로 숨길 수도 있다. B::u는 그대로 두면 public이지만 protected나 private로 옮겨 버리면 외부에서 호출할 수 없다.
using 선언은 접근 가능한 멤버의 액세스 속성을 변경하는 역할을 한다. 어디까지나 접근 가능한 멤버에 대해서만 이 지정을 사용할 수 있을 뿐인데 예제에서 B의 private 영역에 있는 p 함수를 public 영역에 두고 싶다고 해서 using B::p; 선언을 사용할 수는 없다. 자신도 접근하지 못하는 멤버에 대한 액세스 지정을 변경한다는 것 자체가 말이 되지 않는 것이다. 오로지 기반 클래스의 허가된 멤버(protected, public)만 using 선언을 사용할 수 있다.
다음 예제는 private 상속시 외부로 공개되지 않는 인터페이스를 공개하기 위해 using 선언을 사용한다.
예 제 : classusing2
|
#include <Turboc.h>
class B
{
protected:
void f() { puts("Base protected function"); }
public:
int m;
};
class D : private B
{
protected:
using B::m;
public:
using B::f;
};
class G : public D
{
public:
void gf() {
m=1234;
}
};
void main()
{
D d;
d.f();
// d.m=1234;
}
D는 B를 private 상속받았으므로 B의 f, m 멤버는 모두 private가 될 것이다. 이 상태에서는 D 외부나 파생 클래스에서 상속받은 멤버를 액세스할 수 없는 것이 정상이다. 하지만 D가 using 선언으로 이 멤버들의 액세스 속성을 변경하였으므로 변경된 속성대로 액세스가 허가된다. f의 경우 public 영역에 using 선언했으므로 클래스 외부인 main에서 이 함수를 호출할 수 있다. m의 경우 protected 영역에 using 선언했으므로 이차 파생 클래스인 G가 이 멤버를 참조할 수 있다. 하지만 main에는 m을 읽을 수 없다.
댓글 없음:
댓글 쓰기