책정리/혼자 연구하는 C,C++ 2

34장 네임 스페이스

GONII 2015. 3. 10. 14:35

34-1 네임 스페이스

가. 명칭의 충돌

C/C++ 소스를 구성하는 7가지 요소 중의 하나인 명칭(Identifier)은 변수, 함수, 타입 등 다양한 요소를 정의할 때 사용한다. 몇 가지 규칙만 지키면 자유롭게 정의할 수 있으며 그래서 가급적이면 기억하기 쉽고 대상을 명확하게 표현할 수 있는 이름을 붙인다.

그러나 프로그램이 복잡해지고 규모가 커질수록 더 많은 명칭이 필요해져서 고유한 이름을 붙이는 일이 점점 더 어려워지고 있다. 게다가 팀 단위로 작업할 때는 혼자서 명칭을 다 만드는 것이 아니며 외부 라이브러리를 가져다 쓰는 일도 흔해져서 우연히 명칭이 충돌하는 일이 잦아졌다. 외부 라이브러리끼리 명칭이 충돌하면 수정할 수도 없어 한쪽 라이브러리의 사용을 포기해야 하는 곤란한 상태가 되기도 한다. 그래서 명칭 충돌 문제를 언어 차원에서 좀 더 근본적으로 해결할 수 있는 방법이 필요해졌고 이것이 바로 네임 스페이스가 필요해진 이유이다.

네임 스페이스(Name Space)는 말 뜻 그대로 명칭들이 기억되는 여역이며 명칭의 소속 공간이다. 이름을 담는 통이라고 생각하면 이해하기 쉽다. 일정한 선언 영역을 만들고 이 영역 안에 명칭을 그룹화하여 넣어 두면 충돌 가능성이 대폭 감소된다.

명칭도 마찬가지로 소속 네임 ㅅ페이스가 다르면 이름이 중복되어도 상관없다. 충돌할 가능성이 조금이라도 있는 명칭이라면 아예 처음부터 네임 스페이스 안에 선언하는 것이 좋다.

  • 네임 스페이스 기본 형식

    namespace 이름

    {

    여기에 변수나 함수를 선언

    }

    • 예제 namespace1

#include <iostream>

   

int i;

double i;

void func()

{

i = 123;

}

   

void main()

{

func();

}

두 변수가 같은 이름을 쓰고 있으므로 func 함수에서 칭하는 i는 어떤 i인지 애매해진다. 그러나 다음과 같은 중복은 가능하다.

double i

void func()

{

int i;

i = 123;

}

전역변수 i와 지역변수 i가 서로 다른 영역에 선언되어 있는데 지역, 전역 명칭이 충돌할 경우는 지역변수가 우선권을 가진다. 그래서 func 함수 내에서 i 명칭을 참조하면 이는 지역변수 i를 의미한다. 이 상태에서 만약 전역변수 i를 참조하고 싶다면 :: 연산자를 사용하여 ::i = 1.2; 라고 쓰면 된다.

전역 명칭이 충돌할 경우 네임 스페이스를 각각 만들고 각 여역 안에 명칭을 선언하면 된다.

  • 예제 namespace2

#include <iostream>

   

namespace A

{

int i;

}

   

namespace B

{

double i;

}

   

void func()

{

A::i = 123;

B::i = 1.23;

}

   

void main()

{

func();

}

네임스페이스를 별도로 정의하지 않아도 항상 존재하는 네임스페이스가 있는데 이를 전역 네임 스페이스라고 한다. 이른바 디폴트 네임 스페이스라고 볼 수 있는데 흔히 전역변수를 선언하는 영역, 그러니까 함수의 바깥쪽이 바로 이 영역이다. 원래부터 존재하므로 별도의 이름은 없다.

  • 예제 namespace3

#include <iostream>

   

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();

}

명칭 충돌이 문제가 될 때는 외부 라이브러리를 쓰거나 직접 라이브러리를 작성할 때이다. 내가 만든 라이브러리에서 count, time, status 같은 변수나 cStack, cArray 같은 타입을 정의한다고 해보자. 이런 이름은 너무 일반적이기 때문에 이 라이브러리를 사용하는 클라이언트 모듈과 충돌할 확률이 아주 높다. 이럴 때 handsome_sanghyung 같은 긴 이름의 네임 스페이스 안에 명칭을 선언하면 충돌을 걱정할 필요가 없다.

