2016년 3월 19일 토요일

[c++기초] 3. 캡슐화

27-1.정보 은폐

27-1-가.프로그램의 부품

C에서, 좀 더 정확하게 표현하자면 구조적 프로그래밍 기법에서는 함수가 프로그램을 구성하는 기초적인 부품의 역할을 한다. 이 책에서 지금까지 만들어 왔던 몇 가지 예제를 보면 과연 그렇다는 것을 확인할 수 있다. Copule 게임은 DrawScreen, InitGame 함수가 게임의 핵심 동작을 처리하며 main에서는 이 함수들을 조립하여 게임을 진행한다. Sokoban 게임도 초기화, 이동, 화면 그림, 조건 판단 등의 주요 동작이 모두 함수에게 위임되어 있고 main은 키 입력 정도만 담당하고 있다.
그 외 인터넷이나 대중 통신망을 통해 구할 수 있는 잘 짜여진 C소스를 분석해 보면 모두 함수 위주의 구조를 가지고 있을 것이다. 필요한 모든 기능들이 함수로 작성되어 있으며 main은 필요할 때 적절한 함수를 골라 호출하는 총사령관 역할만 할 뿐이다. 어떤 예제는 모든 동작이 함수로만 정의되어 있어 main이 텅텅 비어 있기도 하다. 이런 경우 main은 진입점의 역할만 할 뿐이며 프로그램의 논리에는 거의 관여하지 않는다.
C++ (객체 지향 프로그래밍)에서는 기존의 함수가 맡고 있던 역할을 객체가 대신한다. 객체 안에는 속성과 동작이 캡슐화되어 있으며 이런 객체들이 모이고 서로 상호 작용하면서 프로그램을 구동시키는 것이다. 함수는 단순히 동작을 정의할 뿐이지만 객체는 스스로 상태를 관리하고 동작까지 가능한 훨씬 더 우월한 존재이다. C의 함수가 차지하고 있던 자리를 C++에서는 객체가 대신 차지하고 함수는 객체를 구성하는 단위로 격하되었다. 다음은 현대적 프로그램의 한 예인 파워포인트 프로그램이다.
강력하고 편리한 기능만큼 화면도 화려하다. 툴바 안에는 다양한 명령을 처리할 수 있는 버튼들이 있으며 메뉴, 대화상자, 각양 각색의 편리한 컨트롤들이 화면을 차지하고 있다. 이런 것들이 모두 객체이며 파워포인트는 이런 객체들을 모아서 만들어진 프로그램이다. 뿐만 아니라 슬라이드에 놓여지는 원, 사각형, 도형, 선, 글자 등도 모두 객체들이며 객체들이 슬라이드를 구성하고 슬라이드가 모여 프리젠테이션이 된다.
이런 대형 프로그램을 구조적 프로그래밍 방식으로 작성하려면 규모가 너무 커서 효율적이지 못하며 유지 보수 비용도 많이 든다. 객체라는 부품을 먼저 만들고 이런 부품을 조립하는 방식이 대형 프로젝트에는 훨씬 더 효율적이다. 물론 객체를 만드는 것도 굉장한 시간과 노력을 필요로 하지만 일단 만들어지면 얼마든지 재사용할 수 있다. 같은 회사에서 만든 워드, 엑셀 프로그램을 보면 파워포인트와 똑같은 종류의 컨트롤들이 재사용되는 것을 확인할 수 있다.
 
객체의 재사용성은 소프트웨어 제작사의 입장에서도 이익이 되지만 사용자도 한 번 배워서 여러 개의 비슷한 프로그램을 익숙하게 사용할 수 있는 이점이 있다. 프로그램 하나에 익숙해지면 유사한 다른 프로그램은 금방 배울 수 있거나 심지어 배우지 않아도(Look & Feel) 바로 사용할 수 있다.
객체 조립식의 장점에 대해서는 직관적으로 이해가 될 것이다. 이런 프로그래밍 방식의 이점을 최대한 활용하려면 그 주체가 되는 객체를 제대로 만들어야 한다. 부품이 제 기능을 발휘하고 고도의 신뢰성이 있어야 조립도 쉬워지고 완제품의 품질도 좋아지는 법이다. 객체가 소프트웨어의 부품 역할을 충실히 수행하려면 여러 가지 조건을 만족해야 한다. 우선 관련된 속성과 동작을 한 곳에 모아 스스로 동작할 수 있도록 하는 캡슐화가 가장 기본적인 조건이다.
그리고 꼭 필요한 인터페이스만 외부로 노출하고 세부 구현은 숨기는 추상성의 조건도 만족해야 한다. 그래야 최소한의 정보만으로 객체를 쉽게 사용할 수 있으며 부주의한 사용자로부터 자신을 방어할 수도 있다. 캡슐화를 완성하고 추상성의 조건을 만족하여 완벽한 부품이 되기 위해 자신의 정보를 적당히 숨겨야 하며 이것이 정보 은폐의 개념이다. 정보 은폐는 캡슐화의 범주에 속하면서 동시에 추상화를 위한 필요조건이기도 하다.
객체 지향 프로그램의 동작 방식은 흔히 클라이언트/서버 모델에 비유되곤 하는데 서버는 중앙에서 기억, 연산, 제어, 입출력 등의 핵심 처리를 담당하며 클라이언트는 사용자와 서버 사이를 중계하기만 하는 방식이다. 클라이언트는 서버의 내부적인 동작 방식을 구체적으로 모르지만 사용자의 지시를 수행하기 위해 서버에게 무엇을 어떻게 요청해야 하는가를 알고 있으며 서버는 클라이언트의 합당한 요청에 약속대로 응답하도록 되어 있다.
 서버는 작업 규칙을 잘 캡슐화하고 있으며 꼭 필요한 기능만 클라이언트에게 공개하여 클라이언트의 잘못된 작업 지시로부터 자신을 보호하기도 한다. 이 모델에서 객체가 서버에 비유되며 객체를 사용하는 프로그램이 클라이언트에 비유된다. 프로그램은 객체에게 모든 작업을 요청하며 객체는 프로그램의 요구에 응답하는 식이다.

27-1-나.몰라도 된다

정보 은폐의 개념을 가장 쉽게 단 한마디로 표현하면 "몰라도 된다"는 것이다. 부품을 쓰는 사용자가 알아야 하는 것은 부품을 사용하는 방법뿐이지 부품의 내부 동작이나 상세 구조가 아니다. 사용자가 굳이 알 필요가 없는 불필요한 정보는 숨김으로써 사용자는 최소한의 정보만으로 부품을 쉽게 사용할 수 있다.
실세계의 물건인 자동차를 예로 들어 보자. 자동차는 엔진, 바퀴, 브레이크, 핸들, 발전기, 냉각기, 밧데리, 기름통, 문짝 등 수많은 부품으로 구성되어 있는데 사용자들은 이 모든 부품들이 정확하게 무엇을 하는지 잘 모르며 내부적인 동작 원리나 방식에 대해서는 더 모른다. 심지어는 그런 부품이 있는지 존재 자체를 모르는 경우도 많다. 사용자가 알고 있는 정보란 액셀을 밟으면 전진하고 브레이크를 밟으면 멈추고 핸들을 비틀면 방향이 바뀐다는 것 정도에 불과하다. 이 정도만 알아도 운전을 할 수 있는데 이는 자동차가 정보 은폐를 잘 하고 있기 때문이다.
이번에는 좀 더 좁은 범위에서 엔진이라는 부품을 보자. 시동이 어떻게 걸리며 어떤 방식으로 연료를 태워 회전력을 얻는지 상세하게 아는 사람은 드물다. 하지만 그래도 운전을 할 수 있으며 심지어 엔진의 존재 자체를 몰라도 상관없다. 사용자는 엔진과 관련하여 공개된 정보를 가지고 있는데 바로 액셀과 브레이크이며 이 두가지만 잘 조작해도 숨겨진 엔진을 얼마든지 통제할 수 있다. 엔진이 사용자에 대해 추상화되어 있으며 액셀과 브레이크는 사용자에 대한 인터페이스가 되는 것이다.
만약 자동차의 내부 구조나 동작 방식을 다 알아야 운전을 할 수 있다면 운전은 너무 너무 어려운 기술이 될 것이며 운전 면허증을 따기 위해 운전학과를 졸업해야 할 것이다. 다행히도 현실은 그렇지 않아 자동차의 공개된 조작법(전문 용어로 인터페이스라고 한다) 정도만 익히면 면허증을 딸 수 있는데 이것이 정보 은폐에 의한 혜택이다. 이외에 우리가 일상 생활에서 늘상 사용하는 TV, 비디오, 노트북, 전화기 등의 사용법도 지극히 간단하다. 리모콘의 버튼을 누르는 최소한의 의사표현만으로 사용할 수 있도록 제조사들이 정보 은폐, 추상화를 해서 판매하기 때문이다.
프로그래밍의 객체들도 쓰기 쉬운 부품이 되기 위해서는 꼭 필요한 기능만 공개하고 사용자가 몰라도 되는 부분은 숨겨야 한다. 실제 클래스를 예로 들어 보자. JpegImage 클래스는 Jpeg 이미지 파일을 관리하는 클래스이며 이미지를 관리하고 출력할 수 있는 기능들이 캡슐화되어 있다.

class JpegImage
{
private:
     BYTE *RawData;
     JPEGHEADER Header;
     void DeComp();
     void EnComp();

public:
     Jpeg();
     ~Jpeg();
     BOOL Load(char *FileName);
     BOOL Save(char *FileName);
     void Draw(int x, int y);
};

손실 압축을 사용하는 Jpeg 파일의 내부는 무척 복잡해서 직접 Jpeg 파일로부터 이미지를 읽으려면 압축 해제 방법, 헤더의 구조, 버전별 차이 등 많은 것을 알아야 하고 압축을 풀기 위해 비트를 직접 다루는 어렵고도 골치아픈 작업을 해야 한다. 그러나 사용자들이 원하는 것은 Jpeg의 구조나 압축 원리가 아니라 Jpeg 파일을 출력하는 것 뿐이다. 이 클래스를 쓰면 몇 줄의 코드로 간단하게 이미지 파일을 읽어 출력할 수 있다.

JpegImage J;
J.Load("c:\\Image\\PrettyGirl.jpg");
J.Draw(10,10);

JpegImage 객체를 하나 만들고 Load 함수로 원하는 파일을 읽어 Draw로 화면에 출력하기만 하면 된다. 객체가 내부에서 압축을 해제하는 알고리즘이 어떻게 동작하는지, 해제된 비트를 어떻게 조립해서 눈에 보이는 이미지를 만들어 내는지, 화면에 출력하는 방법은 어떤지 이런 것들에 대해서는 전혀 알 필요가 없다. 그래서 JpegImage 클래스는 RawData, Header 등의 멤버 변수를 숨겨 밖으로 공개하지 않으며 압축을 해제하는 멤버 함수 DeComp도 은폐한다.
사용자가 이 클래스를 쓰기 위해 알아야 하는 것은 오로지 Load, Save, Draw등 공개된 멤버 함수뿐이다. 이 함수들이 바로 인터페이스이며 최소한의 인터페이스만 공개하는 것이 추상화의 정의이다. JpegImage 클래스가 더 많은 기능, 예를 들어 확대, 반전, 다른 포맷으로 변환 등을 제공하더라도 공개된 관련 멤버 함수를 호출하는 방법만 알면 된다. 다음은 어떤 초급 개발자가 만든 웹 브라우저 프로그램이다.
이 개발자는 WebBrowser라는 이미 만들어진 객체를 재사용했다. 이 객체는 네트워크를 통해 지정한 URL의 웹 페이지를 읽고 이미지를 출력하고 마우스 입력을 받아 연결된 링크로 이동하는 복잡한 기능을 가지고 있다. 내부적으로는 HTML 문서를 해석하고 캐시를 관리하며 보안 점검을 하고 스크립트를 해석하여 실행하는 더 복잡한 처리를 하고 있을 것이다. 하지만 개발자가 이런 것들까지 신경쓸 필요없이 이동하고 싶은 URL만 알려 주면 나머지는 내부에서 알아서 처리하도록 추상화되어 있다. 만약 이 컨트롤이 정보 은폐와 추상화를 적절히 하고 있지 않다면 초급 개발자가 이런 고성능 프로그램을 이토록 쉽게 만들 수는 없을 것이다.
이 외에도 객체를 활용하여 만들어진 프로그램들을 많이 구경할 수 있는데 동영상 재생 프로그램이나 MP3 플레이어, 그래픽 뷰어, 압축 유틸리티 등의 고성능 프로그램들도 개발자가 모든 것을 혼자 만든 것이 아니라 기존의 발표된 객체들을 재활용하여 만들어진 것이다. 동영상 뷰어는 ActiveMovie같은 좋은 컨트롤들이 많이 공개되어 있어 동영상 재생 자체는 컨트롤이 처리하며 프로그램은 자막 처리나 볼륨 조정 등의 인터페이스만 관리하면 된다.
C++은 클래스의 정보 은폐 기능을 지원하기 위해 private, public, protected 등의 액세스 지정자를 제공하며 액세스 지정자로 숨길 멤버와 공개할 멤버의 블록을 구성하도록 한다. 공개된 멤버는 외부에서 자유롭게 읽을 수 있지만 숨겨진 멤버를 참조하려고 시도하면 컴파일 과정에서 접근할 수 없다는 에러로 처리된다.


27-1-다.몰라야 한다