네임 스페이스의 기본적인 기능은 명칭이 작성되는 공간을 분리함으로써 명칭끼리 충돌하지 않도록 하는 것이다. 이 외에도 네임 스페이스는 명칭들의 논리적인 그룹을 만들어 소스 관리에도 상당한 도움을 준다. 예를 들어 그래픽과ㅏ 관련된 명칭은 GR에 넣고 유저 인터페이스에 관련된 명칭은 UI에 넣어 놓으면 소속으로 두 그룹의 함수군을 나눌 수 있다. 팀별로, 개발자 개인별로 네임 스페이스를 정의하면 누가 만든 명칭인지도 쉽게 파악된다.

나. 네임 스페이스 작성 규칙

  1. 네임스페이스의 이름도 일종의 명칭이므로 다른 명칭과 중복되어서는 안된다. 다른 네임스페이스와 구분된느 이름을 가져야 함은 물론이고 변수와 함수와도 같은 이름을 쓸 수 없다.
  2. 네임스페이스는 반드시 전역 영역에 선언해야 한다. 함수 안에 선언할 수 없다는 뜻이다
  3. 네임스페이스끼리 중첩 가능하다. 즉, 네임스페이스 안에 또 다른 네임스페이스를 선언할 수 있다는 얘기인데 중첩의 단계에 대한 제한은 없다.
  4. 네임스페이스는 항상 개방되어 있다. 그래서 같은 네임스페이스를 여러 번 나누어 명칭을 선언할 수 있다. 꼭 한꺼번에 몰아서 네임스페이스내의 모든 명칭을 일괄 선언해야 하는 것은 아니다.

    namespace A

    {

    double i;

    }

    namespace B

    {

    int i;

    }

    namespace A

    {

    char name[32];

    }

  5. 네임스페이스가 이름을 가지지 않을 수 있다. 키워드 namespace 다음에 { } 괄호를 바로 쓰고 괄호 안에 명칭만 선언하면 된다.
  6. 단일 모듈 프로젝트에서는 별 상관 없지만 다중 모듈 프로젝트에서는 함수의 본체를 어디에 작성할 것인가 주의해야 한다. 여러 개의 모듈로 나누어진 프로젝트를 개발할 때는 보통 헤더 파일과 구현 파일을 따로 작성한다.

다. 네임 스페이스 사용

네임스페이스 안에 명칭을 선언하면 이름을 붙일 때 충돌을 걱정하지 않고 자유롭게 이름을 붙일 수 있다. 그러나 이렇게 작성된 명칭을 사용하려면 매번 소속을 밝히고 참조해야 하므로 무척 번거롭다.

namespace MYNS

{

int value;

}

   

void main()

{

MYNS::value = 3;

}

네임 스페이스 이름이 길어지면 타이핑하는 것도 힘들고 소스의 가독성도 떨어져 여러 모로 좋지 않다. 그래서 이런 불편함을 해소할 수 있는 세 가지 방법이 제공된다.

:: using 지시자(Directive)

  • 예제 usingdirective

#include <iostream>

   

namespace MYNS

{

int value;

double score;

void func() { printf("i am func\n"); }

}

   

using namespace MYNS;

   

void main()

{

value = 3;

score = 1.23;

func();

}

:: using 선언(Declaration)

  • 예제 usingdecl

#include <iostream>

   

namespace MYNS

{

int value;

double score;

void func() { printf("i am func\n"); }

}

   

void main()

{

using MYNS::value;

value = 3;

MYNS::score = 1.23;

MYNS::func();

}

:: using에 의한 충돌

  • 예제 usingdeclconflict

#include <iostream>

   

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

}

  • 예제 usingdireconflict

#include <iostream>

   

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를 의미한다. 지역변수에 의해 같은 이름의 전역변수가 가려지는 것과 동일하다.

요약하자면 using 선언은 명칭이 충돌할 경우 에러로 처리하는데 비해 using 지시자는 네임스페이스의 명칭에 대한 가시성이 제한될 뿐 에러나 경고를 내지 않는다. 언뜻 생각하기에 using 지시자가 더 관대한 것 같지만 사실 이런 상황이 골치 아픈 에러의 원인이 될 수 있다. 일반적으로 애매한 상황보다는 명확하게 에러 처리를 하는 것이 훨씬 더 바람직하다. 그래서 가급적이면 using 지시자로 네임스페이스의 전체 명칭을 가져 오는 것보다 using 선언으로 꼭 필요한 것만 선별적으로 가져오는 것이 더 좋다.