정보 은폐의 목적이 사용자가 신경쓰지 않아도 되는 것은 "몰라도 되도록" 하는 것 뿐이라면 굳이 액세스 지정자로 블록을 나누고 숨겨진 멤버를 건드리지 못하도록 금지까지 할 필요가 있을까? 멤버를 숨기는 언어적 장치까지 도입할 필요없이 사용자들이 관심없는 동작에 대해서는 알아서 신경쓰지 않으면 될 것 아닌가? 과연 그렇기는 하다. JpegImage 클래스의 경우 사용자는 Load, Save, Draw등 관심있는 함수만 사용하고 나머지는 그냥 무시하면 된다.
그러나 이렇게 형식성없이 사용자에게 알아서 조심 조심 쓰라고 하는 것은 무책임한 것이다. 다시 자동차의 예를 들어 보자. 액셀을 밟으면 연료의 양을 조절하는 스로틀 밸브가 열리고 엔진으로 연료가 많이 유입되어 회전수가 빨라진다. 사용자는 액셀만 조작할 수 있으며 스로틀 밸브는 직접 조작할 수 없도록 숨겨져 있다. 스로틀 밸브가 은폐되어 있지 않으면 사용자가 스로틀 밸브를 직접 조작하여 엔진으로 보낼 연료량을 마음대로 결정할 수 있게 될 것이다. 만약 사용자가 부주의하게 스로틀 밸브를 조작해서 초당 1리터씩 연료를 쏟아 부었다면 엔진은 곧 터져 버릴 것이다.
이것은 바람직한 기능 공개가 아니다. 허가된 이상의 연료를 보내는 것은 금지해야 한다. 기능을 은폐하지 않을 때 발생하는 문제점의 예는 얼마든지 찾을 수 있다. 신나게 전진하는 차에 갑자기 후진 기어 넣기, 전진 기어 들어간 채로 시동 걸기, 이런 조작은 위험하기 때문에 금지되어 있다. 하지만 누구도 이런 금지에 대해 자유를 구속한다는 불평을 하지 않는다. 조금이라도 생각이 있는 사람이 자동차를 만들었다면 응당 이렇게 만들어야 한다.
JpegImage 클래스의 경우도 마찬가지이다. RawData 멤버에 압축을 풀기전의 이미지 정보가 저장되어 있는데 이 값을 사용자가 직접 조작하도록 내버려 두면 이미지가 손상될 위험이 있다. 압축을 해제하는 DeComp 함수는 이미지 데이터의 구조 판별과 헤더 분석이 끝나야만 호출할 수 있는데 사용자가 이런 주의 사항을 모르고 아무렇게나 DeComp를 호출하도록 허락해서는 안된다. 사용자는 공개된 멤버를 통해 의사 표현만 하고 객체는 지시대로 서비스하는 것이 합리적이다.
사용자들은 기능이 복잡한 객체의 내부까지 속속들이 알기 어려우며 그러다 보면 부주의한 사용으로 인해 프로그램이 오동작하는 일이 빈번하다. 하드웨어의 경우에도 초보자들은 트랜지스터의 다리를 거꾸로 조립하거나 IC를 반대 방향으로 꽂아 부품이 터져 버리는 경우가 있다. 하드웨어 부품 설계자들은 이런 실수를 고려하여 혹시 잘못된 조작을 하더라도 최악의 상황은 방지할 수 있도록 신경을 쓰는데 예를 들어 CPU는 반대 방향으로 꽂지 못하도록 핀을 비대칭적으로 설계한다. 또한 사용자들이 굳이 몰라도 되는 내부 부품은 케이스 안쪽에 꼭꼭 숨겨 놓아 함부로 분해하지 못하도록 한다.
소프트웨어의 부품인 객체도 마찬가지로 부주의한 사용으로부터 스스로를 방어해야 한다. 일반적으로 객체를 만드는 사람은 객체의 사용자보다 숙련도가 훨씬 더 높으므로 객체 작성자가 이를 미리 예상하여 방어적인 설계를 할 수 있다. 소프트웨어 위기의 주원인은 고급 인력의 부족인데 OOP는 객체 사용자의 요구 숙련도를 떨어뜨려 고급 인력이 아니더라도 개발을 할 수 있도록 한다. 그 주요한 핵심 중 하나가 객체 사용자가 불필요한 것을 신경쓰지 않도록 하고 관심을 가질 수 없도록 정보를 은폐하여 객체의 안전성을 높이는 것이다.
이쯤 되면 정보 은폐는 "몰라도 된다" 정도가 아니라 "몰라야 한다"로 정의할 수 있다. 클래스를 디자인한 사람이 정보를 숨기는 이유는 사용자들이 이 정보를 신경쓰지 않도록 하려는 배려임과 동시에 함부로 건드릴 경우 객체가 위험해지는 상황을 원천적으로 방지하기 위해서이다. 그래서 숨겨 놓은 정보를 참조하려는 시도는 강제로 막아야 하며 사용자는 객체 제작자가 숨겨놓은 정보를 몰라도 되는 권리와 함께 마땅히 몰라야 하는 의무를 가진다.
비공개 멤버에 대해 사용자가 몰라야 하는 또 다른 이유는 클래스의 안정적인 기능 개선을 위해서이다. 비공개 영역은 사용자가 몰라도 됨과 동시에 알고 싶어도 알 수 없는 영역이다. 그래서 이 부분은 기존 사용자의 허가없이 마음대로 뜯어 고치거나 기능을 개선할 수 있으며 이렇게 수정하더라도 공개 영역만 알고있는 사용자들은 이 객체를 원래 쓰던 방법 그대로 사용할 수 있다. 예를 들어 JpegImage 클래스의 제작자가 더 좋은 압축 해제 알고리즘을 개발했다면 DeComp 함수를 즉시 수정할 수 있으며 이때 이미 이 클래스를 사용하는 코드는 DeComp를 직접 사용하지 않았으므로 별다른 영향을 받지 않는다. 그러면서도 개선된 알고리즘의 혜택을 받을 수는 있다.
반면 공개된 영역을 수정할 필요가 있다면 이때는 기존 객체와 호환성을 잃게 된다. 공개 영역은 이미 사용자들이 알고 있는 영역이며 많은 사람들이 사용하고 있기 때문에 함부로 수정할 수 없다. 만약 불가피하게 수정하게 되면 기존 사용자들이 이 객체에 대해 습득한 지식은 무효가 되며 추상화의 조건을 어기게 되는 것이다. 그래서 애초에 사용자들과 직접적인 인터페이스를 이루지 않는 부분은 최대한 숨기는 것이 유리하다.
실행활에 쓰이는 물건들도 이 원칙대로 만들어져 있다. 핸드폰은 버튼을 누르면 번호가 찍히고 통화 버튼을 누르면 상대방과 연결되는데 사용자가 알고 있는 지식은 이 정도뿐이다. 버튼을 고무로 만들건 플라스틱으로 만들건, 기지국과 어떤 주파수로 통신하건 그런건 몰라도 핸드폰을 쓰는데는 아무 지장이 없다. 주요 정보와 동작들이 숨겨져 있기 때문에 제작사들은 사용자와의 약속을 지키는 범위내에서 플립형이니 폴더형이니 슬라이더형이니 모델을 매번 바꿀 수 있는 것이다. 모델이 아무리 바뀌어도 핸드폰으로 통화하는 방법이 바뀌는 경우는 없다.
비공개 멤버를 은폐하고 은폐된 멤버는 강제로 쓰지 못하도록 막는 것은 사용자의 자유를 구속하는 것이 아니다. 오히려 사용자가 알아야 할 정보를 최소화하여 쓰기 쉽도록 하며 부주의한 사용으로부터 스스로를 보호하여 신뢰성을 높이고 호환성을 유지한 채로 업그레이드할 수 있도록 한다. 객체가 진정한 부품이 되기 위해서 숨길 것은 적극적으로 숨겨야 하며 C++은 객체가 정보를 숨길 수 있는 언어적 장치를 훌륭하게 제공한다.
정보 은폐의 개념이 굉장히 생소해 보이겠지만 사실 객체 지향 이전의 C언어에서도 정보 은폐 개념이 존재했으며 알게 모르게 우리는 정보 은폐의 혜택을 받아왔다. printf의 내부 구현을 전혀 모르고도 서식만 외워서 잘만 써 먹고 있으며 심지어 이 함수 내부가 어떻게 작성되어 있는지 관심조차 가지지 않는다. 내부를 모르고 내부 기능에 의존하지 않기 때문에 컴파일러마다 printf의 실제 코드가 달라도 사용자 코드가 영향을 받지 않는 것이다. 내부 코드가 어떻게 바뀌든 사용 방법(%d, %s 등의 서식)만 바뀌지 않으면 그만이다.
정보 은폐에 의해 객체는 완전한 부품이 되고 우리는 이런 부품들을 조립하여 손쉽게 프로그램을 만들 수 있다. 일부 시스템 프로그래머를 제외하고 대부분의 응용 프로그램 개발자들은 직간접적으로 남이 만든 객체를 재활용하여 프로그램을 작성하며 이것이 요즘의 대세다. 남의 코드를 쓰는데 대해 거부감을 느낄 필요는 전혀 없다. 내가 직접 만들지 않은 객체라도 잘 조립해서 좋은 프로그램을 만들 수 있다면 훌륭한 개발자가 될 수 있다.
앞장에서 예를 든 라디오 조립키트를 생각해 보자. 설령 이 키트를 사용해서 설명서대로 납땜만 했다 하더라도 그 라디오는 분명히 내가 만든 것이라고 할 수 있으며 어느 누구도 나의 작품임을 부정하지 않는다. 만약 하드웨어를 매번 처음부터 만들어야 한다면 라디오 하나 만들기 위해 배낭 메고 산으로 바다로 구리와 실리콘을 추출하기 위해 다녀야 할 것이다. 요즘 세상에 이런 식으로 하드웨어를 만드는 사람은 아무도 없다.
소프트웨어도 마찬가지로 처음부터 모든 것을 다 만들어야 하는 시절은 한참 전에 지났다. 그러나 가져다 쓰더라도 내부 구조를 알고 쓰는 것과 무조건 얻어서 쓰기만 하는 것은 분명히 다르다. 그래서 객체를 쓰는 방법뿐만 아니라 객체를 만드는 방법도 배워야 하는 것이다. 객체를 잘 만드는 사람이 남이 만든 것도 잘 활용하기 마련이다. 맨날 남이 만든 것만 쓸 수는 없고 나도 남을 위해, 그리고 미래의 나 자신을 위해 객체를 만들기도 해야 한다. 그래서 지금 여러분들이 힘들게 C++문법을 배우고 있는 것이다.


27-1-라.캡슐화 방법

객체의 정보중 사용자가 관심을 가지지 않는 정보를 왜 숨겨야 하는지, 정보를 숨길 때 어떤 이점이 있는지를 연구해 보았다. 그렇다면 어떤 정보를 숨겨야 하고 공개해야 할 정보는 어떤 것일까? 이 질문에 대한 답은 아주 원론적인데 숨길만한 건 숨기고 공개할 필요가 있는 것은 공개해야 한다. 즉 정보 은폐에 대한 절대적인 공식은 없으며 객체의 상황에 따라 자유롭게 선택할 수 있다.
모든 객체에 적합한 정보 은폐 공식은 없지만 대충의 가이드라인을 제시해 보면 이렇다. 멤버 변수는 객체의 상태를 저장하는 중요한 정보들이므로 외부에서 함부로 변경하지 못하도록 숨기고 멤버 함수는 외부와 인터페이스를 이루는 수단이므로 공개한다. 숨겨진 멤버 변수는 공개된 멤버 함수를 통해 정해진 방법으로만 액세스하도록 하는 것이 보통이다. 물론 항상 그렇지는 않아서 어떤 멤버 변수는 공개하는 것이 더 편리한 경우도 있고 내부적인 동작에만 사용되는 멤버 함수는 숨길 수도 있다.
다음 예제의 Student 클래스는 학생 한 명의 학번, 이름, 점수를 저장하는데 정보를 은폐하는 여러 가지 기법들을 보여준다.

  : InfoHide
#include <Turboc.h>

class Student
{
private:
     int StNum;
     char Name[16];
     unsigned Score;
     BOOL TestScore(int aScore) {
          return (aScore >= 0 && aScore <= 100);
     }

public:
     Student(int aStNum) { StNum=aStNum;Name[0]=0;Score=0; }
     int GetStNum() { return StNum; }
     const char *GetName() { return Name; }
     void SetName(char *aName) { strncpy(Name,aName,15); }
     unsigned GetScore() { return Score; }
     void SetScore(int aScore) {
          if (TestScore(aScore))
              Score=aScore;
     }
};

void main()
{
     Student Kim(8906123);
     Kim.SetName("김천재");
     Kim.SetScore(99);
     printf("학번=%d, 이름:%s, 점수:%d\n",
          Kim.GetStNum(),Kim.GetName(),Kim.GetScore());
}

이 클래스는 학번을 저장하는 StNum, 이름을 저장하는 Name과 점수를 저장하는 Score등 세 개의 멤버 변수를 가지고 있다. 멤버 변수는 모두 private 영역에 선언되어 있으므로 외부에서 이 값을 읽을 수 없으며 변경할 수도 없다. 대신 이 멤버 변수들의 값을 읽고 쓰는 Get, Set 등의 액세스 함수들이 공개되어 있어 클라이언트는 액세스 함수를 통해 객체의 값을 조사하거나 변경할 수 있다.
값을 조사하는 Get 함수들은 대응되는 멤버 변수의 값을 읽는데 이 예제의 Get 함수들은 모두 단순한 return문만 가진다. 필요할 경우 둘 이상의 값을 계산한 결과나 실시간으로 조사된 값을 돌려줄 수도 있다. Set 함수들은 대응되는 멤버 변수의 값을 변경한다. 별다른 규칙이 없다면 단순한 대입문만 가지겠지만 통상 전달된 인수가 규칙에 맞는지 조건을 따져 보고 합리적인 값만 받아들인다.
이름을 변경하는 SetName함수는 인수로 전달된 aName을 Name 멤버 변수에 복사하되 strncpy 함수를 사용하여 문자열이 15자를 넘지 않도록 함으로써 스스로의 무결성을 지킨다. 만약 Name이 공개된 멤버라면 외부에서 strcpy(Kim.Name,"예쁘고 사랑스러운 김공주"); 따위의 명령으로 이 객체를 한방에 엉망으로 만들어 버릴 수 있다. 사용자의 실수에 대해서도 꿋꿋하게 버티기 위해 Name은 숨기고 버퍼 길이만큼만 복사하는 안전한 SetName만 공개한 것이다.
Name 멤버의 값을 조사하는 GetName 함수도 상수 지시 포인터를 리턴함으로써 Name을 읽을 수만 있게 하며 쓰지는 못하도록 금지한다. 만약 이 함수가 const가 아닌 포인터를 리턴할 경우 다음과 같은 코드가 가능해지며 이렇게 되면 객체는 또 다시 위험한 상황에 노출될 것이다.

strcpy(Kim.GetName(),"멋지고 용감하고 씩씩한 김왕자");

일단 포인터를 얻기만 하면 가리키는 대상체뿐만 아니라 그 주변까지도 마음대로 읽고 쓸 수 있으므로 부주의한 사용으로부터 방어할 방법이 없다. 그래서 Name 멤버 변수는 철저하게 숨겨 외부로부터 보호하고 이 멤버를 읽고 쓸 때는 Get/Set 액세스 함수를 경유하여 클래스 작성자가 미리 정해 놓은 규칙을 따라야 한다.
SetScore 함수는 점수를 저장하는 Score 멤버 변수의 값을 변경하는데 점수의 가능한 범위는 0~100까지로 제한된다. 인수로 전달된 aScore가 이 범위에 있는지 먼저 점검해 보고 유효한 점수일 때만 값을 대입하며 그렇지 않으면 무시한다. 점수의 범위를 점검하는 TestScore 함수는 외부에서 직접 호출하지 않으므로 프라이비트 영역에 두었다. 만약 점수의 범위를 점검하는 획기적인 알고리즘이 새로 개발되었다면(별로 그럴 것 같지는 않지만) 이 함수는 사용자의 동의없이 언제든지 수정할 수 있다.
학번을 저장하는 StNum멤버 변수는 생성자에서 초기화되며 Get 함수만 있고 Set 함수가 없다. 그러므로 외부에서 학번을 읽을 수는 있지만 어떤 수를 쓰더라도 학번을 변경하지는 못한다. 즉, 이 멤버는 자연스럽게 읽기 전용의 정보가 되는데 학번이 읽기 전용인 것은 논리적으로 합당하다. 별로 실용성은 없지만 필요하다면 Get 함수는 빼고 Set 함수만 제공하는 방법으로 쓰기 전용의 정보를 만드는 것도 문법적으로 가능하다. 만약 Kim.StNum=1234; 따위의 코드로 숨겨진 멤버를 읽거나 쓰려고 시도하면 컴파일 에러로 처리된다. 컴파일 에러는 개발중에 즉시 알 수 있으므로 수정하기 쉽다.
Student 클래스의 예에서는 볼 수 없지만 protected라는 액세스 속성을 사용하면 외부에서는 읽을 수 없고 상속된 클래스의 객체에서는 읽을 수 있는 멤버를 선언할 수도 있다. protected는 private와 public의 중간 정도되는 은폐 수준인데 다음에 상속을 공부할 때 다시 알아보도록 하자. 상속을 하지 않을 경우 protected는 private와 동일하다.



27-1-마.자동차 클래스

OOP의 정보 은폐 기능을 설명하기 위해 단골로 등장하는 사물이 바로 자동차이며 이 책도 앞에서 자동차를 예로 들어 설명했다. 자동차의 기능들은 대부분의 사람들이 잘 알고 있으며 내부의 복잡한 구조에 비해 공개된 인터페이스가 적어 정보 은폐 기능을 설명하기에 적절하기 때문이다. C++의 클래스는 실세계의 모든 사물을 다 표현할 수 있는데 과연 자동차를 어떻게 표현하는지 예제를 만들어 보자.
표현력이 섬세하고 용량에 상관없이 추상화를 한다면 실제 자동차와 거의 똑같은 자동차 클래스를 만들어 사실적으로 묘사할 수도 있다. 그러나 이렇게 하자면 예제가 너무 커지고 콘솔 환경의 표현력도 충분하지 않으므로 개념적인 자동차만 만들어 보도록 하자. 자동차 문 열기, 시동 걸기, 깜박이 넣기 따위는 무시하고 주행 기능만 표현하기로 한다.

  : CarObject
#include <Turboc.h>

class Car
{
private:
     int Gear;
     int Angle;
     int Rpm;

public:
     Car() { Gear=0; Angle=0; Rpm=0; }
     void ChangeGear(int aGear) {
          if (aGear >= 0 && aGear <= 6) {
              Gear=aGear;
          }
     }
     void RotateWheel(int Delta) {
          int tAngle=Angle+Delta;
          if (tAngle >= -45 && tAngle <= 45) {
              Angle=tAngle;
          }
     }
     void Accel() {
          Rpm=min(Rpm+100,3000);
     }
     void Break() {
          Rpm=max(Rpm-500,0);
     }
     void Run() {
          int Speed;
          char Mes[128];
          gotoxy(10,12);
          if (Gear == 0) {
              puts("먼저 1~6키를 눌러 기어를 넣으시오           ");
              return;
          }
          if (Gear == 6) {
              Speed=Rpm/100;
          } else {
              Speed=Gear*Rpm/100;
          }
          sprintf(Mes,"%d의 속도로 %s쪽 %d도 방향으로 %s진중      ",
              abs(Speed),(Angle >= 0 ? "오른":"왼"),abs(Angle),
              (Gear==6 ? "후":"전"));
          puts(Mes);
     }
};

void main()
{
     Car C;
     int ch;

     for(;;) {
          gotoxy(10,10);
          printf("1~5:기어 변속, 6:후진 기어, 0:기어 중립");
          gotoxy(10,11);
          printf("위:액셀, 아래:브레이크, 좌우:핸들, Q:종료");
          if (kbhit()) ch=getch();
          if (ch == 0xE0 || ch == 0) {
              ch=getch();
              switch (ch) {
              case 75:
                   C.RotateWheel(-5);
                   break;
              case 77:
                   C.RotateWheel(5);
                   break;
              case 72:
                   C.Accel();
                   break;
              case 80:
                   C.Break();
                   break;
              }
          } else {
              if (ch >= '0' && ch <= '6') {
                   C.ChangeGear(ch-'0');
              } else if (ch == 'Q' || ch == 'q') {
                   exit(0);
              }
          }
          C.Run();
          delay(10);
     }
}

속성으로 Gear, 앞 바퀴의 각도인 Angle, 엔진의 회전수인 Rpm만 포함했으며 이 속성들은 클래스의 멤버 변수로 선언된다. 생성자에서 모든 멤버는 0으로 초기화했다. 사용자는 먼저 기어를 넣고 액셀을 밟음으로써 엔진을 회전시키며 핸들을 좌우로 비틀어서 차를 운전한다. 속도를 더 내고 싶으면 기어를 바꾸고 주행중인 차를 멈추고 싶을 때는 브레이크를 밟으면 된다. 자동차의 현재 상태를 나타내는 세 가지 주요 멤버는 private로 숨겨져 있어 외부에서 함부로 조작할 수 없도록 보호된다.
운전자는 기어 스틱, 액셀, 브레이크, 핸들 등의 외부로 공개된 장치로만 자동차를 제어할 수 있는데 Car 클래스는 외부 인터페이스를 public 멤버 함수로 제공한다. 기어를 변경할 때는 ChangeGear 함수를 호출하는데 유효한 기어는 0~6까지뿐이며 이 외의 값이 들어오면 무시한다. 존재하지도 않는 8단, 9단 기어를 넣지는 않는다. 자동차의 방향을 변경하고 싶을 때는 RotateWheel 함수를 호출하는데 좌우로 45도까지만 각도를 변경할 수 있어 바퀴가 뒤로 획 돌아간다거나 하는 일은 절대로 없다.
Accel과 Break는 엔진 회전수인 Rpm을 조정하되 0~3000까지의 범위 내에서만 Rpm을 조작한다. 액셀을 밟을 때는 회전수가 천천히 올라가고 브레이크를 밟을 때는 급격하게 떨어지도록 하여 조금이나마 사실감 있게 표현했다. 실제 회전수인 Rpm은 외부로부터 철저하게 차단되어 있으며 Accel과 Break 함수는 공개되어 있지만 자체적인 에러 처리로 무효한 값을 방지한다. 외부에서 아무리 액셀을 오랫동안 밟고 있어도 엔진 허용치 이상은 더 올라가지 않으며 브레이크를 아무리 세게 밟아도 Rpm이 음수가 되지는 않는다.
Run 함수는 기어와 바퀴의 각도, 엔진 회전수 정보를 종합적으로 판단하여 자동차를 운행한다. 기어가 중립이면 엔진과 바퀴가 연결되지 않은 것으므로 차는 운행할 수 없으며 전진 기어의 단수에 따라 Rpm에 곱해지는 값이 틀려진다. 후진 기어일 경우는 천천히 뒤로 후진하도록 했으며 핸들의 현재 상태에 따라 좌우로 방향을 틀기도 한다. 콘솔 환경에서 이동중인 차를 그릴 수 없어 텍스트 출력으로 대신했지만 그래픽 환경이라면 이동하는 차를 얼마든지 그릴 수 있다.
main에서 Car 클래스의 객체 C를 선언하고 키 입력을 받아 자동차를 조작하는데 클래스의 외부에서는 공개된 멤버 함수만 호출할 수 있으며 자동차의 주요 부품을 직접 조작할 수는 없다. 자동차가 스스로를 보호하고 있으므로 핸들을 과하게 비틀거나 액셀을 계속 밟고 있어도 별 문제가 되지 않는다. 이 예제에서는 자동차 객체 하나밖에 없지만 길, 장애물, 여러 개의 자동차를 만들어 서로간의 관계를 정의하면 멋진 자동차 경주 게임이 만들어질 것이다.
이 예제의 자동차는 아주 단순하게 모델링되었지만 프로그램의 필요에 따라 클래스는 얼마든지 실세계의 사물과 똑같아질 수 있다. 차 문짝도 달 수 있고 백미러, 타이어 등도 부착할 수 있으며 전진할 때 뒤로 뿜어져 나오는 배기 가스도 표현할 수 있다. 사실 이런 식이라면 항공모함이나 우주선, 로보트 태권 V도 얼마든지 만들 수 있는 셈이다.


27-2.프렌드

27-2-가.프렌드 함수

정보를 은폐하면 객체의 신뢰성이 높아지고 기능 개선도 용이한 것은 분명하다. 그러나 솔직히 불편한 면이 있다. C++의 액세스 지정자는 너무 엄격해서 일단 숨기면 정상적인 문법으로는 외부에서 이 멤버를 참조할 수 없다. 물론 캐스트 연산자와 포인터를 사용하는 비정상적인 문법을 동원하면 가능할 수도 있지만 이렇게 하면 이식성과 확장성은 포기해야 한다. 어떤 경우에는 이런 정보 은폐 기능이 방해가 될 수도 있기 때문에 예외적으로 지정한 대상에 대해서는 모든 멤버를 공개할 수 있는데 이를 프렌드 지정이라고 한다.
프렌드는 전역 함수, 클래스, 멤버 함수의 세가지 수준에서 지정할 수 있다. 상대적으로 가장 간단한 프렌드 함수부터 알아보자. 프렌드로 지정하고 싶은 함수의 원형을 클래스 선언문에 적되 원형앞에 friend라는 키워드를 붙인다. friend 선언의 위치는 아무래도 상관없으며 어떤 영역에 있더라도 차이가 없지만 클래스 선언부의 선두에 두어 눈에 잘 띄도록 하는 것이 좋다. 다음은 func 함수를 Some 클래스의 프렌드로 지정한 것이다.

class Some
{
     friend void func();
     ....
};

func함수는 클래스 선언부에 원형이 포함되어 있지만 Some클래스의 멤버는 아니며 본체는 외부에 따로 존재하므로 단순한 전역 함수이다. 하지만 Some 클래스 선언부에서 func 함수를 프렌드로 지정했으므로 마치 클래스 소속의 멤버 함수인 것처럼 이 클래스의 모든 멤버를 자유롭게 액세스할 수 있는 특권이 부여된다. private영역에 있건 public 영역에 있건 어떤 멤버 변수든지 읽고 쓸 수 있으며 모든 멤버 함수를 자유롭게 호출할 수 있다. 프렌드 함수가 실용적으로 사용되는 예를 보자.

  : FriendFunc
#include <Turboc.h>

class Date;
class Time
{
     friend void OutToday(Date &,Time &);
private:
     int hour,min,sec;
public:
     Time(int h,int m,int s) { hour=h;min=m;sec=s; }
};

class Date
{
     friend void OutToday(Date &,Time &);
private:
     int year,month,day;
public:
     Date(int y,int m,int d) { year=y;month=m;day=d; }
};

void OutToday(Date &d, Time &t)
{
     printf("오늘은 %d년 %d월 %d일이며 지금 시간은 %d:%d:%d입니다.\n",
          d.year,d.month,d.day,t.hour,t.min,t.sec);
}

void main()
{
     Date D(2005,01,02);
     Time T(12,34,56);
     OutToday(D,T);
}

Date는 날짜를 표현하는 클래스이며 Time은 시간을 표현하는 클래스이다. 정보를 기억하는 주요 변수들은 모두 private 영역에 선언되어 있어 외부에서 함부로 액세스하지 못하도록 하였다. OutToday함수는 이 두 클래스의 객체를 인수로 전달받아 날짜와 시간을 동시에 출력한다. 그러기 위해서 OutToday는 양쪽 클래스의 모든 멤버를 읽을 수 있어야 하는데 Date나 Time의 멤버 함수로 포함되면 한쪽밖에 읽을 수 없을 것이다. 한 함수가 동시에 두 클래스의 멤버 함수가 될 수는 없기 때문이다.
이럴 때 OutToday를 멤버 함수가 아닌 전역 함수로 정의하고 양쪽 클래스에서 이 함수를 프렌드로 지정하면 된다. 이렇게 되면 OutToday는 Date의 year, month, day와 Time의 hour, min, sec을 모두 읽을 수 있다. 마치 양쪽 클래스의 멤버 함수인 것처럼 숨겨진 멤버를 자유롭게 액세스한다. 클래스 선언문의 프렌드 지정을 주석으로 처리한 후 컴파일하면 숨겨진 멤버를 액세스할 수 없다는 에러 메시지가 잔뜩 출력될 것이다.
OutToday 함수는 Time, Date 타입의 인수를 동시에 취하는데 이 함수의 원형을 사용하기 전에 두 명칭이 클래스라는 것을 선언해야 한다. 양쪽 클래스 선언문에 프렌드 지정이 동시에 들어가야 하므로 먼저 선언하는 쪽을 위해 나중에 선언되는 클래스에 대한 전방 선언이 필요하다. Time 클래스 선언 이전에 Date가 클래스라는 것을 먼저 알려야 컴파일러가 OutToday의 원형을 해석할 수 있으며 그래서 class Date; 라는 전방 선언이 선두에 포함되었다.
이 예제의 경우 프렌드만이 문제를 해결하는 유일한 방법은 아니다. 외부에서 액세스할 필요가 있는 멤버를 공개하는 극약 처방을 쓸 수도 있지만 이렇게 하면 정보 은폐의 원칙이 무너진다. 또한 프렌드 지정을 하는 대신 각 클래스에 숨겨진 멤버를 대신 읽어주는 Get, Set 액세스 함수를 공개 영역에 작성하고 OutToday는 액세스 함수들로 값을 읽을 수도 있다. 하지만 이런 방법이 번거롭고 귀찮기 때문에 프렌드 지정이라는 좀 더 간편한 방법을 사용하는 것이다. 이 예제는 설명을 위한 개념적인 예에 불과하며 프렌드가 꼭 필요한 경우는 다음 장의 연산자 오버로딩에서 보게 될 것이다.


27-2-나.프렌드 클래스

두 클래스가 아주 밀접한 관련이 있고 서로 숨겨진 멤버를 자유롭게 읽어야 하는 상황이라면 클래스를 통째로 프렌드로 지정할 수 있다. 클래스 선언문내에 프렌드로 지정하고 싶은 클래스의 이름을 밝히면 된다. 다음 예는 Any 클래스를 Some 클래스의 프렌드로 지정하는 것이다.

class Some
{
     friend class Any;
     ....
};