:: 모호한 상황

namespace A

{

int i;

}

namespace B

{

double i;

}

void main

{

using namespace A;

using namespace B;

i = 3;        // 모호하다는 에러 발생

}

i가 어떤 네임스페이스의 i인지 모호해진다. A::i, B::i로 소속을 명확하게 밝히든지 아니면 한쪽의 using 지시자를 제거해야 한다.

   

void main

{

using A::i;

using B::i;        // 중복된 선언이라는 에러 발생

i = 3;        

}

A::i가 이미 main 함수 영역에 들어와 있기 때문에 같은 이름의 i를 또 가져올 수 없는 것이다.

:: 별명

네임 스페이스는 우연한 충돌을 방지하기 위해 보통 긴 이름을 주는데 이름이 너무 길면 입력하기에 번거롭고 코드도 지저분해진다. 이럴 경우 namespace 키워드 다음에 A = B;형태로 긴 이름 대신 짧은 별명을 정의할 수 있다.

namespace longNameSpaceName

{

struct person{};

}

   

void main()

{

namespace A = longNameSpaceName;

A::person p;

}

34-2 그 외의 문법

가. 객체의 자기 방어

실제 세상에 존재하는 모든 사물들은 자신이 가질 수 있는 적법한 속성 범위를 가지고 있으며 범위를 지나치게 벗어나는 사물은 제대로 된 사물이 아니다.

무효한 객체는 논리적으로 잘못되었을 뿐만 아니라 치명적인 에러의 원인이 되기도 한다.

따라서 클래스는 이런 잘못된 상태의 객체가 만들어지지 않도록 스스로 방어해야 할 필요가 있다. 객체가 초기화되는 시점은 생성자가 호출될 때이므로 생성자에서 인수의 값을 보고 과연 규칙에 맞는 객체인지 아닌지를 점검할 수 있다. 구조체는 외부에서 주는 값을 선택의 여지없이 저장하기만 하는데 비해 객체는 생성자가 직접 초기화하므로 스스로의 무결성을 지킬 수 있다. 객체를 무효하게 만들 가능성이 있는 인수가 전달되었을 때 생성자는 여러 가지 조치를 취할 수 있는데 어떤 식으로 자신을 방어할 수 있는지 가능한 방법들을 열거해 보자.

  1. 가장 쉬운 방법은 시키는 대로 하고 별도의 조치를 취하지 않는 것이다. 좀 이상하게 들리겠지만 때로는 이런 방법이 가장 현명할 수도 있다. 왜냐하면 이런 객체를 만든 곳에서 잠시 객체의 이상 동작을 확인하고 틀렸다는 것을 알 수 있으며 따라서 곧 모종의 조치를 취할 수 있기 때문이다. 이런 원칙을 GIGO(Garbage In Gabage Out)라 하는데 입력이 틀렸으니 틀린 대로 동작하도록 내버려 둔다는 뜻이다.
  2. 조건이 만족되지 않을 경우 초기화를 거부하고 쓰레기값을 가지도록 내버려둔다.
  3. 틀린 값이 입력되었을 때 무난한 값으로 바꿔서 초기화한다.
  4. 틀린 입력에 대해 적극적인 에러 처리를 한다.
  5. C++이 언어 차원에서 가장 권장하는 방법은 예외를 던지는 것이다.

나. 생성자의 활용

생성자와ㅏ 소멸자는 함수이면서도 자동으로 호출된다는 점에 있어서 일반 함수와는 좀 다르게 취급된다. 객체를 만들거나 파괴하기만 하면 컴파일러가 알아서 호출하도록 되어 있어 객체 선언문이 어떤 동작을 하도록 할 수 있다. 이 점을 잘 활용하면 생성자와ㅏ 소멸자를 아주 특수한 용도로 활용할 수 있는데 프로그램 전역적인 초기화와 종료 처리에 아주 유용하다.

  • 예제 RandInit

#include <iostream>

#include <windows.h>

   

class randominitializer

{

public:

randominitializer() { srand(GetTickCount()); }

};

   

void main()

{

int i;

for ( i = 0 ; i < 10 ; i++ )

{

printf("%d\n", rand()%100);

}

}

객체가 전역일 경우 프로그램과 함께 생성되므로 main 함수보다도 더 빨리 호출된다는 점이다. 그래서 main의 선두에 있는 어떤 코드보다도 실행 우선 순위가 높아 전역적인 초기화에 적합하다.

   

C에서는 진입점인 main 함수가 항상 제일 먼저 실행되지만 C++에서는 그렇지 않을 수도 있다. 위에서 봤다시피 전역 객체의 생성자가 더 우선적으로 실행되는데 이런식으로 프로그램 시작 후에 초기화되는 것을 동적 초기화(runtime initialize)라고 한다. 동적 초기화는 클래스에만 국한되지 않고 일반 변수에도 사용할 수 있다.

  • 예제 runtimeinit

#include <iostream>

#include <time.h>

   

int randinit()

{

srand(time(NULL));

   

return rand()%100;

}

   

int g_r = randinit();

   

void main()

{

int r = rand()%100;

printf("g_r = %d, r = %d",g_r, r);

}

다. 초기화 순서

초기화 리스트의 초기식들은 리스트에 나타난 순서가 아니라 멤버의 선언 순서대로 실행된다. 대개의 경우 어떤 멤버가 초기화되든지 상관없지만 멤버끼리 종속적인 관계에 있을 때는 초기화 순서가 중요한 의미를 가질 수도 있다.

  • 예제 InitOrder

#include <iostream>

   

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(5);

t.outMember();

}

선언 순서를 second와 first를 바꾸면 second는 쓰레기값을 가질 것이다. 왜 이렇게 되는가 하면 초기화 리스트의 순서에 상관 없이 앞쪽에 선언되어 있는 second가 먼저 초기화되고 다음으로 first가 초기화되는데 second가 초기화될 때 first는 아직 초기화되지 않아 쓰레기값을 가지고 있었기 때문이다.

이런 순서가 일반 단순 멤버에서는 그리 중요하지 않을 수도 있다. 그러나 포인터가 개입되거나 중요한 크기 정보 등을 초기화 할 때는 굉장히 민감한 문제를 일으킨다.

  • 예제 InitOrder2

#include <iostream>

   

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의 선언 순서를 바꾸면 실행되자마자 프로그램이 사망한다. 이럴 경우 클래스 선언문의 멤버 순서를 주의깊게 작성할 필요가 있다. 멤버 순서만 제대로 되어 있다면 초기화 리스트는 아무렇게나 순서를 정해도 상관없다.

생성 순서가 중요한 것처럼 소멸 순서도 마찬가지로 중요하다. 그러자면 객체 스스로 어떤 생성자가 자신을 초기화했는지, 각 멤버들이 어떤 순서대로 초기화되었는지를 기억해야 한다는 얘기인데 일반적으로 불가능하다.

그래서 소멸자는 무조건 선언된 역순으로 멤버를 파괴하며 이 순서에 맞추기 위해 성성자는 무조건 선언된 순서대로 초기화할 수 밖에 없는 것이다. 다중 상속의 경우 먼저 초기화되어야 하는 기반 클래스를 앞에 적고 나중에 초기화될 기반 클래스를 뒤쪽에 적어야 한다. 소멸자는 생성자의 역순으로 호출된다.

라. 비트맵 클래스

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함수를 호출한다. 마찬가지 이유인데 비트맵 객체를 쓰다가 다른 파일을 로드하면 이전에 사용하던 비트맵을 해제해야 하기 때문이다. 따라서 객체 파괴없이 비트맵만 삭제하는 멤버 함수가 필요하다.

Draw함수는 모두 4개로 정의되어 있다. 지정한 위치에 출력만 하는 함수, 비트맵의 일부를 원하는 부분에 출력하는 함수, 그리고 투명 출력하는 함수들이 Draw라는 같은 이름으로 오버로딩되어 있으며 확대 출력하는 Stretch함수도 마련되어 있다.

Bitmap 클래스가 완성되었으면 이 객체를 사용하여 비트맵을 자유자재로 다룰 수 있다. 다음이 테스트 코드이다.

  • 예제 BitmapClass

void MakeEllipseBitamp()

{

Bitmap bit(640, 480);

HBITMAP oldBit;

HDC hdc, MemDC;

int i;

   

hdc = GetDC(NULL);

MemDC = CreateCompatibleDC(hdc);

ReleaseDC(NULL, hdc);

oldbit = (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, oldBit);

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);

}