Any가 Some의 프렌드로 지정되었으므로 Any의 모든 멤버 함수들은 Some의 모든 멤버를 마음대로 액세스할 수 있다. 두 클래스가 협조적으로 동작해야 한다거나 상호 종속적인 관계에 있을 때 프렌드로 지정하면 편리하다. 앞 예제의 OutToday 함수를 Date 클래스의 멤버 함수로 선언하되 Date를 Time의 프렌드 클래스로 지정해 보자.

  : FriendClass
#include <Turboc.h>

class Time
{
     friend class Date;
private:
     int hour,min,sec;
public:
     Time(int h,int m,int s) { hour=h;min=m;sec=s; }
};

class Date
{
private:
     int year,month,day;
public:
     Date(int y,int m,int d) { year=y;month=m;day=d; }
     void OutToday(Time &t) {
          printf("오늘은 %d년 %d월 %d일이며 지금 시간은 %d:%d:%d입니다.\n",
              year,month,day,t.hour,t.min,t.sec);
     }
};

void main()
{
     Date D(2005,01,02);
     Time T(12,34,56);
     D.OutToday(T);
}

OutToday 함수는 Date 클래스의 멤버 함수로 선언되었지만 Date가 Time의 프렌드 클래스로 지정되어 있으므로 OutToday는 Time 객체의 모든 멤버를 읽을 수 있다. 실행 결과는 앞의 예제와 동일하다. 개념적으로 이해하기 쉬운 간단한 예제를 보였는데 실제로 프렌드 클래스 지정이 꼭 필요한 예는 다소 크고 복잡하다. MFC 라이브러리의 경우 다음과 같은 프렌드 클래스의 예가 많이 존재한다.

 CDocument가 CView의 프렌드
 CTime이 CTimeSpan의 프렌드
 CToolBar가 CToolTipCtrl의 프렌드
 CPropertySheet가 CPropertyPage의 프렌드

모두 아주 밀접한 관계에 있는 클래스들인데 MFC의 구조를 공부해 보면 이 클래스들이 왜 프렌드여야 하는지 알게 될 것이다. CView와 CDocument는 하나의 실체에 대해 각각 외부와 내부를 다루는 관련있는 클래스이다. CView는 자신이 화면에 출력할 데이터를 읽기 위해 CDocument의 멤버를 마음대로 읽을 수 있어야 하며 CToolTipCtrl 클래스는 툴팁을 보여줄 버튼이나 영역을 구하기 위해 CToolBar의 멤버를 액세스해야 한다.

27-2-다.프렌드 멤버 함수

프렌드 클래스 지정은 특정 클래스의 모든 멤버 함수들이 자신의 숨겨진 멤버를 마음대로 읽도록 허락하는 것이다. 멤버 함수의 수가 많을 경우 모든 멤버 함수들이 대상 클래스의 멤버를 액세스할 필요가 없음에도 불구하고 허용의 범위가 너무 넓어져 위험해진다. 프렌드 멤버 함수는 특정 클래스의 특정 멤버 함수만 프렌드로 지정하는 것이며 꼭 필요한 함수에 대해서만 숨겨진 멤버를 액세스하도록 범위를 좁게 설정할 수 있는 장점이 있다.
개념은 프렌드 함수와 동일하되 다른 클래스에 속한 멤버 함수라는 것만 다르다. 클래스 선언부에 프렌드로 지정하고자 하는 멤버 함수의 원형을 friend 키워드와 함께 적어주면 된다. 다음 예는 Any::func 멤버 함수를 Some 클래스의 프렌드로 지정한다.

class Some
{
     ....
     friend void Any::func(Some &S);
};

이렇게 선언하면 Any클래스의 func 멤버 함수는 Some 클래스의 모든 멤버를 액세스할 수 있다. 그러나 Any 클래스의 다른 멤버 함수에게는 이런 특권이 부여되지 않는다. 오로지 Any::func에 대해서만 프렌드 지정을 한 것이다. 다음 예제는 Date::OutToday 멤버만 프렌드로 지정한다.

  : FriendMem
#include <Turboc.h>

class Time;
class Date
{
private:
     int year,month,day;
public:
     Date(int y,int m,int d) { year=y;month=m;day=d; }
     void OutToday(Time &t);
};

class Time
{
     friend void Date::OutToday(Time &t);
private:
     int hour,min,sec;
public:
     Time(int h,int m,int s) { hour=h;min=m;sec=s; }
};

void Date::OutToday(Time &t)
{
     printf("오늘은 %d년 %d월 %d일이며 지금 시간은 %d:%d:%d입니다.\n",
          year,month,day,t.hour,t.min,t.sec);
}

void main()
{
     Date D(2005,01,02);
     Time T(12,34,56);
     D.OutToday(T);
}

실행 결과는 물론 앞의 두 예제와 동일하다. OutToday 멤버 함수가 Time의 프렌드로 지정되어 있으므로 Time의 멤버들을 자유롭게 액세스할 수 있다. 오로지 이 멤버 함수만 프렌드로 지정되었으므로 다른 멤버 함수들은 여전히 Time 클래스를 액세스할 수 없다. 멤버 함수를 프렌드로 지정할 때는 선언 순서에 약간 신경을 써야 한다. 프렌드 멤버 함수는 프렌드로 지정되는 클래스 소속이며 통상 대상 클래스를 인수로 전달받기 때문에 프렌드 지정을 포함하는 클래스를 먼저 선언하고 프렌드 멤버 함수를 포함한 클래스를 전방 선언해야 한다.
Time에서 Date::OutToday를 프렌드로 지정하기 위해서는 이 함수의 원형을 먼저 알려야 하므로 Date 클래스가 앞쪽에 선언되어야 한다. 또한 OutToday에서 Time형 객체를 인수로 전달받으므로 Date 클래스 선언문 이전에 Time이 클래스라는 전방 선언이 필요하다. 순서가 바뀌면 안된다. 그리고 OutToday의 본체에서 Time 객체의 멤버를 참조하므로 이 함수의 본체는 클래스 선언부에 둘 수 없으며 Time 클래스 정의 후에 따로 본체를 정의해야 한다. 만약 OutToday를 인라인으로 만들고 싶다면 본체 정의부에 inline 키워드를 쓰면 된다.
두 클래스가 서로를 참조하고 있는 상황이라 선언 순서가 조금 난잡스럽다. 말로 설명을 하자니 괜히 복잡해 보이지만 직접 코드를 작성해 보면 별 것도 아닌 규칙들이다. 간단히 요약하자면 서로 알 수 있도록 소개해 주는 것 뿐이다. 위 예제의 선언 순서를 바꿔 보고 에러 메시지를 읽어 보면 어떤 선언 순서가 좋은지 직감적으로 이해할 수 있을 것이다. 에러 메시지가 지적하는대로 순서를 바꾸고 전방 선언을 조금만 활용하면 된다.

27-2-라.프렌드의 특성

프렌드 지정은 몇 가지 특성을 가지고 있는데 가급적이면 명시적으로 선언되지 않은 대상에 대해서는 특권을 주지 않는 성질이 있다. 코드 작성의 편의를 위해 예외적으로 프렌드라는 것을 도입했지만 그 부작용을 최소화하기 위해 마련된 규칙들이며 생각해 보면 아주 상식적인 것들이다.

 프렌드 지정은 단방향이며 명시적으로 지정한 대상만 프렌드가 된다. A가 B를 프렌드로 선언했다고 하더라도 B가 A를 프렌드로 선언하지 않으면 A는 B의 프렌드가 아니다. 그래서 B는 A의 모든 멤버를 읽을 수 있지만 A는 그렇지 못하다.
다소 야박해 보이지만 사실 인간 관계에도 이런 예는 있다. 표준 위원들 사이에 friend라는 키워드가 실제 뜻인 "친구"와 정확하게 같지 않으므로 직관적이지 못하고 부적절하다는 지적이 있었다는 것은 참 재미있는 얘기다. 만약 A와 B가 서로 프렌드가 되려면 양쪽 모두 상대방을 프렌드로 지정해야 하며 이런 관계를 상호 프렌드라고 한다.
양쪽이 서로를 프렌드로 등록했으므로 A와 B는 서로의 멤버를 자유롭게 읽을 수 있다.
 프렌드 지정은 전이되지 않으며 친구의 친구 관계는 인정하지 않는다. A, B, C 세 개의 클래스가 있을 때 이 관계를 그림으로 나타내 보면 다음과 같다.
A는 B를 프렌드 선언했고 B는 C를 프렌드 선언했다. 그래서 B는 A를 마음대로 액세스할 수 있으며 C도 B를 마음대로 액세스할 수 있다. 그러나 C는 A의 숨겨진 멤버를 액세스할 수 없다. C가 A의 프렌드가 되려면 A가 C를 프렌드로 지정해야 한다. 프렌드 함수의 경우도 마찬가지 규칙이 적용된다. func 함수가 A의 프렌드이고 B가 A를 프렌드로 지정한다고 해서 func 함수가 B를 액세스할 수 있는 것은 아니다. 프렌드 지정은 항상 허가하는 쪽에서 명시적으로 해야 한다.
 복수의 대상에 대해 동시에 프렌드 지정을 할 수 있지만 한 번에 하나씩만 가능하다. A가 B, C를 동시에 프렌드로 지정하고 싶을 때 다음처럼 해야 한다.

class A
{
     friend class B;
     firend class C;
     ....

friend class B, C; 이런 문법은 허용되지 않는다는 간단한 얘기다. 프렌드 지정은 흔하지 않기 때문에 한 번에 하나씩 해도 별로 불편하지 않다.
 프렌드 관계는 상속되지 않는다. A가 B를 프렌드로 지정하면 B는 A를 액세스할 수 있다. 그러나 B로부터 파생된 D는 A의 프렌드가 아니므로 A를 마음대로 액세스할 수 없다. 친구의 자식은 친구가 아니다.
그러나 D가 상속받은 B의 멤버 함수는 B 클래스의 소속이라고 볼 수 있으므로 여전히 A를 액세스할 수 있다.

프렌드는 OOP의 정보 은폐 원칙에 대한 일종의 예외이다. 숨겨 놓은 정보를 읽기 위해 일일이 액세스 함수를 경유하는 것이 너무 불편하고 때로는 외부 함수가 내부 멤버를 액세스해야 하는 불가피한 경우가 있어 프렌드가 반드시 필요하지만 너무 빈번하게 사용하는 것은 좋지 않다. 프렌드가 아니면 문제를 해결할 수 없는 경우에 한해서 조심 조심 사용해야 한다.
어떻게 생각하면 프렌드는 은폐된 정보의 기밀성을 완전히 무시해 버리므로 무척 위험한 장치처럼 생각될 수도 있다. 그러나 개발자의 명시적인 지정에 의해서만 예외를 인정하며 그것도 클래스의 선언부에서만 지정할 수 있고 클래스 외부에서는 지정할 수 없으므로 위험하지는 않다. 외부의 클래스나 함수가 임의로 프렌드 선언을 하고 마음대로 읽고 쓸 수는 없다는 얘기다. 프렌드 지정에 의해 공개되는 범위는 지정한 클래스나 함수에 국한되므로 전면적인 공개와는 성질이 다르다.


27-3.정적 멤버

27-3-가.this

멤버 변수는 객체별로 따로 가지며 멤버 함수는 클래스에 속한 모든 객체들이 공유하는데 이 점에 대해서는 앞 장의 Position 클래스에서 이미 확인해 본 바 있다. 멤버 변수는 개별 객체의 상태를 저장하므로 객체별로 유지되는 것이 옳고 멤버 함수가 정의하는 동작은 모든 객체에 공통적으로 적용되므로 공유하는 것이 합당하다. 앞에서 이미 배웠고 상식적이므로 쉽게 수긍이 갈 것이다. 이 사실을 확인하기 위해 아주 간단한 클래스를 하나 만들어 보자.