ReleseDC(hWnd, hdc);

break;

case 'B':

bit.Save("c:\\save.bmp");

MessageBox(hWnd, "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));

}

마. 멤버별 복사

클래스가 복사 생성자나 대입 연산자를 정의하지 않을 경우 컴파일러가 디폴트를 만들어 주는데 이 함수들은 멤버별 복사를 수행하여 우변 객체의 멤버를 순서대로 좌변 객체에 대입한다.

멤버별로 대입한다는 것은 메모리까지 복사한다는 것과는 다른데 말 뜻 그대로 클래스의 멤버를 1:1로 서로 대입하는 것이다.

  • 예제 DefAssign

#include <iostream>

   

// 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 prg;

prg = hyc;

prg.outBook();

}

book은 별도의 대입 연산자를 정의하지 않으므로 컴파일러가 디폴트 대입 연산자를 만들 것이며 이 대입 연산자는 book의 멤버를 1:1로 대입한다. title은 단순 타입이므로 메모리 복사되며 author는 author의 대입 연산자를 호출하여 깊은 복사를 하도록 할 것이다. 멤버별 복사는 멤버가 대입 연산자를 정의할 때 이 연산자를 통해 대입한다는 면에서 단순한 메모리 복사와는 다르다. 포함된 객체가 대입 연산자를 잘 정의하고 있으므로 book은 별도의 대입연산자를 정의할 필요가 없다.

person이 대입 연산자를 정의하지 않으면 디폴트 대입 연산자에 의해 얕은 복사를 하게 되므로 다운될 것이다. 멤버 중에 값을 변경할 수 없는 레퍼런스나 상수가 포함되어 있다면 이 때는 멤버별 대입을 할 수 없으므로 컴파일러는 대입 연산자를 정의하지 않는다.

  • 예제 constRef

#include <iostream>

   

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가 별도의 대입 연산자를 정의하지 않으므로 t1의 모든 멤버가 t2로 대입될 것이다. 그러나 이런 대입은 허가될 수 없다. 왜냐하면 레퍼런스는 대상체가 한 번정해지면 변경할 수 없기 때문이다. 상수 멤버도 마찬가지 규칙에 적용된다. t2.ci는 생성할 때 4로 초기화했으므로 언제까지고 4의 값만 가져야 하는데 멤버 대입에 의해 갑자기 2의 값으로 변경되어야 하므로 이 또한 상수의 정의에 어긋난다.

그래서 컴파일러는 레퍼런스 멤버나 상수 멤버를 가진 클래스에 대해서는 디폴트 대입 연산자를 정의하지 않는다.

대입 연산자를 정의하는 다음 코드를 추가하면 컴파일 된다.

constRef &operator=(const constRef& other)

{

if( this != &other )

{

value = other.value;

}                

return *this;

}

상수와 레퍼런스는 그대로 두고 변경할 수 있는 value 값만 other로부터 대입받았다.

바. 오버로딩과 오버라이딩

상속받은 멤버 함수를 재정의하는 기법을 오버라이딩(Overriding)이라고 하는데 인수 목록이 다른 함수를 같은 이름으로 중복 정의하는 오버로딩(Overloading)과 용어가 비슷하므로 잘 구분하도록 해야 한다. 둘 다 함수의 이름을 동일하게 작성한다는 점에서 비슷하지만 오버로딩은 이미 있는 함수에 하나를 더 추가하는 것이고 오버라이딩은 이미 있는 함수를 무시하고 새로 만드는 것이다.

오버로딩이란 클래스와는 직접적인 상관이 없어서 전역함수끼리도 오버로딩될 수 있다. 이에 비해 오버라이딩은 클래스간의 관계, 그 중에서도 상속된 부모와 자식관계에서만 적용되며 전역 함수에 대해서는 적용되지 않는다.

클래스에서는 오버로딩과 오버라이딩이 동시에 일어날 수 있다. 클래스의 멤버함수들끼리 중복 정의가 가능하도 또 파생 클래스에 상속받은 멤버를 재정의하는 것도 가능하다. 그런데 파생 클래스에서 상속된 멤버 함수와 인수 목록이 다른 함수를 같은 이름으로 재정의하면 이때는 오버로딩이 적용되지 않는다. 즉, 인수 목록이 아무리 달라도 파생 클래스가 같은 이름으로 함수를 재정의하면 동일한 이름을 가지는 부모의 모든 함수들이 가려진다.

  • 예제 InheritOverload