  : this
#include <Turboc.h>

class Simple
{
private:
     int value;

public:
     Simple(int avalue) : value(avalue) { }
     void OutValue() {
          printf("value=%d\n",value);
     }
};

void main()
{
     Simple A(1), B(2);
     A.OutValue();
     B.OutValue();
}

클래스 구조가 간단하기 때문에 이름은 Simple이라고 붙였다. Simple 클래스는 정수형의 멤버 변수 value를 가지며 생성자는 전달받은 인수로 value를 초기화한다. 유일한 멤버 함수인 OutValue는 value의 값을 확인하기 위해 단순히 화면에 출력하기만 한다. 변수와 함수를 각각 하나씩만 가지는 초간단 클래스인데 이 클래스로 멤버 함수의 동작에 대해 고찰해 보자. main에서는 Simple 클래스의 객체 A와 B를 생성했는데 이때 메모리에 두 인스턴스가 생성된 모양을 그려 보면 다음과 같을 것이다.
value 멤버는 고유의 값을 저장해야 하므로 A, B 객체별로 하나씩 할당되어 있고 OutValue는 두 객체가 공유하고 있는 상황이다. 이 상태에서 sizeof(A)를 출력해 보면 결과는 4로 출력된다. main에서 두 인스턴스를 생성한 후 A, B에 대해 OutValue 함수를 호출했는데 출력 결과는 다음과 같다.

value=1
value=2

A의 value는 1로 초기화되었고 B의 value는 2로 초기화되었으므로 OutValue는 1과 2라는 결과값을 순서대로 출력할 것이다. A, B 객체에 대해 각각 OutValue를 호출했으므로 이 결과는 너무 너무 당연하고 더 이상 분석해 볼 가치가 없어 보인다. 지금까지 배웠던 문법으로 이 결과를 충분히 설명할 수 있을 것이다. 그러나 과연 그런지 좀 더 깊이 들어가 보자.
문제는 OutValue 함수가 자신을 호출한 객체를 어떻게 아는가 하는 점이다. 이 함수의 본체 코드에서 value라는 멤버를 이름만으로 참조하고 있는데 이 멤버가 과연 누구의 value인지 어떻게 판단하는가? 함수는 호출원으로부터 정보를 전달받을 때 인수를 사용하는데 OutValue 함수의 원형을 보면 어떠한 인수도 받아들이지 않는다. 입력값인 인수가 없으면 함수의 동작은 항상 같을 수밖에 없음에도 불구하고 OutValue는 호출한 객체에 따라 다른 동작을 할 수 있다.
그 이유를 설명하자면 main에서 OutValue를 호출할 때 어떤 객체 소속의 멤버 함수를 호출할 것인지 소속 객체를 함수 이름앞에 밝혔기 때문이다. 코드를 보면 A.OutValue();B.OutValue(); 식으로 작성되어 있어 사람이 눈으로 보기에도 두 호출문은 구분된다. 그러나 함수의 입장에서는 자신을 호출한 문장앞에 붙어 있는 A. , B. 따위의 객체 이름을 읽을 수 없으며 인수로 전달되는 값만이 의미가 있다.
호출한 객체에 대한 정보가 함수의 인수로 전달되지 않으면 본체는 여전히 호출한 객체를 알 방법이 없다. 난수나 시간을 참조하는 특수한 함수를 제외하고 입력값이 일정하면 출력값이 달라질 수 없는 것은 함수의 본질적인 특성이다. 그래서 멤버 함수가 호출한 객체를 구분하기 위해서는 결국 객체에 대한 정보가 함수의 인수로 전달되어야 하며 C++ 컴파일러는 호출문의 객체를 함수의 인수로 몰래 전달한다. A.OutValue() 호출문은 컴파일러에 의해 다음과 같이 재해석된다.
호출한 객체를 멤버 함수로 전달하는 방법은 컴파일러마다 조금씩 다르다. CX 레지스터를 사용하는 경우도 있고 첫 번째 인수로 전달하는 컴파일러도 있다. 어쨌든 중요한 것은 멤버 함수를 호출할 때 호출한 객체의 정보가 함수에게 암시적으로 전달된다는 것이다. 그래서 멤버 함수는 호출한 객체별로 다른 동작을 할 수 있고 복수 개의 객체가 멤버 함수를 공유할 수도 있게 된다.
우리 눈에 명시적으로 보이지는 않지만 OutValue 함수는 자신을 호출한 객체의 번지를 인수로 전달받는다. 이때 전달받은 숨겨진 인수를 this라고 하는데 호출한 객체의 번지를 가리키는 포인터 상수이다. 일반적으로 Class형의 멤버 함수들은 Class * const this를 받아들이며 this로부터 객체의 고유한 멤버를 액세스할 수 있다. 위 예제의 OutValue 함수는 컴파일러에 의해 다음과 같이 재해석된다.

void OutValue(Simple * const this) {
     printf("value=%d\n",this->value);
}

멤버 함수의 본체에서 멤버를 참조하는 모든 문장 앞에는 this->가 암시적으로 적용된다. 그래서 멤버 변수 mem에 대한 참조문은 this->mem으로 해석되고 멤버 함수 func() 호출문은 this->func()를 호출하며 실제로 이렇게 써도 똑같이 동작한다. A.OutValue() 문에 의해 호출된 OutValue 함수에서 this는 &A의 값을 가지며 따라서 this->value는 A 객체의 value 멤버가 된다. 마찬가지로 B.OutValue() 호출시 this는 &B가 되며 this->value는 B 객체의 value멤버를 의미한다.
멤버 함수의 인수가 n개이면 실제로 이 함수가 호출될 때는 this가 하나 더 전달되므로 항상 n+1개의 인수가 전달되는 셈이다.

멤버 함수
실제 모양
func()
func(this)
func(int a)
func(this,int a)
func(char *p, double d)
func(this,char *p, double d)

멤버 함수가 객체들에 의해 공유되려면 호출한 객체를 구분해야 하고 그러기 위해서는 호출 객체의 정보를 함수의 인수로 전달해야 하는데 이 처리를 개발자가 직접 해야 한다면 무척 귀찮을 것이다. 만약 이런 식이라면 구조체와 함수를 따로 만들고 함수를 호출할 때마다 구조체를 인수로 넘기는 것과 같으므로 캡슐화해 놓은 의미가 없어지는 셈이다. 이 작업은 모든 멤버 함수에 공통적으로 필요한 조치이며 획일적이기 때문에 개발자가 별도로 명시하지 않아도 컴파일러가 알아서 자동으로 하도록 되어 있다.
예외가 없으므로 개발자가 개입할 필요가 없으며 기계가 이 작업을 대신 할 수 있는 것이다. 이처럼 멤버 함수 호출시에 this를 암시적으로 전달하는 호출 규약을 thiscall이라고 하는데 모든 멤버 함수에 자동으로 적용된다. 단, 가변 인수를 취하는 멤버 함수는 cdecl 호출 규약을 사용한다. C++ 컴파일러가 멤버 함수를 처리하는 방식을 C언어에서 그대로 따라하면 C로도 객체를 흉내낼 수 있을 것이다.
this에 대한 모든 관리는 컴파일러가 알아서 처리한다. 멤버 함수를 호출할 때마다 this를 전달하고 본체의 모든 멤버 참조문앞에 this->를 일일이 붙인다. 그렇다면 개발자가 this의 존재를 굳이 알아야 하는 이유는 뭘까? 멤버 함수의 본체에서 this 키워드는 지금 이 함수를 실행하고 있는 객체 그 자체를 표현하는 1인칭 대명사이다. 멤버 함수가 객체를 칭할 필요가 있을 때는 this를 직접 사용해야 한다. 다음 함수를 Simple 클래스에 추가해 보자.

Simple *FindBig(Simple *Other) {
     if (Other->value > value) {
          return Other;
     } else {
          return this;
     }
}

이 함수는 인수로 전달된 Other 객체와 자신을 비교하여 더 큰 값을 가진 객체의 포인터를 리턴한다. 객체의 대소 비교 기준은 클래스마다 다르겠지만 Simple 클래스는 value라는 정수값을 가지고 있으므로 이 값을 비교하면 될 것이다. value가 비공개 영역에 선언되어 있으므로 객체끼리의 비교는 외부에서 할 수 없으며 클래스의 멤버 함수가 직접 해야 한다. Other->value와 value(this->value의 간략한 표현)를 비교해 보고 Other->value의 값이 더 크면 비교 결과로 Other를 리턴한다.
만약 그렇지 않다면 Other보다 자신이 더 크므로 자신을 리턴해야 하는데 이때 this 키워드가 필요하다. this가 자신을 가리키는 포인터 상수이므로 this를 리턴하면 된다. this가 없다면 이 멤버 함수를 호출한 객체를 칭할 방법이 없다. 다음 코드는 두 객체중 큰 값을 가진 객체를 찾아 이 객체의 value를 출력하는데 두 코드 모두 결과는 같다. A가 B와 비교하나 B가 A와 비교하나 마찬가지다.

A.FindBig(&B)->OutValue();
B.FindBig(&A)->OutValue();

객체는 보통 단순 타입보다는 크기 때문에 함수의 인수로 전달할 때는 포인터나 레퍼런스를 사용하는 것이 유리하다. FindBig 함수를 다음과 같이 수정해도 동일하게 동작한다.

Simple &FindBig(Simple &Other) {
     if (Other.value > value) {
          return Other;
     } else {
          return *this;
     }
}

자기 자신에 대한 레퍼런스를 리턴할 때는 *this 표현식을 사용한다. 함수를 이렇게 수정한 후 호출부에서 비교 대상을 포인터가 아닌 객체로 전달하면 된다. 비교 결과 리턴되는 값도 레퍼런스이므로 -> 연산자 대신 . 연산자로 OutValue를 호출하면 될 것이다. 호출부를 A.FindBig(B) .OutValue();로 수정하면 동일하게 동작한다. 꼭 원한다면 포인터나 레퍼런스가 아닌 객체 자체를 인수로 넘기고 리턴을 받을 때도 객체를 돌려 받을 수 있는데 FindBig 함수의 원형을 Simple FindBig(Simple Other)로 고치기만 하면 된다. 이 방법은 속도에도 불리하고 인수 전달 과정에서 여러 가지 부작용이 발생할 수도 있으므로 권장되지 않는다.
객체가 자신을 스스로 삭제하고자 할 때도 this 키워드를 쓴다. 치명적인 에러나 프로그램 종료시 스스로 자살하고자 할 때 delete this; 한 줄이면 가볍게 생을 마감할 수 있다. 물론 이 객체는 동적으로 할당된 객체여야만 한다. delete this;를 말로 해석해 보면 "나 좀 죽여줘"가 되는데 여기서 '나'라는 1인칭을 칭하기 위해 this가 필요한 것이다. 자신을 스스로 삭제하는 delete this; 문장은 자동화된 객체 관리를 위해 종종 사용된다.
객체의 멤버 함수에서 자신을 칭할 필요는 늘상 있다. memset(this, 0, sizeof(*this))는 자신의 모든 멤버를 0으로 리셋하며 func(this)는 func 전역 함수로 자신을 전달한다. 자바나 비주얼 베이직 등의 다른 언어에서는 객체가 자신을 칭할 때 self, me 등의 키워드를 사용하는데 이 키워드는 C++의 this와 용도가 같다. 다른 언어의 키워드를 보면 this의 의미를 좀 더 분명히 알 수 있다. 멤버 함수의 지역변수와 멤버 변수와의 이름 충돌이 발생했을 때도 this를 사용한다. 예를 들어 다음 클래스 선언을 보자.

class Some
{
private:
     int i;   

public:
     void func() {
          int i;
          i=3;
     }
};

멤버 함수 func에서 3을 대입하는 i는 멤버 변수 i가 아니라 지역변수 i이다. 멤버 변수와 같은 이름을 가진 지역변수가 선언되어 있을 때 지역변수 대신 멤버 변수를 액세스하고 싶다면 Some::i 또는 this->i로 소속을 명확하게 밝혀야 한다. 물론 불가피하게 충돌이 발생할 때 이렇게 해결할 수 있다는 것이지 이 경우는 지역변수의 이름을 멤버 변수와 다른 것으로 바꾸는 것이 더 바람직하다.

27-3-나.정적 멤버 변수

정적 멤버 변수는 클래스의 바깥에 선언되어 있지만 클래스에 속하며 객체별로 할당되지 않고 모든 객체가 공유하는 멤버이다. 개별 객체와는 직접적인 상관이 없고 객체 전체를 대표하는 클래스와 관련된 정보를 저장하는 좀 특수한 멤버이다. 정의가 좀 복잡해 보이는데 이런 멤버 변수가 왜 필요한지 문제 하나를 풀어 보면서 차근 차근히 생각해 보자.
다음 예제는 정적 멤버 변수의 필요성과 동작을 설명하기 위한 가장 전형적인 예제이다. Count라는 이름의 클래스를 선언하여 사용하는데 main에서 Count형 객체가 몇 개나 생성되었는지 그 개수를 관리하고자 한다. 첫 번째 예제는 다음과 같다.