#include <iostream>

   

class B

{

public:

void f(int a) { puts("B::f(int)"); }

void f(double a) { puts("B::f(double)"); }

};

   

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);        // 에러

}

D에서 f(char*)를 재정의하는 순간 기반 클래스의 f(int), f(double)은 모두 가려진다. D의 객체에서 f를 호출하면 이는 자신 멤버 함수 f(char*)를 의미하며 d.f(1)로 정수 인수를 주어도 상속받은 f(int)가 호출되지 않는다. 그러나 가려질 뿐이지 상속은 되므로 범위 연산자를 사용하여 d.B::f(1)로 후쳘하면 가려진 부모의 멤버 함수를 호출할 수도 있다.

오버로딩과 오버라이딩이 양립할 수 없기 때문에 상속받은 멤버 함수를 재정의할 때는 부모의 멤버 함수와 완전히 같은 원형으로 재정의해야 한다. 만약 원형이 다르다면 아예 함수 이름을 다르게 작성하는 것이 바람직하다. 또한 기반 클래스에 여러 개의 함수가 중복 정의되어 있다면 이 함수들을 모두 재정의 하거나 아예 재정의 하지 말아야 한다. 하나만 재정의하면 이 함수에 의해 원형이 다른 함수들을 모두 가려질 것이다.

만약 오버로딩된 함수들의 일부를 상속받으면서 그 중 원하는 것만 재정의할 수 있도록 한다면 오버로딩된 함수를 결정하는 메커니즘이 과다하게 정교해져야 한다. 또한 부모 클래스가 오버로딩된 함수의 원형을 하나 더 추가할 때 자식 클래스가 호출하는 함수가 뜻하지 않게 다른 함수로 바뀌어 버릴 위험도 있다.

  • 예제 OverrideOverload

#include <iostream>

   

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);

}

오버로딩된 멤버 함수의 일부만 재정의했으므로 부모의 f는 모두 가려진다. main에서 d객체의 f(1234) 함수를 호출했는데 실행해보면 D::f(double) 함수가 호출될 것이다. 1234는 정수이지만 실수가 아니므오 f(long)이 더 가깝지만 이 함수가 가려지기 때문에 가장 가까운 함수가 호출되는 것이다. 만약 Derived가 부모의 오버로딩된 함수들을 전부 상속받는다면 f(1234)는 f(long)이 호출되는 것이 옳다. D::f(double) 함수를 잠시 주석 처리하면 전부 상속되므로 이때는 B::f(long)이 호출될 것이다.

컴파일러는 오버로딩된 함수를 결정하기 위해 인수의 타입을 검사하는데 이 과정에서 완전히 일치하는 함수가 없을 경우 암시적인 타입 변환을 통해 최대한 일치하는 함수를 찾는다. 이런 직관적이지 못한 변환에 의해 호출될 함수가 결정되는데 상속 계층까지 추가되면 더욱 혼란스러워 질 것이다. 그래서 C++은 오버로딩된 함수들 중 하나를 재정의할 경우 나머지 함수들을 아예 숨겨 버림으로써 이런 사고를 미연에 방지한다.

사. 문법의 예외

알아봤다시피 부모 포인터가 자식 객체를 가리키는 것은 항상 안전하다. 그러나 이 당연한 법칙에도 예외가 존재하는데 문법적으로 가능하지만 실질적으로는 문제가 있는 경우도 있다. 언제인가 하면 부모 타입의 포인터가 자식 타입의 객체의 배열을 가리킬 때이다.

  • 예제 ObjArrayPtr

#include <iostream>

   

class base

{

private:

int num;

public:

virtual void outMessage() { printf("base class\n"); }

};

   

class derived : public base

{

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 상속한 경우는 정확한 부모 자식 관계라고 보기 어려우므로 이 정의가 성립되지 않는다. 왜냐하면 private 상속은 인터페이스를 상속받지 않으므로 자식 클래스에는 인터페이스가 존재하지 않기 때문이다. 대입하는 것은 허락되지만 외부에서 함수를 호출할 수 없다.

아. C언어에서의 다형성

다형성은 객체 지향 개발 방식의 큰 특징이자 꽃이라고 할 만큼 훌륭한 기능이다. 객체 지향이란 특정 언어의 고유 기능이 아니라 프로그래머가 문제를 푸는 사고방식이므로 C++ 언어로만 다형성을 구현할 수 있는 것은 아니다. C나 파스칼 같은 구조적인 언어는 물론이고 어셈블리 같은 저급 언어에서도 다형성을 얼마든지 흉내낼 수 있다.