  : ObjCount
#include <Turboc.h>

int Num=0;
class Count
{
private:
     int Value;

public:
     Count() { Num++; }
     ~Count() { Num--; }
     void OutNum() {
          printf("현재 객체 개수 = %d\n",Num);
     }
};

void main()
{
     Count C,*pC;
     C.OutNum();
     pC=new Count;
     pC->OutNum();
     delete pC;
     C.OutNum();
     printf("크기 = %d\n",sizeof(C));
}

Count 클래스에는 객체의 고유한 정보를 저장하기 위해 Value라는 멤버 변수가 선언되어 있다. 이 예제에서는 Value를 사용하지 않지만 나중에 객체 크기를 점검해 보기 위한 용도로 포함된 것이다. 생성된 객체의 개수를 저장하기 위해 프로그램 선두에 전역변수 Num을 선언하고 0으로 초기화했다. Count 클래스의 생성자에서 Num을 1 증가시키고 파괴자에서 Num을 1감소시킴으로써 이 변수는 생성된 객체의 수를 정확하게 기억한다.
OutNum 멤버 함수는 단순히 Num 전역변수의 값을 화면으로 출력하여 현재 몇 개의 객체가 만들어져 있는지를 확인시켜 준다. main에서 Count 클래스의 객체를 정적으로 선언하기도 하고 동적으로 생성하기도 하면서 OutNum을 호출했다. 실행 결과는 다음과 같다.

현재 객체 개수 = 1
현재 객체 개수 = 2
현재 객체 개수 = 1
크기 = 4

프로그램이 실행된 직후에 전역변수 Num은 0으로 초기화될 것이다. main 함수가 시작되기 전에 지역 객체 C가 생성되며 이때 C의 생성자에서 Num을 1증가시키므로 Num은 1이 된다. new 연산자로 Count 클래스의 객체를 동적으로 생성하면 이때도 생성자가 호출되어 Num은 2가 되며 delete 연산자로 이 객체를 파괴하면 파괴자가 호출되어 Num은 다시 1이 될 것이다. main 함수가 종료되면 지역 객체 C가 파괴되므로 Num은 최초의 상태인 0으로 돌아간다.
정적이든 동적이든 객체가 생성, 파괴될 때는 생성자와 파괴자가 호출되며 이 함수들이 Num을 관리하고 있으므로 Num은 항상 생성된 객체의 개수를 정확하게 유지한다. 디버거로 한 줄씩 실행해 가면서 Num 변수의 값을 관찰해 보면 이 변수가 생성된 객체수를 정확하게 세고 있음을 확인할 수 있다. 애초에 원하는 목적은 달성했지만 이 예제는 전혀 객체 지향적이지 못하다. 전역변수는 세 가지 면에서 문제가 있다.

① 클래스와 관련된 중요한 정보를 왜 클래스 바깥의 전역변수로 선언하는가가 일단 불만이다. 자신의 정보를 완전히 캡슐화하지 못했으므로 이 클래스는 독립적인 부품으로 동작할 수 없다.
② 전역변수가 있어야만 동작할 수 있으므로 재사용하고자 할 경우 항상 전역변수와 함께 배포해야 한다. 클래스만 배포해서는 제대로 동작하지 않는다.
 전역변수는 은폐할 방법이 없기 때문에 외부에서 누구나 마음대로 집적거릴 수 있다. 어떤 코드에서 고의든 실수든 Num=1234; 라고 대입해 버리면 생성된 객체수가 1234개라고 오판하게 될 것이다.

객체가 외부의 전역변수와 연관되는 것은 캡슐화, 정보 은폐, 추상성 등 모든 OOP 원칙에 맞지 않다. 전역변수는 심지어 구조적 프로그래밍 기법에서도 사용을 꺼리는 대상인데 하물며 객체 지향 프로그래밍 기법에서야 오죽하겠는가? 일단 문제는 해결했지만 객체 지향적인 요건에 맞추려면 무슨 수를 쓰든지 Num을 Count 클래스안에 캡슐화해야 한다. 다음과 같이 Count 클래스를 수정해 보자.

class Count
{
private:
     int Value;
    int Num;

public:
     Count() { Num++; }
     ~Count() { Num--; }
     void OutNum() {
          printf("현재 객체 개수 = %d\n",Num);
     }
};

Num을 Count클래스의 멤버 변수로 포함시켰으며 생성자에서 증가, 파괴자에서 감소시키고 있다. 일단 클래스의 멤버로 포함시키는데는 성공했지만 막상 실행해 보면 이 예제는 제대로 동작하지 않으며 문제가 아주 많다. 적어도 다음 두 가지 큰 문제가 있다.
우선 Num은 전혀 초기화되지 않으므로 쓰레기값을 가지게 되며 어느 누구도 Num을 초기화할 수 없다. Num이 개수를 저장하려면 최초 0으로 초기화되어야 하는데 초기화할 주체가 없는 것이다. 언뜻 생성자에서 Num을 초기화할 수 있을 것 같지만 이건 말도 안된다. 객체의 개수를 헤아리는 Num을 객체가 생성될 때마다 0으로 만들어 버린다면 이 값은 결코 0보다 커질 수 없다. 누군가가 0으로 초기화해 놓고 생성자는 증가, 파괴자는 감소만 해야 개수가 제대로 유지되는데 초기화해 줄 적절한 "누구"를 도저히 찾을 수 없는 것이다.
또 다른 문제는 Num을 객체마다 개별적으로 가진다는 점이다. C나 pC 객체는 모두 각각의 Num을 가져 필요없는 메모리를 낭비할 뿐만 아니라 자신과 똑같은 타입의 객체가 몇 개나 있는지를 자신이 가진다는 것도 논리적으로 합당하지 않다. 도대체 어떤 객체가 가진 Num이 진짜 개수인지 판단하기도 어렵다. Num은 객체 자체의 정보가 아니라 객체들을 관리하는 값이며 따라서 객체보다는 더 상위의 개념인 클래스에 포함되어야 한다. 그래야 Num이 오직 하나만 존재하게 된다.
이 문제를 풀려면 Num은 클래스의 멤버이면서 클래스로부터 생성되는 모든 객체가 공유하는 변수여야 한다. 이것이 바로 정적 멤버 변수의 정의이며 이 문제를 풀 수 있는 유일한 해결책이다. Count 클래스를 다음과 같이 한 번 더 수정해 보자.

class Count
{
private:
     int Value;
    static int Num;

public:
     Count() { Num++; }
     ~Count() { Num--; }
     void OutNum() {
          printf("현재 객체 개수 = %d\n",Num);
     }
};
int Count::Num=0;

Num은 여전히 Count 클래스 내부에 선언되어 있되 static 키워드를 붙여 정적 멤버임을 명시했다. 클래스 선언문에 있는 int Num; 선언은 어디까지나 이 멤버가 Count의 멤버라는 것을 알릴 뿐이지 메모리를 할당하지는 않는다. 그래서 정적 멤버 변수는 외부에서 별도로 선언및 초기화해야 한다. Count 클래스 선언문 뒤에 Num 변수를 다시 정의했는데 이때 반드시 어떤 클래스 소속인지 :: 연산자와 함께 소속을 밝혀야 한다.
클래스 내부의 선언은 Num이 Count 클래스 소속이며 정수형의 정적 멤버 변수라는 것을 밝히고 외부의 정의는 Count에 속한 정적 멤버 Num을 생성하고 0으로 초기화한다는 뜻이다. 외부 정의에 의해 메모리가 할당되며 이때 초기값을 줄 수 있다. 관습에 따라 클래스를 헤더 파일에 선언하고 멤버 함수를 구현 파일에 작성할 때 정적 멤버에 대한 외부 정의는 통상 클래스 구현 파일(*.cpp)에 작성한다. Class에 속한 Type 타입의 정적 멤버 smem를 선언하는 일반적인 방법은 다음과 같다.
헤더 파일의 클래스 선언부에 정적 멤버 변수에 대한 내부 선언이 있고 구현 파일에 정적 멤버 변수에 대한 외부 정의 및 초기값 지정문이 온다. 헤더 파일에 외부 정의를 둔다면 헤더 파일이 두 번 인클루드될 때 이중 정의되므로 에러로 처리될 것이다. 이 예제는 단일 모듈이기 때문에 편의상 클래스 선언 바로 다음에 정적 멤버의 외부 정의를 했다.
이렇게 선언하면 Num은 Count 클래스에 소속되며 외부 정의에서 지정한 초기값으로 딱 한 번만 초기화된다. Count형의 객체 A,B,C가 생성되었다면 각 객체는 자신의 고유한 멤버 Value를 개별적으로 가지며 정적 멤버 변수 Num은 모든 객체가 공유한다. 그래서 각 객체의 생성자에서 증가, 파괴자에서 감소하는 대상은 공유된 변수 Num이며 한 변수값을 모든 객체가 같이 관리하므로 Num은 생성된 객체의 정확한 개수를 유지할 수 있다.
정적 멤버 변수는 객체와 논리적으로 연결되어 있지만 객체 내부에 있지는 않다. 정적 멤버 변수를 소유하는 주체는 객체가 아니라 클래스이다. 그래서 객체 크기에 정적 멤버의 크기는 포함되지 않으며 sizeof(C) = sizeof(Count)는 객체의 고유 멤버 Value의 크기값인 4가 된다.
정적 멤버의 액세스 지정은 일반 멤버와 똑같은 방식으로 적용된다. 위 예제의 경우 Num은 private 영역에 선언되었으므로 외부에서 액세스할 수 없다. main에서 이 값을 함부로 변경할 수 없으며 오로지 Count 클래스의 멤버 함수(이 예제의 경우 생성자와 파괴자, OutNum)에서만 Num값을 액세스할 수 있다. 정적 멤버도 분명히 클래스 소속이므로 클래스에 속한 멤버 함수들은 액세스 속성에 상관없이 이름만으로 이 멤버를 참조할 수 있다.
단 외부에서 정적 멤버 변수를 정의할 때는 예외적으로 액세스 속성에 상관없이 초기값을 줄 수 있다. 초기식은 대입과는 다르므로 액세스 속성의 영향을 받지 않는다. 정적 멤버 변수를 외부에서도 참조할 수 있도록 공개하려면 클래스 선언부의 public영역에 선언해야 한다. 외부에서 정적 멤버를 액세스할 때는 반드시 소속을 밝혀야 하는데 두 가지 방법으로 소속을 밝힐 수 있다.

Count C;
Count::Num=3;            // 클래스 소속
C.Num++;                        // 객체 소속

객체의 멤버들은 통상 객체.멤버 식으로 소속을 밝히지만 정적 멤버 변수는 객체와 직접적인 연관이 없기 때문에 보통 클래스의 이름과 범위 연산자로 소속을 밝힌다. Count::Num이라는 표현은 Count 클래스에 속한 정적 멤버 변수 Num이라는 뜻이다. 그래서 객체가 전혀 생성되지 않은 상태에서도 클래스의 이름만으로 정적 멤버를 참조할 수 있다. 만약 main에서 최초 Num을 10으로 대입하고 싶다면 객체가 생성되기 전에 Class::Num=10; 으로 대입하면 된다.
원한다면 C.Num처럼 객체.멤버 식으로 객체의 소속인 것처럼 표현할 수도 있다. 이 때 C객체의 이름은 별다른 의미는 없으며 C객체가 소속된 클래스를 밝히는 역할만 한다. 정적 멤버에 대해 객체의 소속으로 액세스하는 것은 일단 가능하지만 일반적이지 않으며 바람직하지도 않다. 정적 멤버는 논리적으로 클래스 소속이므로 가급적이면 클래스::멤버 식으로 액세스하는 것이 합당하다. 단, 어디까지나 논리적으로 소속되는 것 뿐이지 클래스는 실체가 아니므로 클래스 안에 정적 멤버가 배치되는 것은 아니다.

static 멤버는 객체 소속이 아니고 클래스 소속이다. 객체 없이도 사용이 가능하다. 


27-3-다.정적 멤버 함수

정적 멤버 함수의 개념도 정적 멤버 변수의 경우와 비슷하다. 객체와 직접적으로 연관된다기보다는 클래스와 연관되며 생성된 객체가 하나도 없더라도 클래스의 이름만으로 호출할 수 있다. 일반 멤버 함수는 객체를 먼저 생성한 후 obj.func() 형식으로 호출한 객체에 대해 어떤 작업을 한다. 이에 비해 정적 멤버 함수는 Class::func() 형식으로 호출하며 클래스 전체에 대한 전반적인 작업을 한다. 주로 정적 멤버 변수를 조작하거나 이 클래스에 속한 모든 객체를 위한 어떤 처리를 한다.
정적 멤버 함수를 선언하는 방법은 정적 멤버 변수와 동일하다. 클래스 선언부의 함수 원형앞에 static이라는 키워드만 붙이면 된다. 정적 멤버 함수의 본체는 클래스 선언부에 인라인 형식으로 작성할 수도 있고 아니면 외부에 따로 정의할 수도 있는데 외부에 작성할 때 static 키워드는 생략한다. 다음 예제는 앞에서 만든 객체 개수를 세는 예제를 조금 수정해 본 것이다.

  : ObjCount2
#include <Turboc.h>

class Count
{
private:
     int Value;
     static int Num;

public:
     Count() { Num++; }
     ~Count() { Num--; }
     static void InitNum() {
          Num=0;
     }
     static void OutNum() {
          printf("현재 객체 개수 = %d\n",Num);
     }
};
int Count::Num;

void main()
{
     Count::InitNum();
     Count::OutNum();
     Count C,*pC;
     C.OutNum();
     pC=new Count;
     pC->OutNum();
     delete pC;
     pC->OutNum();
     printf("크기=%d\n",sizeof(C));
}

정적 멤버 변수 Num을 정의할 때 0으로 초기화하지 않았으며 이 작업은 새로 추가된 정적 멤버 함수 InitNum이 담당한다. InitNum은 정적 멤버 함수이므로 Count 클래스의 객체가 전혀 없는 상태에서도 호출될 수 있다. main에서 Count::InitNum()을 먼저 호출하여 Num을 0으로 초기화하였다. 변수를 초기화하는 별도의 함수를 만들었으므로 원한다면 실행중에 언제든지 이 함수를 호출하여 Num을 0으로 리셋할 수도 있다.
객체의 개수를 출력하는 OutNum 함수도 개별 객체에 대한 함수가 아니기 때문에 정적 멤버 함수로 수정할 수 있다. OutNum 함수가 객체로부터 호출되지 않으므로 이제 객체가 전혀 생성되지 않은 상태, 즉 Num이 0인 상태에 대한 출력도 가능하다. 정적 멤버 함수가 아니면 이런 호출은 불가능하다. main에서 지역 객체 C를 생성하기 전에 Count::OutNum()을 호출했는데 이 호출문은 0을 출력하며 아직 생성된 객체가 없다는 것을 보여 준다.
C 객체를 생성한 후 C.OutNum()을 호출하면 1이 출력되고 pC객체를 동적 생성한 후 pC->OutNum()을 호출하면 2가 출력된다. 이 두 호출의 예처럼 정적 멤버 함수를 객체의 이름으로 호출할 수도 있지만 이때 객체의 이름은 아무런 의미가 없으며 컴파일러는 객체가 소속된 클래스의 정보만 사용한다. 편의상 C.OutNum(), pC->OutNum(); 이라는 표현을 허용할 뿐이지 이 호출은 실제로 Count::OutNum()으로 컴파일된다는 얘기다.
그래서 delete pC;로 pC 객체를 해제한 후에도 pC->OutNum()이라는 호출이 정상적으로 동작한다. 컴파일러는 pC에 실제로 객체가 생성되어 있는지를 볼 필요도 없으며 pC가 Count *형이라는 것만 참조할 뿐이다. 심지어 main의 4번째 줄에 pC가 할당되기도 전인 C.OutNum()을 pC->OutNum()으로 바꿔도 잘 동작한다. 이걸 보면 컴파일러가 포인터의 타입만으로 호출할 함수를 결정한다는 것을 알 수 있다.

     Count *pC;
     pC->OutNum();               // 생성 전에도 호출 가능
     pC=new Count;
     pC->OutNum();               // 생성 후에도 호출 가능
     delete pC;
     pC->OutNum();               // 파괴된 후에도 호출 가능

정적 멤버 함수는 특정한 객체에 의해 호출되는 것이 아니므로 숨겨진 인수 this가 전달되지 않는다. 클래스에 대한 작업을 하기 때문에 어떤 객체가 자신을 호출했는지 구분할 필요가 없으며 따라서 호출한 객체에 대한 정보도 필요없다. 그래서 정적 멤버 함수는 정적 멤버만 액세스할 수 있으며 일반 멤버(비정적 멤버)는 참조할 수 없다. 왜냐하면 일반 멤버 앞에는 암시적으로 this->가 붙는데 정적 멤버 함수는 this를 전달받지 않기 때문이다. 정적 멤버 함수인 InitNum에서 비정적 멤버인 Value를 참조하는 것은 불가능하다.

static void InitNum() {
     Num=0;
     Value=5;
}

이 코드를 컴파일하면 정적 멤버 함수에서 Value를 불법으로 참조했다는 에러 메시지가 출력된다. InitNum의 본체에서 Value를 칭하면 누구의 Value인지를 판단할 수 없다. 또한 정적 멤버 함수는 생성된 객체가 전혀 없어도 호출할 수 있는데 이때 Value는 아예 존재하지도 않는다. 비정적 멤버 함수도 호출할 수 없으며 오로지 정적 멤버만 참조할 수 있다.




27-3-라.정적 멤버의 활용

정적 멤버는 필요한 모든 것을 객체 내에 둔다는 캡슐화 원칙에 위배되는 것처럼 보이기도 하고 정적 멤버 변수의 경우 선언과 정의가 두 번 나타나기 때문에 문법적으로도 조금 어색해 보인다. 그러나 물리적으로는 객체 바깥에 선언되어 있지만 논리적으로 클래스에 속해 있고 액세스 지정에 의해 정보 은폐도 가능하므로 캡슐화 위반은 아니다. 정적 멤버의 개념이 꼭 필요한 이유는 여러 가지 경우에 이것이 굉장히 유용하기 때문이다. 정적 멤버를 훌륭하게 활용하는 몇 가지 예를 보도록 하자.
 단 한 번만 해야 하는 전역 자원의 초기화
데이터 베이스 연결이나 네트워크 연결, 윈도우 클래스 등록 등과 같이 단 한 번만 하면 되는 초기화는 정적 멤버 함수에서 하고 그 결과를 정적 멤버 변수에 저장한다. 이런 전역 초기화는 일반적으로 두 번 할 필요도 없고 두 번 초기화하는 것이 허용되지도 않는다. 그래서 객체별로 초기화해서는 안되며 클래스 수준에서 딱 한 번만 초기화하고 그 결과는 모든 객체가 공유한다.
데이터 베이스에서 질의를 하는 클래스를 예로 들어 보자. 질의를 하기 위해서는 먼저 정보가 저장되어 있는 DB 서버에 연결하는 인증 절차를 거쳐야 한다. 연결이나 인증이나 두 번 한다는 것은 의미가 없으므로 한 번만 연결하고 이후부터는 모든 질의 객체가 이 연결을 공유하면 될 것이다. 다음은 질의 클래스의 가상 코드이다. 실제 DB 접속을 하려면 복잡하기 때문에 가상 코드를 예로 들었다.

  : GlobalInit
#include <Turboc.h>

class DBQuery
{
private:
     static HANDLE hCon;
     int nResult;

public:
     DBQuery() { };
     static void DBConnect(char *Server, char *ID, char *Pass);
     static void DBDisConnect();
     BOOL RunQuery(char *SQL);
     // ....
};
HANDLE DBQuery::hCon;

void DBQuery::DBConnect(char *Server, char *ID, char *Pass)
{
     // 여기서 DB 서버에 접속한다.
     // hCon = 접속 핸들
}

void DBQuery::DBDisConnect()
{
     // 접속을 해제한다.
     // hCon=NULL;
}

BOOL DBQuery::RunQuery(char *SQL)
{
     // Query(hCon,SQL);
     return TRUE;
}

void main()
{
     DBQuery::DBConnect("Secret","Adult","doemfdmsrkfk");
     DBQuery Q1,Q2,Q3;

     // 필요한 DB 질의를 한다.
     // Q1.RunQuery("select * from tblBuja where 나랑 친한 사람");

     DBQuery::DBDisConnect();
}

DB 서버와의 연결은 DBConnect 정적 멤버 함수가 처리한다. 이 함수는 서버 이름, ID, 비밀 번호를 인수로 전달받아 DB 서버와 연결 및 인증을 하고 연결 결과는 정적 멤버 변수 hCon에 저장한다. 정적 멤버 함수는 정적 멤버 변수를 액세스할 수 있으므로 DBConnect에서는 hCon을 당연히 액세스할 수 있다. 연결을 해제하는 작업도 역시 정적 멤버 함수인 DBDisConnect에서 처리한다.
main 함수에서는 DBQuery 객체를 생성하기 전에 DBConnect 함수를 호출해서 DB 서버에 연결하며 이로서 DBQuery 객체가 질의를 할 수 있는 환경을 만들어 놓는다. 이후 생성되는 DBQuery 객체 Q1, Q2, Q3의 RunQuery 함수는 정적 멤버 hCon에 저장된 연결 핸들로 원하는 질의를 처리할 것이다. RunQuery 함수는 정적 멤버는 아니지만 공유된 연결 핸들 hCon은 얼마든지 액세스할 수 있다.
질의를 마치고 프로그램을 종료하기 전에 DBDisConnect 정적 멤버 함수를 호출하여 DB 서버와의 연결을 끊고 필요한 뒷처리를 한다. DB 서버에 연결하는 과정은 굉장히 느리고 리소스를 많이 차지하기 때문에 객체별로 따로 연결하지 않고 딱 한 번만 연결해야 한다. 이럴 때 사용하는 것이 바로 정적 멤버이다. 물론 각 객체별로 따로 연결 핸들을 가지고 생성자에서 접속, 파괴자에서 해제하는 것도 가능하다.
그러나 이렇게 되면 매 객체가 생성될 때마다 접속해야 하므로 느리고 용량 낭비가 심하다. 뿐만 아니라 어떤 서버는 클라이언트당 하나의 접속만 인정하기도 하고 접속수별로 라이센스 비용을 지불해야 하는 경우도 있다. SQL 서버나 오라클같은 대형 RDB 시스템은 최대 동시 접속수에 따라 가격 차이가 무척 심하다.
  읽기 전용 자원의 초기화
객체는 스스로 동작할 수 있지만 때로는 외부의 환경이나 자원에 대한 정보를 필요로 한다. 예를 들어 정확한 출력을 위해 화면 크기를 알아야 할 경우도 있고 장식을 위해 외부에 정의된 예쁜 비트맵 리소스를 읽어야 하는 경우도 있다. 이런 정보들은 일반적으로 한 번 읽어서 여러 번 사용할 수 있는 읽기 전용이기 때문에 객체별로 이 값을 일일이 조사하고 따로 유지할 필요가 없다. 다음 예제는 화면 크기에 대한 정보를 정적 멤버로 가진다.

  : ReadOnlyInit
#include <Turboc.h>

class Shape
{
private:
     int ShapeType;
     RECT ShapeArea;
     COLORREF Color;

public:
     static int scrx,scry;
     static void GetScreenSize();
};

int Shape::scrx;
int Shape::scry;

void Shape::GetScreenSize()
{
     scrx=GetSystemMetrics(SM_CXSCREEN);
     scry=GetSystemMetrics(SM_CYSCREEN);
}

void main()
{
     Shape::GetScreenSize();
     Shape C,E,R;
     printf("화면 크기 = (%d,%d)\n",Shape::scrx,Shape::scry);
}

Shape 클래스는 화면에 도형을 그리는 클래스인데 이 클래스의 객체들은 공통적으로 현재 화면 크기에 대한 정보를 필요로 한다고 하자. 각 객체별로 scrx, scry를 가지고 생성자에서 일일이 조사할 수도 있지만 이렇게 하면 기억 공간이 낭비되며 실행 시간도 느려진다. 각 객체들은 동일한 화면에서 실행되며 각자가 조사하는 화면 크기가 다르지 않으므로 여러 번 조사할 필요가 전혀 없다.
정적 멤버 변수 scrx, scry를 만들고 이 변수의 값을 초기화하는 정적 멤버 함수 GetScreenSize() 함수를 정의한 후 main에서 객체를 생성하기 전에 딱 한 번만 이 함수를 호출하면 된다. 정적 멤버 함수이므로 생성된 객체가 없어도 호출할 수 있다. 이후 생성되는 모든 Shape 객체는 별도의 조사 과정을 거치지 않고 공유된 scrx, scry 멤버 변수를 읽는 것으로 언제든지 화면 크기를 참조할 수 있다.
이 예제에서는 간략함을 위해 조사하기 쉬운 화면 크기 정보를 사용했는데 때로는 공유 정보가 비트맵이나 멀티 미디어 파일, 대화상자 같은 덩치가 큰 자원일 수도 있다. 각 정보가 읽기 전용이 아니라 객체별로 다른 값을 가져야 하는 경우라면 얘기가 달라지겠지만 일반적으로 장식이나 정보 취득에 사용되는 자원들은 읽기 전용이며 실행중에 값이 변하지 않는다. 이런 자원들은 반드시 정적 멤버로 관리해야 하며 그렇지 않을 경우 속도나 크기면에서 아주 불리해진다.
 모든 객체가 공유해야 하는 정보 관리
중요한 계산을 하는 객체의 경우 계산에 필요한 기준값이 있을 수 있다. 예를 들어 환율이나 이자율 따위는 금융, 재무 처리에 상당히 중요한 기준값으로 작용하며 기준값에 따라 계산 결과가 달라진다. 이런 값들은 프로그램이 동작중일 때도 수시로 변할 수 있지만 일단 정해지면 모든 객체에 일관되게 적용된다. 그래서 개별 객체들이 각자 멤버로 가질 필요가 없으며 정적 멤버로 선언해 두고 공유하면 항상 최신의 기준값을 제공받게 된다. 다음은 환율을 계산하는 Exchange 클래스의 예이다.

  : ShareInfo
#include <Turboc.h>

class Exchange
{
private:
     static double Rate;

public:
     static double GetRate() { return Rate; }
     static void SetRate(double aRate) { Rate=aRate; }
     double DollarToWon(double d) { return d*Rate; }
     double WonToDollar(double w) { return w/Rate; }
};
double Exchange::Rate;

void main()
{
     Exchange::SetRate(1200);
     Exchange A,B;
     printf("1달러는 %.0f원이다.\n",A.DollarToWon(1.0));
     Exchange::SetRate(1150);
     printf("1달러는 %.0f원이다.\n",B.DollarToWon(1.0));
}

정적 멤버 변수 Rate는 Exchange 클래스에 속해 있고 이 클래스의 모든 객체가 같이 참조한다. 누구든지 환율이 필요하면 이 값을 읽을 수 있고 또한 변경할 수 있어 관리하기가 편리하다. 만약 객체별로 환율을 따로 가지면 객체를 초기화할 때마다 현재의 기준 환율을 전달해야 하며 환율이 변했을 때 생성되어 있는 모든 객체에게 이 사실을 알려야 할 것이다. 현재 생성된 모든 객체의 목록을 유지하는 것은 생각보다 훨씬 어려운 일이다. 하나의 값은 하나의 기억 장소에 두는 것이 가장 바람직하다.
정적 멤버를 쓰는 대신 기준값을 필요로 하는 모든 멤버 함수들이 기준값을 인수로 전달받는 방법도 생각해 볼 수 있다. 하지만 이렇게 되면 클래스 외부에서 별도의 전역변수로 기준값을 저장 및 관리해야 하며 이는 캡슐화에 위배된다. 함수가 호출될 때 최신값을 인수로 제공받으므로 결과는 가장 정확하겠지만 호출할 때마다 인수를 일일이 전달하는 것은 아주 비효율적이다. 예제의 실행 결과는 다음과 같다.

1달러는 1200원이다.
1달러는 1150원이다.

main에서 최초 정적 멤버 함수 SetRate를 호출하여 환율을 1200으로 설정했다. 이 값은 정적 멤버 변수 Rate에 저장되며 이후 생성되는 모든 Exchange 객체는 이 값을 공유한다. 중간에 환율이 변경되었다면 Exchange::SetRate() 함수로 새 기준값을 Rate에 설정하여 모든 객체들이 다음 계산에 이 값을 사용하도록 한다.



27-4.상수 멤버

27-4-가.상수 멤버

상수 멤버는 한 번 값이 정해지면 변경될 수 없는 멤버이다. 클래스 전체에서 참조하는 중요한 상수가 있다면 이를 상수 멤버로 정의하여 클래스에 포함시킬 수 있다. 예를 들어 수학 계산을 하는 클래스에서 원주율을 자주 사용한다면 다음과 같이 상수 멤버를 정의한다.

  : ConstMember
#include <Turboc.h>

class MathCalc
{
private:
     const double pie;

public:
     MathCalc(double apie) : pie(apie) { }
     void DoCalc(double r) {
          printf("반지름 %.2f인 원의 둘레 = %.2f\n",r,r*2*pie);
     }
};

void main()
{
     MathCalc M(3.1416);
     M.DoCalc(5);
}

원주율을 정의하는 값을 pie라는 상수 멤버로 포함시켰다. 3.1416이라는 값을 바로 쓰지 않고 상수 멤버를 사용할 때의 장점은 매크로 상수의 경우와 마찬가지로 값의 의미 파악이 쉽고 수정하기 쉽다는 점이다. 상수는 대입을 받을 수 없기 때문에 반드시 생성자의 초기화 리스트에서 초기화해야 하는데 이는 앞에서 이미 알아본 내용이다. 상수 멤버가 모든 객체에 대해 항상 같은 값을 가진다면 객체를 생성할 때마다 매번 초기화할 필요없이 정적 멤버로 선언한 후 딱 한 번만 초기화할 수도 있다.

class MathCalc
{
private:
    static const double pie;

public:
    MathCalc() { }
     void DoCalc(double r) {
          printf("반지름 %.2f인 원의 둘레 = %.2f\n",r,r*2*pie);
     }
};
const double MathCalc::pie=3.1416;

void main()
{
    MathCalc M;
     M.DoCalc(5);
}

pie 멤버 선언문앞에 static을 붙이면 이 멤버는 클래스내의 모든 멤버가 공유하는 정적 멤버가 된다. 정적 멤버는 클래스 외부에서 다시 한 번 더 정의해야 하며 이때 초기값을 주는데 일반 정적 멤버와는 달리 상수 멤버는 선언할 때 초기값을 반드시 지정해야 한다. pie는 정적이면서도 상수라는 성질이 있어 정의할 때 초기화하지 않으면 다시는 초기화할 기회가 없다. 초기식이 외부 정의로 이동되었으므로 생성자는 더 이상 이 멤버를 초기화하지 않아도 된다.
단 이렇게 정적 상수 멤버로 선언하면 클래스 전체를 통틀어 pie가 하나만 존재하므로 각각의 MathCalc 객체는 모두 같은 상수를 공유하며 객체별로 다른 값을 가질 수 없다. 정적 상수가 아닐 때는 다음과 같이 객체별로 필요한 정밀도에 따라 다른 원주율값을 가질 수도 있다.

MathCalc M1(3.14);
MathCalc M1(3.1416);
MathCalc M1(3.14159265358979);

상수 멤버를 초기화하는 세 번째 방법은 두 번째 방법에 초기식을 같이 지정하는 방법이다. 클래스 선언문내의 멤버 선언문에 아예 초기식을 같이 주는 것이다. 정적 상수 멤버 선언 및 정의 코드는 다음과 같이 좀 더 짧고 간단하게 수정할 수 있다.

class MathCalc
{
private:
    static const double pie=3.1416;
     ....

정적 멤버는 객체에 소속되지 않고 클래스에 소속되므로 클래스를 선언할 때 초기화할 수 있다. 이 방법은 최근에 C++ 표준에 추가된 것이어서 모든 컴파일러가 지원하지는 않는다. gcc는 이 방법을 잘 지원하며 비주얼 C++의 경우 6.0은 이 방법을 지원하지 않고 7.0이후는 지원하되 단, 정수 멤버에 대해서만 초기값을 지정할 수 있다. 비록 최신 C++ 표준에서 허용하기는 하지만 아직까지는 호환성에 불리하므로 외부 정의를 따로 두는 것이 더 바람직하다.
사용하고자 하는 상수가 정수 타입인 경우는 상수 멤버 대신 열거 멤버를 사용할 수도 있다. 열거형 정의 문법에 따라 열거 멤버 다음에 =초기값을 줄 수 있는데 이를 이용하는 것이다. 열거 멤버의 값만 사용하는 것이므로 열거형 타입의 이름은 줄 필요가 없다. 다음의 Some 클래스는 123으로 정의된 Value라는 열거 멤버를 가진다.

class Some
{
public:
     enum { Value=123 };
     ....

열거 멤버는 컴파일러가 컴파일중에만 사용하며 실제로 메모리를 차지하지 않으므로 선언문내에서도 값을 정의할 수 있다. 또는 조금 어울리지 않지만 클래스 선언문내에 #define Value 123같은 매크로 상수 정의문을 두는 것도 가능하다. 열거 멤버나 매크로 상수는 조금 구식이기는 하지만 정적 상수 멤버니 초기화 리스트니 하는 거창한 문법보다 솔직히 제일 속편한 방법이다. 단 열거 멤버는 정수형 상수만 표현할 수 있고 매크로 상수는 프로젝트 전체에 걸쳐 유일한 이름을 주어야 한다는 제약이 있다. 또한 매크로끼리 참조될 때 괄호를 잘 싸야 한다는 것도 항상 주의해야 한다.
정적 상수 멤버는 클래스가 소유하기 때문에 객체별로 값을 따로 가질 수는 없다. 열거형이나 매크로 상수도 마찬가지로 한 번 값이 정해지면 생성되는 모든 객체가 같은 값을 사용하는 수밖에 없다. 상수가 객체별로 다른 값을 가져야 한다면 이때 쓸 수 있는 유일한 방법은 생성자의 초기화 리스트뿐이다.