  • 예제 CPolymorphism

#include <iostream>

   

typedef enum { LINE, CIRCLE, RECT } 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 RECT:

puts("사각형");

break;

}

}

   

void main()

{

graphic ar[5] = {

{LINE, 1,2,3,4},

{CIRCLE, 5,6,7,0},

{RECT, 8, 9, 10, 11},

{LINE, 12, 13, 14, 15},

{CIRCLE, 16, 17, 18, 19},

};

   

int i;

for( i = 0 ; i < 5 ; i++ )

{

Draw(&ar[i]);

}

}

공용체는 상속이라는 기법이 되고 type필드는 vptr을 통한 동적 결합으로 구체화되었다.

graphic 타입의 변수 하나는 하나의 도형을 표현하는데 한 도형이 원이면서 동시에 사각형일 수는 없으므로 도형에 대한 정보들을 공용체로 선언하는 것이 메모리 관리 측면에서 유리하다. 단, 이렇게 할 경우 이 변수가 어떤 도형을 표현하는지 알 수 없으므로 별도의 type 필드가 필요하다. 공용체는 기억 장소를 공유하도록 할 뿐이므로 어떤 정보를 가지고 있는지 스스로 기억해야 한다. 일련의 구조체 선언문에 의해 다음과 같은 개념적인 계층이 형성된다.

type은 도형을 그릴 방ㅂ버을 실행 중에 결정하는 정보이므로 C++의 vptr에 비유할 수 있다. draw함수는 switch문으로 실행시에 호출할 함수를 선택하므로 실제 도형을 그리는 함수가 동적으로 결합되는 것과 효과가 동일하다.

main에서는 크기 5의 graphic 배열을 선언하는데 공용체는 첫 번째 멤버에 대해서만 초기값을 줄 수 있으므로 type 다음의 초기값들은 모두 Line의 멤버들과 대응된다.

   

C언어로도 다형성을 구현할 수 있다는 것을 증명했는데 C++에 비해서는 여러 모로 불편한 점이 많다.

다형성은 C++ 언어의 창시자가 독자적으로 만든 기법이 아니라 객체 지향 개념이 소개되기 훨씬 전부터 이미 사용되어 왔던 기술이다. 프로그램의 규모가 커지면 누구나 이런 기법의 필요성을 느끼게 되며 필요한 기법들은 누군가에 의해 만들어지고 다듬어지기 마련이다. C++은 이를 좀 더 쓰기 쉽고 안전하도록 언어 차원에서 다형성을 지원할 뿐이다.

자. using 선언

using 선언은 다른 네임스페이스의 명칭을 이 선언이 있는 곳으로 가져 오는 문장인데 클래스 계층 사이에서도 사용할 수 있다.

  • 예제 classusing1

#include <iostream>

   

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();

}

u는 외부에서도 호출 가능하지만 protected 속성을 가지는 f는 D에서는 호출할 수 있지만 외부에서는 호출할 수 없다. 만약 f를 외부에서 호출할 수 있도록 하고 싶다면 두 가지 방법을 사용할 수 있다.

우선 이 함수를 public 영역에 같은 이름으로 재정의하는 방법을 쓸 수 있는데 이렇게 되면 B::f는 가려진다.

두번째 방법은 using 선언을 사용하여 protected영역에 있는 f함수를 public 영역으로 명칭을 가져올 수 있다.

다음 예제는 private 상속시 외부로 공개되지 않는 인터페이스를 공개하기 위해 using 선언을 사용한다.

  • 예제 classusing2

#include <iostream>

   

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;

}

 

반응형

'책정리 > 혼자 연구하는 C,C++ 2' 카테고리의 다른 글

37장 STL 개요  (0) 2015.03.12
36장 표준 라이브러리  (0) 2015.03.11
33장 타입 정보  (0) 2015.03.09
32장 예외 처리  (0) 2015.03.07
31장 템플릿  (0) 2015.03.05