  : ConstMemberInit
#include <Turboc.h>

class Enemy
{
private:
     const int Speed;

public:
     Enemy(int aSpeed) : Speed(aSpeed) { }
     void Move() {
          printf("%d의 속도로 움직인다.\n",Speed);
     }
};

void main()
{
     Enemy E1(10), E2(20);
     E1.Move();
     E2.Move();
}

Enemy 클래스는 게임의 적군을 표현하는 클래스인데 각 객체별로 고유한 속도를 가지되 한 번 정해진 속도가 객체 내에서 불변이라면 Speed라는 상수 멤버를 선언한다. 그리고 객체가 생성될 때 생성자를 통해 딱 한 번만 초기화한다.



27-4-나.상수 멤버 함수

상수 멤버 함수는 멤버값을 변경할 수 없는 함수이다. 멤버값을 단순히 읽기만 한다면 이 함수는 객체의 상태를 바꾸지 않는다는 의미로 상수 멤버 함수로 지정하는 것이 좋다. 클래스 선언문의 함수 원형 뒤쪽에 const 키워드를 붙이면 상수 멤버 함수가 된다. 함수의 앞쪽에서는 리턴값의 타입을 지정하기 때문에 const를 함수 뒤에 붙이는 좀 별난 표기법을 사용한다.

class Some
{
private:
     int Value;

public:
     int SetValue(int aValue);        // 비상수 멤버 함수
     int GetValue() const;             // 상수 멤버 함수
};

정수형의 Value 변수가 비공개 영역에 선언되어 있고 이 멤버값을 읽고 쓰는 Get/Set 액세스 함수들은 공개 영역에 선언되어 있다. Value를 외부에서 변경하고 싶다면 SetValue 함수를 호출하고 Value를 읽고 싶을 때는 GetValue 함수를 호출한다. 이때 GetValue는 객체의 어떠한 멤버값도 변경하지 않으므로 상수 멤버 함수이며 이 함수 원형 뒤에 const를 붙여 GetValue는 값을 읽기만 한다는 것을 컴파일러에게 확실하게 알려 준다.
상수로 선언된 객체에 대해서는 상수 멤버 함수만 호출할 수 있으며 비상수 멤버 함수는 호출할 수 없다. 왜냐하면 상수 객체는 읽기 전용이므로 어떤 멤버의 값도 변경되어서는 안되기 때문이다. 다음 예제를 보자.

  : ConstFunc
#include <Turboc.h>

class Position
{
private:
     int x,y;
     char ch;

public:
     Position(int ax, int ay, char ach) { x=ax;y=ay;ch=ach; }
     void OutPosition() const { gotoxy(x, y);putch(ch); }
     void MoveTo(int ax, int ay) { x=ax; y=ay; }
};

void main()
{
     Position Here(1,2,'A');
     Here.MoveTo(20,5);
     Here.OutPosition();

     const Position There(3,4,'B');
     There.MoveTo(40,10);           // 에러 발생
     There.OutPosition();
}

문자를 출력하는 OutPosition함수는 값을 읽기만 하므로 const로 선언되어 있고 MoveTo함수는 위치를 옮기기 위해 x, y 멤버의 값을 변경하므로 const가 아니다. 만약 MoveTo를 const로 지정하면 상수를 변경할 수 없다는 에러로 처리된다. 객체의 값을 조금이라도 변경하는 함수는 상수 멤버 함수로 지정하지 말아야 한다. const로 선언된 OutPosition에 x++따위의 코드를 작성하면 상수 멤버 함수가 객체의 상태를 변경하려고 했으므로 역시 에러로 처리될 것이다. 단, 상수 멤버 함수라도 정적 멤버 변수의 값은 변경할 수 있는데 정적 멤버는 객체의 소속이 아니며 객체의 상태를 나타내지도 않기 때문이다.
main의 테스트 코드를 보자. Here는 비상수 객체로 선언되었므로 OutPosition으로 문자를 출력함은 물론 MoveTo로 위치를 옮길 수도 있다. 그러나 There는 상수 객체로 선언되었므로 상수 멤버 함수인 OutPosition만 호출할 수 있으며 MoveTo 호출문은 에러로 처리된다. 이 문장이 에러로 처리되는 이유는 다음 문장이 에러로 처리되는 이유와 동일하다.

const int i=5;
i=8;                   // 에러

상수에 어떤 값을 대입하여 변경할 수 없는 것과 마찬가지로 상수 객체의 상태를 변경하는 함수를 호출하는 것도 불가능하다. 비상수 멤버 함수가 받는 객체 포인터 this는 Position * const 형이며 this 자체는 상수이지만 this가 가리키는 대상은 상수가 아니다. 반면 상수 멤버 함수가 받는 객체 포인터 this는 const Position * const 형이며 this도 상수이고 this가 가리키는 대상도 상수이다. 결국 상수 멤버 함수의 제일 끝에 붙는 const는 이 함수로 전달되는 숨겨진 인수 this의 상수성을 지정한다.
컴파일러는 멤버 함수의 코드를 읽어보고 멤버값을 변경하는지 아닌지를 정확하게 판단할 수 없다. 멤버의 값을 변경하는 방법에는 직접적인 대입만 있는 것이 아니라 포인터를 통한 간접 변경, 함수 호출을 통한 변경 등 여러 가지 변칙적인 방법들이 많기 때문에 함수의 내용만으로 상수성을 정확하게 판단하는 것은 불가능하다. 그래서 상수 멤버 함수인지 아닌지는 개발자가 판단해서 지정해야 한다. 만약 OutPosition의 원형에 const를 빼 버리면 There.OutPosition() 호출조차도 에러로 처리된다. 왜냐하면 컴파일러는 OutPosition함수가 멤버값을 변경할 수도 있다고 생각하기 때문이다.
어떤 멤버 함수가 값을 읽기만 하고 바꾸지는 않는다면 const를 붙이는 것이 원칙이며 이 원칙대로 클래스를 작성해야 한다. 그러나 이 책의 예제들은 예제로서의 간략함을 위해 종종 이 원칙을 무시하고 있는데 절대로 본받지 말아야 한다. 예제는 다루고 있는 주제의 핵심을 보여야 하기 때문에 불가피하게 모든 원칙을 준수하기 어렵다. 만약 원칙을 어기면 상수 객체에 대해서 비상수 멤버 함수를 호출할 수 없게 된다. 다음과 같은 함수의 경우를 보자.

void func(const Position *Pos);

이 함수로 전달되는 Pos는 상수 지시 포인터이므로 *Pos는 func 함수 안에서 상수 객체이다. 따라서 Pos 객체에 대해서는 상수 멤버 함수만 호출할 수 있다. MoveTo로 위치를 옮길 수 없으며 OutPosition 이 상수 멤버 함수로 지정되어 있지 않다면 문자를 출력하는 것도 불가능해진다. 이런 경우에도 잘 동작하려면 원칙대로 멤버값을 바꾸지 않는 함수는 상수 멤버 함수로 지정해야 한다.
함수의 상수성은 함수 원형의 일부로 포함된다. 그래서 이름과 인수 목록이 같더라도 const가 있는 함수와 그렇지 않은 함수를 오버로딩할 수 있다. 즉, 다음 두 함수는 이름과 취하는 인수가 같더라도 다른 함수로 인식된다.

void func(int a, double b, char c) const;
void func(int a, double b, char c);

사실 이는 지극히 당연한 규칙인데 인수의 상수성이 오버로딩 조건이 되므로 const인 this와 그렇지 않은 this를 받는 함수도 당연히 중복 정의할 수 있다. 다음 두 함수가 중복 정의 가능한 것과 같은 이유라고 이해하면 된다.

void func(const char *p);
void func(char *p);


컴파일러는 상수 객체에 대해서는 위쪽의 상수 멤버 함수를 호출할 것이고 그렇지 않은 경우는 아래쪽의 비상수 멤버 함수를 호출할 것이다. 객체가 상수일 때와 그렇지 않을 때의 처리를 다르게 하고 싶다면 두 타입의 함수를 따로 제공하는 것도 가능하다. const와 비슷한 지정자인 volatile 도 마찬가지로 함수 원형의 일부이다.

7-4-다.mutable

mutable은 C++에 새로 추가된 키워드인데 영어 뜻 그대로 번역하면 변덕스럽다는 뜻이다. 상수의 반대 의미로 사용되며 "수정 가능" 정도로 이해하면 된다. mutable로 지정된 멤버는 상수 함수나 상수 객체에 대해서도 값을 변경할 수 있다. 객체의 상태를 표현하는 중요한 멤버가 아닐 때 이 속성을 사용한다. 잘 쓰이지 않으므로 간단한 예제 하나만 만들어 보자.

  : mutable
#include <Turboc.h>

class Some
{
private:
     mutable int v;

public:
     Some() { }
     void func() const { v=0; }
};

void main()
{
     Some S;
     S.func();

     const Some T;
     T.func();
}

func 함수는 상수 멤버 함수로 선언되었지만 멤버 변수 v의 값을 변경할 수 있다. v가 상수 멤버 함수에서도 값을 변경할 수 있는 mutable로 선언되었기 때문이다. 만약 mutable을 빼 버리면 상수 함수에서는 멤버값을 변경할 수 없다는 에러로 처리된다. T는 상수 객체로 선언되었지만 마찬가지로 v를 변경할 수 있다.
mutable은 상수 멤버 함수나 상수 객체의 상수성을 완전히 무시해 버린다. 변수는 본질적으로 값을 마음대로 바꿀 수 있지만 const에 의해 값 변경이 금지된다. mutable은 이런 const의 값 변경 금지 기능을 금지하여 값 변경을 다시 허용하는 복잡한 지정을 한다. 도대체 이런 지정이 왜 필요할까?
객체에 상수성을 주는 이유는 객체의 상태가 우발적으로 변경되는 것을 금지하여 안정성을 높이자는 취지이다. 그런데 때로는 객체의 멤버이면서도 객체의 상태에 포함되지 않는 멤버가 존재하기도 하는데 예를 들어 값 교환을 위한 임시 변수가 이에 해당한다. 또는 i, j같은 통상적인 루프 제어 변수도 객체의 상태라고 볼 수 없으며 디버깅을 위해 임시적으로 추가된 멤버도 mutable이어야 한다. 예를 들어 객체 상태를 출력해 보기 위한 문자열 버퍼를 멤버로 잠시 선언했다면 이 버퍼는 객체의 주요 멤버 변수에 포함되지 않는다. 다음 예제를 보자.

  : mutableinfo
#include <Turboc.h>

class Position
{
private:
     int x,y;
     char ch;
     mutable char info[256];

public:
     Position(int ax, int ay, char ach) { x=ax;y=ay;ch=ach; }
     void OutPosition() const { gotoxy(x, y);putch(ch); }
     void MoveTo(int ax, int ay) { x=ax; y=ay; }
     void MakeInfo() const { sprintf(info,"x=%d, y=%d, ch=%c",x,y,ch); }
     void OutInfo() const { puts(info); }
};

void main()
{
     const Position Here(11,22,'Z');
     Here.MakeInfo();
     Here.OutInfo();
}

객체의 현재 상태를 문자열로 출력하기 위해 info라는 문자열 버퍼를 멤버 변수로 선언했으며 이 버퍼에 상태를 조립하는 MakeInfo와 OutInfo 함수를 선언했다. Position 클래스는 워낙 간단해서 상태를 조사하는 것이 아주 쉽지만 복잡한 클래스는 상태가 수시로 변하며 특정 시점의 상태를 즉시 조사하기 힘든 경우도 있어 미리 조사해 두어야 한다. 이때 info는 객체 자체의 상태가 아니라 속도 향상을 위한 임시적인 캐시 정보일 뿐이며 원한다면 언제든지 다시 조사할 수 있다. 객체의 속성이 아닌 멤버에 대해 예외적으로 아무나 값을 변경할 수 있도록 하는 장치가 바로 mutable이다. 위 예제에서 info가 mutable이 아니라면 MakeInfo는 상수 멤버 함수가 될 수 없으며 상수 객체에 대해서는 정보를 조사하거나 출력하는 것이 불가능해질 것이다.

이상으로 상수 멤버에 대해 연구해 보았다. 상수의 개념이 도입된 이유는 변경하지 말아야 할 값을 잘못 변경하는 우발적인 사고를 방지하여 골치아픈 버그의 원인을 원천적으로 차단하기 위해서이다. const 키워드는 컴파일러가 컴파일을 할 때만 참조하므로 결과 프로그램의 크기나 성능에는 아무런 영향을 주지 않는다. 컴파일러에게 가급적 상세한 정보를 제공하면 실행중에 우연히 발생할 수 있는 에러를 컴파일할 때 미리 알 수 있게 된다. 컴파일 에러는 발견 즉시 원인을 파악하고 수정할 수 있으므로 실행중의 에러 보다 훨씬 더 수정하기 쉽고 말썽을 부릴 여지도 낮다.
값을 변경하지 않는 멤버 함수나 함수 내부에서 변경되지 않는 인수에 대해 일일이 const를 붙이는 것은 무척이나 번거로운 일이다. 일단 상수를 사용하면 프로젝트내의 모든 함수들이 상수 규칙을 지키도록 수정되어야 한다. 가령 func(int a) 함수가 a를 변경하지 않는다 하여 func(const int a)로 원형을 바꾸면 func 함수가 a와 함께 호출하는 함수들도 상수를 받아 들이도록 수정되어야 한다. 그 외에 상수를 사용하면 귀찮아지는 점들도 많이 있다.
하지만 분명한 것은 처음부터 원칙대로 상수 지정을 제대로 하게 되면 확실히 프로그램의 안전성이 높아진다는 것이다. 바꿀 수 없는 값 또는 상황에 대해 const를 붙여 미리 신고해 두면 개발자의 실수나 논리적인 설계 오류에 의한 사고 발생시 컴파일러가 적극적으로 신속하게 잘못을 알려 준다.




댓글 없음:

댓글 쓰기

[Effective C++] 항목 30 : 인라인 함수는 미주알고주알 따져서 이해해 두자.

인라인 함수를 사용하면 컴파일러가 함수 본문에 대해 문맥별 최적화를 걸기가 용이해집니다. 인라인 함수의 아이디어는  함수 호출문을 그 함수의 본문으로 바꿔치기하자는 것  남발했다가는 코드의 크기가 커질 게 뻔하다. 인라인 함수로 부풀려진 ...