지금까지 ApiEdit 컨트롤은 CApiEdit 클래스 단 하나로 표현되며 CApiEdit 의 인스턴스 하나를 생성하면 곧 컨트롤을 만드는 것이었다. 객체를 선언하고 Create 멤버 함수만 부르면 바로 쓸 수 있으며 포인터를 사용할 경우 new 연산자로 동적 생성하여 Create를 호출하고 실컷 사용한 후 delete하면 된다. 모든 초기화, 종료 처리 및 분석기 관리 등을 생성자와 파괴자, OnCreate, OnDestroy 등에서 자동으로 수행하므로 호스트가 별로 신경쓸 것이 없다. 외부 인터페이스가 이렇게 간단하기 때문에 컨트롤 사용자가 ApiEdit 컨트롤을 활용하기 쉽고 내부적인 자원 관리도 단순한 편이다.
그러나 이런 단순함으로 인해 기능상의 제약이 있고 확장에도 불리하다. 그래서 통째로 되어 있는 CApiEdit 객체를 전문 기능을 담당하는 몇개의 작은 객체로 분할할 것이다. 객체를 좀 더 작은 부품으로 분할하면 몇가지 이점이 생기는데 당근 1.0에 분할된 좋은 예가 이미 있다. 구문 분석을 하는 CParse와 그 파생 클래스(CParseCpp, CParseHtml,...)들이 좋은 예인데 이런 분석기들은 CApiEdit에 통합되어 있지 않고 별도의 객체로 작성되어 있다. 그래서 조건에 따라 분석기를 바꿔 가며 사용할 수 있어 기능면에서 좋고 새로운 분석기를 추가하는 확장성도 좋다.
ApiEdit는 CParse의 포인터 Parser만 가지며 구문 분석을 할 필요가 있을 때 이 객체에게 모든 작업을 위임하고 그 결과만을 취한다. 사실 ApiEdit는 각 구문이 어떤 의미를 가지는지, 어떤 식으로 구문 분석을 할 지 전혀 알지 못하지만 분석기들의 도움을 받아 문법 강조 기능을 훌륭하게 제공한다. 이런 구조를 만들면 유지, 보수, 확장에 유리하다는 것을 이미 경험해 보았는데 구문 분석에 버그가 있을 경우 분석기만 디버깅하면 되고 추가 분석기가 필요하면 CParse의 파생 클래스만 늘리면 된다.
객체 분할의 더 많은 이점을 얻기 위해 여기서는 CApiEdit 클래스를 더 잘게 분할할 것이다. 분할을 하는 이유는 여러 가지 이유가 있겠지만 주로 다음 두가지 기능을 위해서이다. 차후에 더 많은 기능을 구현하고 싶다면 객체를 다른 방식으로 분할할 필요가 있을 수도 있는데 1.2 버전에서는 다음 두 기능을 위한 분할만 하기로 한다.
분할창이란 한 문서의 다른 두 부분을 동시에 보여주는 기능이며 비주얼 스튜디오가 이 기능을 제공한다. 수직 스크롤 바 위의 조그만 스플릿 박스(Split Box)를 드래그하면 문서창이 다음과 같이 둘로 갈라지며 각 창은 개별적으로 스크롤 가능하므로 멀리 떨어진 두 함수를 왔다 갔다 할 필요없이 동시에 보면서 편집할 수 있다.
소스 작성시 이미 완성된 함수를 참조하면서 새로운 함수를 작성할 경우가 아주 많은데 편집창이 분할되면 이럴 때 아주 편리하다. 편집창만 분할되어 있을 뿐 편집 대상 문서는 여전히 하나여야 하며 두 개의 컨트롤이 생성되는 것이 아니다. 컨트롤은 하나이되 내부에 두 개의 뷰를 거느리고 있는 상태이다. CApiEdit는 컨트롤이 통째로 하나의 객체이기 때문에 현재 구조로는 창분할 기능을 만들 수 없으며 이런 기능상의 제약을 극복하기 위해 객체 분할이 필요한 것이다.
분할창 기능을 구현하기 위해 두 개의 ApiEdit 컨트롤을 생성하여 아래 위에 배치해 놓으면 되지 않을까 하는 생각이 들 수도 있다. 그러나 이런 식이라면 컨트롤의 두 인스턴스가 같은 문서를 동시에 열어야 하며 상호 편집 내용을 실시간으로 동기화해야 하는데다 편집 버퍼, 취소 레코드, 각종 설정값 등이 이중으로 낭비되는 문제가 있다. 또한 이렇게 되면 분할창 기능은 컨트롤의 고유 기능이 아니라 호스트의 기능으로 넘어가게 되어 컨트롤 사용자에게 부담을 주게 된다. 억지로 작성한다면 가능은 하겠지만 잠재적으로 많은 문제를 유발할 것임은 어렵지 않게 짐작할 수 있다. 어쨋든 창을 분할하려면 객체를 분할하는 것이 가장 최선의 선택이다.
텍스트 파일을 꼭 텍스트 형태로만 보라는 법은 없다. HTML 파일은 서식을 적용하여 웹 페이지에 나타나는 모양 그대로 보여줄 수도 있고 XML 파일은 계층적인 트리 형태로 보여줄 수도 있다. 텍스트 포맷 자체는 서식이 없지만 태그나 기타 다른 방법으로 논리적인 서식을 정의할 수 있는데 이런 서식을 적용하여 보여줄 수도 있다. 또한 이진 파일은 텍스트 형태보다는 16진수로 덤프하는 것이 훨씬 더 효율적이고 같은 텍스트라도 세로로 출력하는 것이 더 멋스러워 보이기도 한다. 보기 모드별로 객체를 따로 만들면 똑같은 문서라도 내용을 더 쉽게 파악할 수 있는 객체로 교체해 가며 쓸 수 있다.
동일한 문서를 여러 가지 다른 방식으로 볼 수 있다는 것은 굉장히 강력하고 편리한 기능이다. HTML 파일을 편집하는 중에 편집기를 떠나지 않고도 이 파일이 웹에서 어떻게 보일 것인가를 실시간으로 확인할 수 있다면, 더 나아가 웹에 보이는 모양 그대로 편집할 수 있다면 얼마나 편리하겠는가? 이렇게 되면 텍스트 편집기는 문서의 논리적인 구조를 보여주는 뷰어가 된다. 대부분의 웹 편집기들은 이런 보기 모드 변경 기능이 있다.
워드 프로세서들도 이런 다양한 보기 모드를 지원하는데 아래한글이나 워드, 훈민정음 등의 제대로 만든 프로그램은 기본 보기, 페이지 레이아웃 보기, 개요 보기, 웹 보기 등의 모드가 있어 같은 문서를 다양한 방식으로 살펴볼 수 있다. 텍스트 편집기중에도 텍스트 모드와 16진 모드를 구분하는 프로그램들이 있다.
이런 보기 모드의 변경을 위해 하나의 객체가 조건에 따라 출력을 다르게 하는 방법을 사용할 수도 있다. 예를 들어 ViewMode라는 변수를 두고 이 변수의 값에 따라 OnPaint의 출력문을 스위칭하면 된다.
switch (ViewMode) {
case 0:
이렇게 출력;
case 1:
요렇게 출력;
case 2:
저렇게 출력;
....
}
그러나 보기 모드란 단순히 출력을 다르게 하는 것뿐만 아니라 편집, 캐럿 처리, 선택 영역, 정렬 상태 등에 광범위한 영향을 미치기 때문에 코드의 곳곳에서 이 값에 따라 다른 동작을 해야 한다. 단순히 동작만 달라지는 정도가 아니라 아예 질적으로 다른 함수 집합이 필요하거나 새로운 개념이 도입되어야 할 경우도 있다. 예를 들어 한 줄당 일정 개수의 바이트를 출력하는 16진 모드에서는 정렬이라는 개념이 필요없고 HTML 모드는 논리적 서식에 따라 글꼴을 관리해야 한다.
요컨데 보기 모드란 변수 하나로 통제할만큼 간단한 개념이 아니다. 또한 보기 모드가 더 늘어날 경우 모든 코드가 수정되어야 하고 어떤 경우는 코드의 구조가 바뀌어야 할 경우도 있기 때문에 확장에 아주 불리하다. 그래서 각 보기 모드별로 보기 객체를 만들고 모드에 따라 객체를 바꾸는 것이 더 좋다.
실행중에 보기 객체를 바꾸는 방식은 현재 버전의 CApiEdit가 실행중에 분석기 객체를 바꾸는 방식과 동일하다. 호스트나 사용자의 명시적인 명령에 따라 분석기만 교체하면 구문 분석을 완전히 다르게 할 수 있는 것처럼 보기 객체만 교체하면 문서를 출력하고 편집하는 방식을 새롭게 정의할 수 있다. 일단 객체가 분할되면 더 필요한 보기 모드를 쉽게 추가할 수 있으므로 확장성의 제약도 쉽게 극복된다.
소프트웨어뿐만 아니라 무엇이든지 분리 가능하고 교체 가능한 것이 만들기는 어렵고 비용이 더 들기 때문에 비싸지만 그만큼 더 좋다. 메인 보드에 사운드 카드, 비디오 카드, 심지어 모뎀까지 같이 붙어 있는 통합형 엄마판(Mother Board)은 한마디로 싸구려로 취급되며 실제로도 싸다. 고급형 디지탈 카메라는 다양한 종류의 메모리를 사용할 수 있으며 밧데리도 여러 종류를 지원하고 심지어 렌즈까지도 교체해가며 쓸 수 있다. 이런 제품은 가격이 비싼 대신에 기능성과 확장성은 탁월하다.
ApiEdit도 더 많은 기능을 제공하고 차후의 확장성을 확보하기 위해 객체를 분할할 것이며 이 것이 이 장의 주요 목표이다. CApiEdit 클래스를 두 개의 클래스로 분할한 후에 분할창 기능을 만들어 볼 것이며 차후에 헥사 편집 모드 지원을 준비할 것이다. 창분할과 헥사 뷰 이 두 가지가 당근 1.2의 가능 핵심적인 개선 사항이며 따라서 그만큼 부피가 크다.
그렇다면 하나로 되어 있는 CApiEdit 객체를 어떤 식으로 분할할 것인지 계획을 세워 보자. 이미 나누어져 있는 분석기 객체는 그대로 두고 CApiEdit 객체만 분할할 것이다. 객체를 분할하는 방식은 분할의 목적이 무엇인가에 따라 달라지겠지만 분할창 기능과 보기 모드 지원을 위해서는 다음 두 개의 클래스로 분할하는 것이 합리적이다.
■ 내부 객체 : 문서 자체를 관리하는 것이 주된 기능이며 문서에 대한 편집 기록인 취소 레코드, 문서의 수정 여부 등의 정보를 관리한다. 내부적인 작업을 처리하기 때문에 대부분의 경우 사용자에게는 직접 보이지 않는다. 대신 사용자와 상호 작용을 할 수 있는 복수 개의 외부 객체를 차일드로 거느릴 수 있으며 이 차일드를 관리한다.
■ 외부 객체 : 사용자와의 통신을 담당한다. 내부 객체에 저장되어 있는 문서 내용을 화면으로 출력하며 사용자의 편집 동작을 입력 받아 문서를 변경한다. 출력을 위해 정렬 정보, 구문 분석 등을 처리하고 여러 가지 설정값들을 적용한다.
이렇게 두 개의 객체로 나누면 분할창과 다양한 보기 모드가 가능해진다. 하나의 내부 객체에 복수개의 외부 객체를 붙여서 배치하면 창을 분할하는 것이 되고 모드에 따라 외부 객체를 교체하면 다양한 보기 모드를 만들 수 있다.
비슷한 방식으로 객체를 분할하는 MFC 프레임워크의 예를 보면 문서창 하나를 도큐멘트, 뷰, 프레임으로 분할하며 이 세 객체를 합쳐 도큐멘트 템플릿이라고 한다. 문서를 관리하는 내부 객체를 도큐멘트라고 부르며 사용자와 상호작용을 하는 외부 객체를 뷰라고 부른다. 프레임은 이 둘을 감싸서 관리하는 껍데기 윈도우인데 별로 하는 일은 없다. MFC의 이런 구조는 흔히 도큐멘트/뷰 구조라고 하며 도큐멘트와 뷰가 각각의 역할에 따라 코드를 나누어 가진다. MFC의 핵심 구조 중 하나이며 MFC 코딩을 해 본 사람들은 아주 익숙할 것이다.
ApiEdit의 경우 MFC의 프레임에 해당하는 껍데기 윈도우는 호스트가 제공하는 DGChild 윈도우이며 이 윈도우안에 ApiEdit 객체가 배치된다. DGChild는 컨트롤의 일부가 아니며 호스트에 의해 주어지는 것이기 때문에 ApiEdit는 껍데기 윈도우를 직접 제공할 필요가 없다. ApiEdit의 외부 객체는 MFC와 마찬가지로 뷰라는 이름을 가지며 역할(사용자와 상호작용)은 동일하다. ApiEdit의 내부 객체는 MFC의 도큐멘트와 프레임의 통합체이며 프레임이라고 부르기로 한다. DGChild 윈도우안에 ApiEdit의 프레임이 배치되며 이 프레임안에 뷰가 배치된다. 즉, ApiEdit는 프레임/뷰 구조로 작성된다.
ApiEdit의 내부 객체는 사실 도큐멘트의 역할을 더 많이 하지만 도큐멘트라고 부르지 않고 프레임이라고 부르는 이유는 윈도우 핸들을 가지며 뷰를 차일드로 거느리기 때문이다. 또한 컨트롤 외부에서 볼 때 ApiEdit의 내부 객체가 곧 컨트롤 그 자체인 것처럼 보이므로 프레임이라는 이름이 더 어울린다. MFC의 도큐멘트는 뷰와 연결되어 있을 뿐 뷰를 포함하지도 않으며 윈도우도 아니므로 ApiEdit의 내부 객체 개념과는 상당한 거리가 있다.
ApiEdit는 프레임과 뷰로 분할되는데 프레임이 도큐멘트를 포함하고 있으므로 도큐멘트라는 용어를 따로 쓰지는 않을 것이다. 객체의 이름을 어떻게 붙일 것인가는 그리 중요한 문제가 아니므로 ApiEdit의 프레임을 도큐멘트라고 생각해도 무방하다. 분할된 결과를 보면 내부 객체의 이름을 프레임이라고 보는 것이 더 타당하다는 생각이 들 것이다.
CApiEdit 클래스가 원래 가지고 있던 멤버들은 프레임과 뷰 두 클래스가 나누어 가지게 된다. 이때 어떤 멤버가 어디로 이동할 것인가는 보기 상태에 따라 다른 값을 가지는 멤버인가 아닌가를 기준으로 한다. 보기 상태와 무관한 멤버는 프레임이 가지며 보기에 따라 달라질 수 있는 멤버는 뷰가 가져야 한다. 몇가지 예를 보자.
멤버 |
소속 |
buf |
문서의 내용 자체이며 보기 상태와 무관하므로 프레임에 속한다. |
off |
각 뷰가 다른 위치를 편집할 수 있으므로 뷰에 속한다. |
ypos, xpos |
스크롤 위치도 뷰에 따라 달라질 수 있으므로 뷰에 속한다. |
pUR |
편집 기록은 문서의 변화에 대한 기록이므로 프레임에 속한다. |
pLine |
정렬 정보는 문서를 보여주는 방식에 대한 정보이므로 뷰에 속한다. |
모든 멤버들이 이 도표처럼 프레임 또는 뷰 한 쪽으로 소속을 명확하게 정할 수 있는 것은 아니다. 어디에 두어도 상관없는 멤버도 있는데 예를 들어 설정 변수들은 프레임에 두고 뷰가 참조할 수도 있고 뷰에 두고 뷰가 직접 쓸 수도 있다. 또한 프레임이나 뷰나 둘 다 윈도우이므로 hWnd 핸들은 양쪽에 다 있어야 하며 필요에 따라 새로 추가되는 멤버도 당연히 있다.
멤버 함수들의 경우는 더 애매해지는데 문서만 다루는 함수(예:Undo, Redo)들은 프레임으로 가고 보기 상태만 관리하는 함수(예:DrawLine, SetCaret)는 뷰로 가지만 둘 다 다루는 함수(예:Insert, Delete)들은 양쪽에 코드를 나누어 가져야 한다. 또한 외부에서 볼 때 프레임이 ApiEdit 컨트롤이므로 호스트와 인터페이스하는 모든 함수들은 일단 프레임에 소속되어야 한다. 각 멤버들이 어디로 이동되는지는 실습을 하면서 살펴 보기 바란다. 대충 감이 오겠지만 이 실습을 하려면 지금까지 만들었던 모든 코드를 한바탕 뒤집어 엎는 대공사를 벌려야 한다.
흔히 전문 용어로 "갈아 엎는다"라고 표현하는데 모든 코드를 완전히 재배치해야 하므로 만만한 작업이 아니며 실수할 여지도 많고 시간도 많이 걸린다. 처음 기획할 때부터 객체 분할을 염두에 두었더라면 중간에 이런 귀찮은 작업을 하지 않아도 될 것이다. 그러나 웬만큼 경험이 많지 않고서는 미래를 예측한다는 것 또한 쉽지 않기 때문에 갈아 엎기는 어쩌면 개발 과정에서 피할 수 없는 한 과정이며 개발자에게는 숙명과도 같은 작업이다. 이런 작업을 최소화하려면 처음부터 기획을 치밀하게 해야 하며 그러기 위해 필요한 덕목은 바로 풍부한 경험이다.
객체 분할의 필요성과 이점, 그리고 분할 방식에 대한 정책 수립을 완료했으므로 이제 직접 객체를 분할해 보자. 그런데 CApiEdit는 실습용으로 사용하기에는 이제 덩치가 너무 커져 버렸고 이런 복잡한 실습 과정을 설명하기에는 부적합하다. 워낙 멤버가 많다 보니 분할하는 과정이 지나치게 긴데다가 관련 이론도 복잡하기 때문에 CApiEdit로 분할 실습을 해 보기는 번거롭다.
그래서 훨씬 더 작은 예제로 실습을 먼저 해보면서 주요 분할 과정을 살펴 보기로 하자. 여기서는 1권에서 컨트롤을 객체화할때 작성했던 CShowMsg 클래스를 먼저 분할해 보고 분할창과 보기 모드 변경을 모두 해 볼 것이다. ShowMsgObj 예제를 복사하여 SplitView 예제를 만들고 분할 실습을 해 보자. 이 예제에서 작성한 함수와 구조는 약간의 변형을 거쳐 CApiEdit에 적용될 것이다. 실습양이 많지는 않지만 이론은 굉장히 복잡한 편이데 C++에 대해 웬만큼 자신있는 사람도 이 실습을 이해하기는 쉽지 않으리라 전망된다. C++ 문법서를 옆에 두고 문법 공부도 병행해야 할 정도로 난이도가 높으므로 약간의 긴장이 필요하다.
ShowMsgObj 예제에서 도우미 클래스인 RegisterHelper는 CShowMsg 컨트롤의 윈도우 클래스 등록, 객체와 윈도우의 맵 관리를 담당하였다. 이제 CShowMsg가 여러 개의 클래스로 분할되면 도우미도 분할된 각 클래스의 인스턴스를 관리할 수 있도록 수정되어야 하는데 객체 맵에 CShowMsg의 포인터와 윈도우 핸들의 대응관계를 기록하는 대신 생성되는 모든 객체에 대한 포인터를 윈도우 핸들과 대응시킬 수 있어야 한다.
이렇게 되려면 분할되는 모든 객체를 포괄할 수 있는 타입, 즉 공통의 조상 클래스가 필요하다. 부모 클래스형의 포인터는 자식 객체를 가리킬 수 있으므로 공통의 조상 클래스형의 포인터와 윈도우 핸들의 대응 관계를 맵으로 작성하면 된다. 이런 목적으로 루트 클래스 CWindow를 ShowMsg.h의 선두에 다음과 같이 선언한다.
class CWindow
{
public:
HWND hWnd;
~CWindow();
virtual LRESULT OnMessage(UINT iMessage,WPARAM wParam,LPARAM lParam)=0;
};
CWindow는 윈도우 형태로 표현되는 모든 객체에 대한 최소한의 조건만을 가진다. 윈도우이므로 당연히 윈도우 핸들(hWnd)을 가져야 하며 이 윈도우로 전달되는 메시지를 처리하는 OnMessage 가상 함수를 가진다. 메시지 처리함수에서 실제 메시지를 받을 객체의 타입에 따라(포인터 타입이 아니라) 적절한 OnMessage 함수가 호출되어야 하므로 이 함수는 반드시 가상 함수여야 한다. 또한 CWindow 자체는 메시지를 처리하지 않으므로 순수 가상 함수로 선언했으며 이 클래스로부터 파생되는 클래스는 반드시 OnMessage 함수를 재정의해야 한다.
CWindow는 순수 가상 함수를 가지므로 일반적인 윈도우를 표현하는 추상 클래스가 되고 인스턴스를 생성할 수 없다. CWindow 클래스의 파괴자는 다음과 같이 작성한다. ShowMsg.cpp에 작성하되 _RegisterHelper 전역 객체를 참조하므로 이 객체 선언문보다 뒤에 작성해야 한다.
CWindow::~CWindow()
{
if (_RegisterHelper.arObj)
_RegisterHelper.RemoveObject(hWnd);
}
파괴자는 도우미의 객체 맵이 아직 유효하면 객체 맵에서 자신을 제거한다. CWindow의 파괴자가 객체 제거를 하므로 파생 클래스들은 객체 맵에서 자신을 제거하는 작업에 대해서는 더 이상 신경 쓸 필요가 없다. 도우미 클래스는 새로 정의된 CWindow의 객체에 대한 맵을 관리할 수 있도록 다음과 같이 수정한다.
class CRegisterHelper
{
public:
CRegisterHelper();
~CRegisterHelper();
struct _arObj
{
CWindow *pObj;
HWND hWnd;
} *arObj;
int arSize;
int nReg;
CWindow *FindObject(HWND hWnd);
void AddObject(HWND hWnd, CWindow *pObj);
void RemoveObject(HWND hWnd);
};
CRegisterHelper::CRegisterHelper()
{
WNDCLASS WndClass;
WndClass.cbClsExtra=0;
WndClass.cbWndExtra=0;
WndClass.hbrBackground=(HBRUSH)(COLOR_WINDOW+1);
WndClass.hCursor=LoadCursor(NULL,IDC_ARROW);
WndClass.hIcon=LoadIcon(NULL,IDI_APPLICATION);
WndClass.hInstance=GetModuleHandle(NULL);
WndClass.lpfnWndProc=(WNDPROC)ShowMsgProc;
WndClass.lpszClassName="ShowMsgCtrl";
WndClass.lpszMenuName=NULL;
WndClass.style=CS_HREDRAW | CS_VREDRAW;
RegisterClass(&WndClass);
WndClass.hbrBackground=(HBRUSH)NULL;
WndClass.hCursor=LoadCursor(NULL,IDC_IBEAM);
WndClass.lpszClassName="ShowMsgView";
RegisterClass(&WndClass);
nReg=0;
arSize=10;
arObj=(_arObj *)malloc(arSize*sizeof(_arObj));
memset(arObj,0,arSize*sizeof(_arObj));
}
CWindow *CRegisterHelper::FindObject(HWND hWnd)
{
....
}
void CRegisterHelper::AddObject(HWND hWnd, CWindow *pObj)
{
....
}
void CRegisterHelper::RemoveObject(HWND hWnd)
{
int i,j;
if (IsWindow(hWnd)) {
DestroyWindow(hWnd);
}
for (i=0;i<nReg;i++) {
if (arObj[i].hWnd == hWnd)
break;
}
for (j=i+1;j<arSize;j++) {
arObj[j-1].hWnd=arObj[j].hWnd;
arObj[j-1].pObj=arObj[j].pObj;
}
nReg--;
}
CRegisterHelper _RegisterHelper;
LRESULT CALLBACK ShowMsgProc(HWND hWnd,UINT iMessage,WPARAM wParam,LPARAM lParam)
{
CWindow *pSM;
pSM=_RegisterHelper.FindObject(hWnd);
if (pSM == NULL) {
pSM=(CWindow *)((LPCREATESTRUCT)lParam)->lpCreateParams;
_RegisterHelper.AddObject(hWnd,pSM);
}
return pSM->OnMessage(iMessage, wParam, lParam);
}
일단 생성자에서는 뷰의 윈도우 클래스를 ShowMsgView라는 이름으로 등록하였다. 뷰도 별도의 윈도우이므로 윈도우 클래스가 필요하다. 배경 브러시는 가지지 않으며 커서를 I자 모양으로 정의하였다. 윈도우 프로시저는 프레임과 마찬가지로 ShowMsgProc인데 어차피 윈도우 프로시저는 OnMessage 가상 함수를 찾아 메시지를 전달하기만 하므로 CWindow의 파생 클래스들은 같은 윈도우 프로시저를 공유해도 무관하다.
arObj의 pObj 멤버가 CShowMsg *형에서 CWindow *형으로 변경되었으며 멤버 함수들의 인수 타입도 CWindow *형으로 변경되었다. 따라서 도우미는 CWindow 로부터 파생되는 모든 클래스의 객체 맵을 관리할 수 있다. 멤버 함수의 코드는 변경되지 않았는데 다만 RemoveObject 함수는 큰 변화가 생겼다. 이 함수에 작성되어 있던 DestroyWindow 호출문이 삭제되었으며 객체가 관리하던 윈도우를 자동으로 파괴하지 않는다. 즉 RemoveObject 함수는 객체 맵에서 객체만 제거할 뿐이다. 이 문제는 객체 분할의 가장 어려운 점이며 다양한 변화를 줄 수 있는 부분이므로 다음 항에서 자세하게 따로 연구해 볼 것이다.
메시지 프로시저의 CShowMsg *도 CWindow *로 변경하여 모든 CWindow 파생 클래스의 메시지를 처리하도록 하였다. 이제부터 작성되는 모든 클래스들은 도우미의 지원을 받기 위해 CWindow로부터 상속받아야 한다.
원래의 CShowMsg 클래스는 CShowMsg 프레임 클래스와 CShowMsgView 뷰 클래스로 분할된다. 호스트의 입장에서 볼 때 프레임이 컨트롤이므로 원래 컨트롤의 클래스 이름을 프레임이 대신 사용하게 된다. ShowMsg.h 헤더 파일에 프레임 클래스를 새로 선언한다.
enum {VIEW1,VIEW2};
class CShowMsgView;
class CShowMsg : public CWindow
{
friend class CShowMsgView;
private:
int x;
int y;
TCHAR *str;
public:
CShowMsg();
CShowMsgView *arView[2];
BOOL Create(int x,int y,int w,int h,DWORD style,UINT id,HWND hParent);
LRESULT OnMessage(UINT iMessage,WPARAM wParam,LPARAM lParam);
void ChangeString(TCHAR *nstr);
CShowMsgView *CreateView(int Type);
void DeleteView(int nView);
void SplitView();
void UpdateViews(CShowMsgView *pView);
};
프레임은 컨트롤 그 자체이므로 클래스 하나로 표현되지만 뷰는 필요에 따라 얼마든지 만들 수 있다. 그래서 필요한 뷰의 타입을 열거형으로 선언했으며 이 번호로 뷰의 타입을 관리할 것이다. 이 예제는 다른 종류의 뷰를 만들 수 있다는 것을 보여 주기 위해 VIEW1, VIEW2 두 종류의 뷰만 가질 계획인데 필요한만큼 뷰의 타입을 더 늘릴 수 있다.
새로운 프레임 클래스인 CShowMsg는 CWindow로부터 상속받았으므로 일종의 윈도우이며 도우미 클래스에 의해 관리된다. 원래의 CShowMsg가 가지고 있던 x,y,str 멤버는 다 프레임에 속하므로 별도로 뷰에 나누어 줄 것은 없다. 자기에게 연결된 뷰의 목록을 관리하기 위해 CShowMsgView* 형의 배열 arView를 새로 선언했으며 최대 2개까지의 뷰를 연결할 수 있도록 배열 크기는 2로 정했다.
프레임이 뷰의 포인터 배열을 가지므로 뷰 클래스에 대한 전방 선언이 필요하다. 그리고 뷰가 프레임의 모든 멤버를 자유롭게 액세스하기 위해 프랜드로 지정했다. 이들은 다른 객체지만 하나의 컨트롤을 구성하는 부품들이므로 자기들끼리는 굳이 멤버를 숨길 필요가 없다. 그 외 뷰를 관리하는 몇 개의 함수들이 추가되었는데 이 함수들은 따로 상세히 분석해 볼 것이다. CShowMsg 의 코드는 다음과 같이 전면 수정된다.
CShowMsg::CShowMsg()
{
arView[0]=NULL;
arView[1]=NULL;
}
BOOL CShowMsg::Create(int x,int y,int w,int h,DWORD style,UINT id,HWND hParent)
{
CreateWindow("ShowMsgCtrl",NULL, style,
x,y,w,h,hParent,(HMENU)id,GetModuleHandle(NULL),this);
return TRUE;
}
void CShowMsg::ChangeString(TCHAR *nstr)
{
lstrcpy(str,nstr);
UpdateViews(NULL);
}
LRESULT CShowMsg::OnMessage(UINT iMessage,WPARAM wParam,LPARAM lParam)
{
RECT crt;
int i;
switch(iMessage) {
case WM_CREATE:
x=50;
y=50;
str=(TCHAR *)malloc(128);
lstrcpy(str,"String");
arView[0]=CreateView(VIEW1);
return 0;
case WM_DESTROY:
free(str);
for (i=0;i<2 && arView[i];i++) {
DeleteView(i);
}
return 0;
case WM_SIZE:
GetClientRect(hWnd,&crt);
if (arView[1]==NULL) {
MoveWindow(arView[0]->hWnd,0,0,crt.right,crt.bottom,TRUE);
} else {
MoveWindow(arView[0]->hWnd,0,0,crt.right,crt.bottom/2-2,TRUE);
MoveWindow(arView[1]->hWnd,0,crt.bottom/2+2,crt.right,crt.bottom/2-2,TRUE);
}
return 0;
}
return(DefWindowProc(hWnd,iMessage,wParam,lParam));
}
CShowMsgView *CShowMsg::CreateView(int Type)
{
CShowMsgView *pView;
switch (Type) {
case VIEW1:
pView=new CShowMsgView;
break;
}
pView->pFrame=this;
pView->Create(0,0,0,0,WS_CHILD | WS_VISIBLE,0,hWnd);
return pView;
}
void CShowMsg::DeleteView(int nView)
{
DestroyWindow(arView[nView]->hWnd);
delete arView[nView];
arView[nView]=NULL;
}
void CShowMsg::SplitView()
{
if (arView[1]==NULL) {
arView[1]=CreateView(VIEW1);
} else {
DeleteView(1);
}
SetFocus(arView[0]->hWnd);
SendMessage(hWnd,WM_SIZE,0,0);
}
void CShowMsg::UpdateViews(CShowMsgView *pView)
{
if (arView[0] != pView) {
InvalidateRect(arView[0]->hWnd,NULL,TRUE);
}
if (arView[1] && arView[1] != pView) {
InvalidateRect(arView[1]->hWnd,NULL,TRUE);
}
}
생성자에서는 두 개의 뷰 포인터를 모두 NULL로 초기화하여 일단 뷰가 없는 상태로 컨트롤을 생성했다. WM_CREATE에서 프레임의 멤버들을 초기화하고 CreateVeiw 함수를 호출하여 VIEW1 타입의 기본 뷰를 생성하였다. 생성자는 단순히 포인터 배열을 초기화한 것 뿐이고 WM_CREATE에서 기본 뷰를 하나만 생성하므로 여기까지의 동작만 보면 원래의 ShowMsg 컨트롤과 동일하게 초기화된 것이다. 하지만 실행중에 뷰를 추가로 더 만들 수 있고 다른 타입의 뷰로 교체 가능하다는 차이점이 있다.
WM_DESTROY에서는 컨트롤이 동적으로 할당해서 사용하던 str 버퍼를 정리하고 생성되어 있는 모든 뷰에 대해 DeleteView 함수를 호출하여 뷰를 파괴한다. 통상 arView[0]에 기본 뷰만 생성되어 있겠지만 실행중에 뷰를 더 만들었다면 추가로 만든 뷰까지도 같이 파괴함으로써 프레임이 거느리고 있는 모든 뷰를 제거하는 것이다.
WM_SIZE에서는 뷰를 자신의 작업 영역에 적절히 배치하는데 뷰가 하나밖에 없으면 작업 영역 전체를 뷰로 가득 채운다. 이렇게 되면 프레임은 뷰에 의해 완전히 덮여지므로 사용자에게는 전혀 보이지 않는 상태가 될 것이다. 만약 두 개의 뷰가 있으면 두 뷰가 작업 영역의 절반씩을 나누어 가지는데 이때 두 뷰 사이로 약간의 여백을 두면 프레임의 작업 영역이 살짝 드러난다. 이 여백은 뷰의 분할 비율을 조정하는 유저 인터페이스로 활용된다. 생성되어 있는 뷰의 개수는 1 또는 2인데 arView[1]이 NULL인가 아닌가를 보면 쉽게 알 수 있다.
파괴자는 따로 정의하지 않으므로 CWindow의 파괴자를 상속 받아 객체가 파괴될 때 맵에서 자신을 제거할 것이다. CShowMsg프레임은 어디까지나 뷰를 담기 위한 껍데기이기 때문에 화면 출력을 하지 않으며 키보드와 마우스 관련 메시지도 처리하지 않는다. 프레임의 OnMessage 함수는 초기화, 종료 처리, 뷰 배치만을 담당할 뿐이며 사용자와의 상호 작용은 이제 각각의 뷰로 옮겨질 것이다.
뷰 클래스는 다음과 같이 선언한다. ShowMsg.h 헤더 파일의 프레임 클래스 선언부 다음에 이 클래스를 선언하면 된다.
class CShowMsgView : public CWindow
{
public:
CShowMsgView() { ViewType=VIEW1; }
CShowMsg *pFrame;
int ViewType;
BOOL Create(int x,int y,int w,int h,DWORD style,UINT id,HWND hParent);
LRESULT OnMessage(UINT iMessage,WPARAM wParam,LPARAM lParam);
};
뷰도 CWindow로부터 상속받으므로 일종의 윈도우이며 동일한 객체 맵에 등록되고 관리된다. 생성자에서 뷰의 타입을 VIEW1(=0)로 초기화하여 자신이 0번 타입의 뷰라는 것을 기록해 놓는다. pFrame은 자신과 연결된 프레임 객체의 포인터인데 이 포인터를 사용하여 프레임의 멤버에 접근할 수 있다. 자신을 생성하는 Create 멤버 함수와 메시지를 처리하는 OnMessage 가상 함수를 가지는데 이 함수들은 다음과 같이 작성한다.
BOOL CShowMsgView::Create(int x,int y,int w,int h,DWORD style,UINT id,HWND hParent)
{
CreateWindow("ShowMsgView",NULL, style | WS_CLIPCHILDREN,
x,y,w,h,hParent,(HMENU)id,GetModuleHandle(NULL),this);
return TRUE;
}
LRESULT CShowMsgView::OnMessage(UINT iMessage,WPARAM wParam,LPARAM lParam)
{
HDC hdc;
PAINTSTRUCT ps;
RECT crt;
switch(iMessage) {
case WM_KEYDOWN:
GetClientRect(hWnd,&crt);
switch (wParam) {
case VK_LEFT:
if (pFrame->x > 0)
pFrame->x--;
break;
case VK_RIGHT:
if (pFrame->x < crt.right-50)
pFrame->x++;
break;
case VK_UP:
if (pFrame->y > 0)
pFrame->y--;
break;
case VK_DOWN:
if (pFrame->y < crt.bottom-10)
pFrame->y++;
break;
}
InvalidateRect(hWnd,NULL,TRUE);
pFrame->UpdateViews(this);
return 0;
case WM_LBUTTONDOWN:
if (lstrcmp(pFrame->str,"String") == 0) {
pFrame->ChangeString("문자열");
} else {
pFrame->ChangeString("String");
}
SetFocus(hWnd);
return 0;
case WM_PAINT:
GetClientRect(hWnd,&crt);
hdc=BeginPaint(hWnd, &ps);
Rectangle(hdc,0,0,crt.right,crt.bottom);
TextOut(hdc,pFrame->x,pFrame->y,pFrame->str,lstrlen(pFrame->str));
EndPaint(hWnd, &ps);
return 0;
case WM_DESTROY:
return 0;
}
return(DefWindowProc(hWnd,iMessage,wParam,lParam));
}
Create 함수는 "ShowMsgView" 윈도우 클래스로부터 자신을 생성하되 이때 사용자 정의 데이터로 this를 전달하여 객체 맵에 자신을 등록한다. 뷰의 화면 출력 및 키보드, 마우스 처리는 원래의 ShowMsg 컨트롤의 동작 방식과 동일하다. 즉, 커서 이동키로 메시지의 좌표를 이동하고 마우스 왼쪽 버튼으로 문자열을 변경하며 좌표 위치에 문자열을 출력한다. 이때 화면 출력에 참조할 x,y,str 등의 변수는 프레임이 가지고 있으므로 pFrame 포인터로부터 얻어야 한다.
이로써 기존의 ShowMsg 컨트롤이 프레임과 뷰로 분할되었다. 내부 객체인 프레임은 x,y,str 변수를 가지며 자신에게 연결된 차일드 뷰를 관리하고 뷰는 이 변수들의 내용을 화면에 출력하고 사용자로부터 입력받아 변수들을 조작한다. 제대로 분할이 되었는지 점검해 보기 위해 ShowMsgTest.cpp의 테스트 코드를 다음과 같이 수정해 보자.
CShowMsg msg;
LRESULT CALLBACK WndProc(HWND hWnd,UINT iMessage,WPARAM wParam,LPARAM lParam)
{
switch(iMessage) {
case WM_CREATE:
msg.Create(0,0,0,0,WS_CHILD | WS_VISIBLE,1,hWnd);
CreateWindow("button","창 분할",WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON,
10,10,100,25,hWnd,(HMENU)1,g_hInst,NULL);
return 0;
case WM_COMMAND:
switch (LOWORD(wParam)) {
case 1:
msg.SplitView();
break;
}
return 0;
case WM_SIZE:
MoveWindow(msg.hWnd,0,50,LOWORD(lParam),HIWORD(lParam)-50,TRUE);
return 0;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return(DefWindowProc(hWnd,iMessage,wParam,lParam));
}
작업 영역 상단에 "창 분할"버튼을 배치했으며 이 버튼을 누르면 프레임의 SplitView 함수를 호출하여 컨트롤의 작업 영역을 다음과 같이 분할할 것이다. 만약 이미 분할되어 있다면 분할을 취소한다. 하나의 프레임에 두 개의 뷰가 생성된다.
ShowMsg 컨트롤의 뷰는 스크롤을 하지 않기 때문에 당장 분할의 이점을 느끼기 어렵지만 일단 분할이 되었으므로 차후에 스크롤이나 편집 기능이 들어가면 두 개의 뷰가 문서의 다른 부분을 보여주거나 편집할 수 있다. 일단 실행 결과만 보고 분석은 다음 항에서 상세하게 해 보자.
ShowMsg 컨트롤이 프레임과 뷰로 분할되었으므로 생성되는 절차에도 약간의 변화가 생긴다. 프레임은 이 컨트롤을 필요로하는 호스트 컨트롤에 의해 생성되는데 예제의 경우 ShowMsgTest 의 WM_CREATE에서 msg 객체의 Create 멤버 함수를 호출함으로써 프레임을 생성한다. 프레임의 생성자는 뷰를 생성하지 않으므로 프레임은 일시적으로 뷰가 없는 빈 상태로 생성되었다가 WM_CREATE 메시지를 받았을 때 기본 뷰를 생성한다. 뷰는 프레임에 의해 관리되는데 한 번 생성되어 계속 사용되는 것이 아니라 수시로 생성 및 파괴될 수 있다. 그래서 프레임은 뷰를 관리하는 별도의 멤버 함수들을 가진다.
CreateView 함수는 생성할 뷰의 타입을 Type 인수로 전달받으며 이 인수에 따라 다른 종류의 뷰를 생성할 수 있다. Type 인수의 값에 따라 new 연산자로 새로운 뷰 객체를 생성하는데 현재 이 컨트롤이 지원하는 뷰의 타입은 VIEW1 하나뿐이지만 얼마든지 늘어날 수 있는 구조를 가지고 있다. 잠시 후면 VIEW2를 만드는 실습을 하게 될 것이다.
객체를 생성한 후 뷰의 Create 함수를 호출하여 윈도우를 생성한다. 보다시피 객체 자체를 생성하는 것과 윈도우를 생성하는 것이 분리되어 있는데 이 점에 대해서는 1권에서도 다룬 바 있다. Create 함수의 마지막 인수로 프레임의 핸들 hWnd를 전달하였으므로 뷰는 프레임의 자식 윈도우로 생성된다. 생성할 때 뷰의 좌표와 크기는 의미가 없으므로 모두 0으로 주었다. 어차피 뷰는 프레임의 WM_SIZE에서 재배치된다. Create가 호출될 때 뷰가 객체 맵에 등록될 것이다.
뷰 객체를 생성하기 직전에 뷰의 pFrame 멤버에 프레임 자신의 포인터인 this를 대입하여 새로 생성된 뷰가 부모 프레임의 멤버를 참조할 수 있도록 한다. 뷰의 WM_CREATE에서부터 프레임을 참조할 수 있으므로 이 대입문은 뷰의 Create호출보다 먼저 와야 한다. pView->pFrame에 this를 대입하는 것은 "너는 이제부터 나의 졸병이다. 앞으로 나의 멤버인 x,y,str을 참조하라"는 지시를 내리는 것이다. CreateView 함수는 타입에 맞는 뷰 객체, 뷰 윈도우를 생성하고 뷰에게 부모 포인터를 가르쳐 준 후 생성된 뷰의 포인터를 리턴한다.
DeleteView 함수는 파괴할 뷰의 첨자를 nView 인수로 받아들이는데 ShowMsg 컨트롤은 최대 두 개까지의 뷰만 가질 수 있으므로 nView는 0 또는 1 둘 중 하나이다. 인수로 전달된 nView값에 따라 arView[nView] 객체가 파괴 대상이 된다. 생성할 때 객체와 윈도우를 따로 만들었으므로 파괴할 때도 윈도우와 객체를 각각 파괴해야 한다.
DestroyWindow 함수로 뷰의 윈도우를 파괴할 때 WM_DESTROY 메시지가 전달되며 이 단계에서 뷰가 스스로 종료 처리를 할 수 있는 기회가 제공된다. ShowMsgView의 경우 별도의 멤버가 없으므로 특별히 정리할 내용이 없지만 ApiEdit의 뷰는 정렬 버퍼, 더블 버퍼링 비트맵, 분석기 제거 등 할 일이 많기 때문에 WM_DESTROY 메시지를 반드시 받아야 한다. 만약 이 메시지가 제대로 전달되지 않으면 당장은 이상이 없는 것처럼 보이겠지만 심각한 리소스 누출이 발생할 것이다.
윈도우를 파괴한 후 delete 연산자로 뷰 객체를 파괴하는데 이 단계에서 뷰의 파괴자가 호출된다. CShowMsgView 클래스는 파괴자를 가지지 않으므로 CWindow로부터 상속받은 ~CWindow가 호출될 것이며 이 함수에서 RemoveObject 함수를 호출하여 객체 맵에서 자신을 제거한다. 객체까지 제거한 후 arView[nView]에 NULL을 명시적으로 대입함으로써 이 뷰가 파괴되었음을 표시해 두는데 그래야 다음 WM_SIZE 메시지를 받았을 때 파괴된 뷰를 빼고 나머지 뷰를 제대로 배치할 수 있다.
프레임은 이 두 함수로 기본적인 뷰 관리를 하는데 예제에서 그 과정을 점검해 보자. 호스트에 의해 프레임이 생성될 때 WM_CREATE 메시지가 전달되며 이 메시지에서 CreateView 함수로 VIEW1 타입의 뷰를 생성한 후 arView[0]에 그 포인터를 저장한다. WM_SIZE에서 이 뷰를 작업 영역에 가득 채우므로 프레임이 마치 뷰인 것 처럼 보일 것이다. 프레임이 파괴될 때 WM_DESTROY 메시지가 전달되고 이 메시지에서 생성되어 있는 모든 뷰를 제거한다.
이것이 ShowMsg 컨트롤의 일반적인 일생이다. ShowMsgTest 예제가 실행될 때 호스트, 프레임, 뷰 순으로 생성되었다가 종료될 때는 정확하게 역순으로 뷰, 프레임, 호스트 순으로 파괴된다. 창을 분할할 때는 이런 일상적인 순서를 벗어나 실시간으로 뷰가 생성되었다가 파괴될 수 있는데 이제 창이 분할되는 과정을 분석해 보자.
창을 분할하는 함수는 SplitView인데 이 함수의 정확한 동작은 분할 상태를 토글하는 것이다. 즉, 뷰가 하나밖에 없으면 둘로 분할하고 이미 분할되어 뷰가 둘이면 하나를 파괴하여 분할을 취소한다. 분할이 되어 있는가 아닌가는 arView[1]에 뷰의 포인터가 대입되어 있는지 아닌지로 판단한다. 만약 arView[1]이 NULL이면 현재 arView[0] 뷰 하나밖에 없다는 뜻이므로 arView[1]에도 새로운 뷰를 생성한다. 이때는 물론 CreateView 함수를 사용한다.
arView[0]는 항상 존재하며 arView[1]이 있으면 뷰가 둘이고 없으면 뷰가 하나뿐이다. 만약 arView[1]이 NULL이 아니라면 두 개의 뷰로 이미 분할되어 있는 상태이므로 arView[1]을 파괴하며 이때는 DeleteView 함수를 사용한다. 즉, 이 함수가 호출될 때마다 뷰가 하나, 둘인 상태를 반복하는 것이다. 분할 상태가 바뀔 때마다 프레임으로 WM_SIZE 메시지를 보내 뷰를 재배치하였다.
이 예제의 경우 SplitView 함수는 호스트의 "창 분할"버튼에 의해 호출되는데 이 방법보다는 컨트롤 스스로 분할을 처리하는 것이 좋다. 컨트롤이 스스로 창을 분할하려면 화면에 창 분할을 시작할 수 있는 유저 인터페이스를 제공해야 하는데 보통 수직 스크롤 바 위에 조그만 사각 영역(Split Box)을 드래그하는 것으로 창 분할을 시작한다. 이 예제는 스크롤 바가 없기 때문에 컨트롤 스스로 분할을 시작할 수 없으며 외부에서 명령을 전달하는 방법을 사용했다.
만약 스크롤 바 위에 스플릿 박스를 만든다면 이 박스를 드래그하여 창을 분할한다. 객체 분할시의 역할 분담 원칙에 따라 사용자와의 상호 작용은 뷰의 몫이므로 창분할 명령을 받는 주체도 뷰이다. 그러나 뷰가 분할 명령을 받아들이더라도 분할의 주체는 항상 프레임이어야 하며 뷰는 단지 사용자의 분할 요청을 프레임으로 전달할 수만 있다. 뷰가 스스로 형제 뷰를 만들 수는 없다.
창이 분할된 상태에서는 하나의 프레임에 복수 개의 뷰가 존재하는데 이때 한쪽 뷰에서 변화가 발생하면 분할된 모든 뷰도 즉시 변화를 반영해야 한다. 왜냐하면 분할된 모든 뷰는 같은 프레임에 속한 데이터를 다른 방식으로 또는 다른 부분을 보여주는 것 뿐이기 때문이다. 프레임에 조금이라도 변화가 생기면 이 프레임과 연결된 모든 뷰는 갱신되어야 한다.
이런 처리는 비주얼 스튜디오의 편집기로도 쉽게 확인할 수 있는데 창을 분할한 상태에서 한쪽에 텍스트를 입력하면 반대쪽 뷰도 갱신되어 새로 입력된 텍스트가 보일 것이다. 만약 이런 갱신이 제대로 되지 않는다면 창분할은 오히려 혼란만 초래하게 된다.
사용자와의 상호 작용은 분할 구조상 뷰가 담당하는데 그 중에서 현재 포커스를 가진 활성 뷰가 사용자로부터 입력을 받아들인다. 만약 사용자가 뷰1에서 어떤 편집을 했다고 한다면 뷰1은 이 입력을 받아들여 프레임의 데이터를 변경할 것이다. 이때 나머지 뷰에게도 변경 사실을 알려야 하나 뷰1은 나머지 뷰에게 알려줄 수가 없다. 뷰끼리는 서로 형제이지만 서로의 존재에 대해서 알지 못하기 때문이다.
그래서 뷰는 사용자에 의해 편집이 발생했을 때 프레임에게 자신이 데이터를 변경했음을 알리는데 이때 호출하는 함수가 바로 UpdateViews 함수이다. 이 함수의 인수로는 변화를 유발한 뷰의 포인터가 전달되는데 이 뷰는 갱신 대상에서 제외되는 것이 합리적이다. 뷰1이 데이터를 바꾼 후 더 있을지도 모르는 뷰2, 뷰3 등도 같이 갱신하기 위해 프레임의 UpdateViews 함수를 호출한다. 이 함수 호출은 곧 "쟤들도 좀 바꿔 주세요"라는 요청인데 이때 뷰1은 자신이 직접 입력을 받았기 때문에 프레임이 따로 갱신할 필요가 없다.
UpdateViews 함수의 코드 자체는 무척 간단한다. 뷰가 최대 2개 밖에 없으므로 arView[0]와 arView[1]에 대해 갱신을 하되 NULL이 아니고 pView가 아닌 모든 뷰를 갱신하면 된다. 간단한 예제이기 때문에 뷰의 작업 영역 전체를 무효화하여 다시 그리는 것으로 뷰를 갱신할 수 있다.
예제에서는 WM_KEYDOWN, ChangeString에서 UpdateViews 함수를 호출한다. 그래서 창을 분할한 상태에서 커서 이동키로 메시지를 이동시키면 양쪽 뷰의 메시지가 모두 이동하며 마우스 버튼을 누르면 문자열이 같이 바뀐다. WM_KEYDOWN은 InvalidateRect를 직접 호출했으므로 자신은 제외한 뷰를 모두 갱신하지만 ChangeString 함수는 자기 자신도 같이 갱신하기 위해 UpdateViews(NULL)을 호출하였다.
여기까지 작성된 예제를 보면 객체 분할도 잘 되었고 덕분에 창 분할에도 아무 무리가 없다. 결과를 보면 그렇지만 코드를 좀 더 살펴 보면 약간 마음에 안드는 점이 있는데 객체가 파괴될 때 윈도우를 자동으로 파괴하지 않는다는 점이다. 왜 이렇게 되어야만 하는지, 다른 방법은 없는지 이론적인 점검을 해 보자.
객체가 분할됨으로써 생긴 가장 큰 변화는 도우미가 여러 종류의 객체, 즉 CWindow로부터 파생되는 모든 객체를 관리해야 한다는 점이다. 객체간의 계층이 생겼기 때문에 모든 객체에 대해 윈도우를 자동으로 파괴하는 것이 어렵게 되었고 그래서 RemoveObject에 윈도우를 파괴하는 코드가 삭제되었으며 대신 프레임의 DeleteView 멤버 함수에서 명시적으로 DestroyWindow를 호출한다. 즉 객체의 윈도우 파괴가 자동에서 수동으로 바뀐 것이다.
그렇다 하더라도 컨트롤을 활용하는데 특별히 문제가 있는 것은 아니지만 파괴자에서 객체의 윈도우를 자동으로 제거한다면 객체 스스로 통합된 자원 관리를 한다는 점에서 완성도가 높아지고 이 컨트롤을 사용하는 개발자는 별 주의 사항없이 컨트롤을 쓸 수 있으므로 더 편리할텐데 말이다. 요컨데 자동이 아니라 수동이라는 점이 기분 나쁘고 뭔가 엉성한 것 같아 불안해 보인다는 것이 불만이다. 이 불만 사항에 대해 이론적 점검을 해 보자.
파괴자에서 가상 함수를 호출하면 이 가상 함수는 파괴자를 호출하는 객체의 타입에 따라 제대로 호출된다. 파괴자가 호출될 시점은 아직 객체가 파괴되기 전이기 때문에 vtable(가상 함수 테이블)이 온전하게 남아 있고 따라서 파괴자에서 가상 함수를 호출하더라도 정확한 번지를 찾아갈 수 있다. 그러나 상속받은 파괴자에서 가상 함수를 호출할 때는 그렇지 못한데 객체 타입과는 무관하게 파괴자를 정의하는 클래스의 가상 함수가 호출된다. 이것이 윈도우 파괴를 수동으로 할 수밖에 없는 근본적인 이유이다. 다음 VirtTest 예제로 과연 그런지 테스트해 보자.
class CSuper
{
public:
~CSuper() { func(); }
virtual void func () { MessageBox(hWndMain,"CSuper의 func","호출",MB_OK); }
};
class CSub : public CSuper
{
public:
void func() { MessageBox(hWndMain,"CSub의 func","호출",MB_OK); }
};
CSuper *p1=NULL;
LRESULT CALLBACK WndProc(HWND hWnd,UINT iMessage,WPARAM wParam,LPARAM lParam)
{
switch(iMessage) {
case WM_LBUTTONDOWN:
if (p1==NULL) {
p1=new CSub;
p1->func();
}
return 0;
case WM_RBUTTONDOWN:
if (p1) {
delete (CSub *)p1;
p1=NULL;
}
return 0;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return(DefWindowProc(hWnd,iMessage,wParam,lParam));
}
CSuper와 CSub 두 개의 클래스가 선언되어 있다. CSuper는 가상 함수 func를 정의하며 이 함수는 호출 확인용으로 메시지 박스만을 띄우는데 MessagBox 호출문을 객체를 파괴시키기 위한 중요한 종료 처리라고 가정하자. CSuper의 파괴자는 파괴되기 직전에 func 함수를 호출하며 따라서 CSuper형의 객체를 만들었다 파괴하면 다음 메시지 박스가 나타날 것이다.
CSub는 CSuper로부터 상속받았으며 파괴자는 CSuper의 것을 그대로 쓰고 func 가상 함수는 재정의하였다. CSub::func도 호출 확인용 메시지 박스만 띄우는데 메시지 내용만 다를 뿐이다. 예제에서는 CSuper형의 포인터 p1 전역 변수를 선언하고 있으며 마우스 왼쪽 버튼을 누를 때 p1에 CSub형의 객체를 생성하고 p1->func를 호출한다. 비록 p1이 CSuper형의 포인터이지만 CSub형의 객체를 가리키고 있으므로 이때 호출되는 함수는 CSub::func가 된다. func가 가상 함수이므로 포인터 타입이 아닌 객체 타입에 따라 호출될 함수를 찾으며 지극히 당연하다.
마우스 오른쪽 버튼을 누를 때는 p1을 delete하는데 이때 파괴자가 호출될 것이다. CSub는 파괴자를 재정의하지 않으므로 ~CSuper 파괴자가 대신 호출된다. 이 함수에서 가상 함수 func를 호출하는데 이때 호출되는 함수는 이상하게도 CSub::func가 아니라 CSuper::func가 된다. func가 가상 함수이지만 파괴되는 객체의 타입에 상관없이 파괴자가 호출하는 함수는 항상 CSuper::func가 되는 것이다.
원하는 결과는 CSuper *p1에 CSub 객체를 생성했을 경우 이 객체를 파괴할 때 CSub::func를 호출하고 싶다는 것인데 그렇게 되지 않는다. 여기서 혹시 파괴자를 가상으로 선언해야 하는 것이 아닌가 하는 생각을 할지 모르겠지만 그것은 전혀 상관이 없는 문제이다. 파괴자가 가상인 것은 객체 파괴시 객체 타입에 따라 적절한 파괴자를 호출할 뿐이지 파괴자가 호출하는 가상 함수 선택과는 아무런 관련이 없다.
C++ 컴파일러는 상속된 파괴자에서 가상 함수를 호출할 때 파괴되는 객체의 함수를 호출하지 못한다. 왜 그런지 작은 클래스 계층 모델을 가정하여 생각해 보자. C1 루트 클래스로부터 C2, C3 가 차례대로 상속되었고 각 클래스는 고유의 파괴자를 가진다. C1은 가상 함수 func를 가지며 C2, C3는 이 가상 함수를 재정의하고 있다. 물론 각 클래스는 자신에게 필요한 기타 멤버 변수들과 함수들을 추가로 가지고 있을 것이다.
이때 C1 *p가 C3 객체를 가리키고 있고 이 객체를 delete p로 파괴한다고 해 보자. 상속받은 객체가 생성될 때는 부모의 생성자가 먼저 호출되어 상속받은 멤버를 먼저 초기화하고 차례대로 파생 클래스의 생성자가 호출된다. 반대로 파괴될 때는 자신의 파괴자가 먼저 호출되고 부모 클래스의 파괴자가 순서대로 호출된다. 즉 생성될 때와는 반대로 ~C3(), ~C2(), ~C1() 순인데 자신의 고유 멤버를 먼저 해제하고 상속받은 멤버를 차례대로 해제하는 것이다.
이렇다 보니 ~C1()에서 가상 함수 func를 호출할 때는 C3나 C2의 멤버가 이미 해제되어 버린 상황이 되고 만다. 그래서 ~C1()에서는 C1::func밖에 호출할 수가 없는 것이다. 비록 p가 C3 타입의 객체이지만 ~C1()에서 C3::func를 호출한다면 C3의 멤버 중 일부가 이미 파괴된 상황이기 때문에 이 함수가 제대로 동작한다고 보장할 수 없으며 그래서 C++ 컴파일러는 상위 클래스의 파괴자에서 호출하는 가상 함수는 하위 클래스의 함수가 될 수 없도록 금지한다.
파괴자는 각 클래스 계층을 따라 역순으로 실행되며 각 실행 단계에서 vtable을 현재 파괴자가 실행되고 있는 클래스의 것으로 변경한다. ~C3()가 실행중일 때 vtable은 아직 C3 클래스의 가상 함수를 포인트하고 있다. 그러나 ~C3()가 실행을 마치고 ~C2()로 제어가 넘어갈 때 vtable은 C2클래스의 가상 함수를 가리키도록 변경되며 마찬가지로 ~C1()으로 넘어갈 때는 C1클래스의 가상 함수를 가리킨다. 그러니 ~C1()에서 호출하는 가상함수는 C1의 함수일 수밖에 없는 것이다.
VirtTest 예제의 ~CSuper()에서 호출하는 func 함수는 현재 파괴되고 있는 객체가 무엇인가에 상관없이 무조건 CSuper::func가 된다. 간단하게 모델로 설명을 했는데 그럼 SplitView 예제의 실제 상황을 생각해 보자. SplitView 예제에서도 파괴자에서 가상 함수 호출 문제가 그대로 나타나는데 단계를 좀 더 거칠뿐 VirtTest예제와 완전히 동일하다.
만약 SplitView 예제의 RemoveObject 함수에서 DestroyWindow 함수를 호출하여 윈도우를 자동으로 파괴한다고 해 보자. 그러면 ShowMsgView 객체가 파괴될 때 ~CWindow가 호출될 것이고 파괴자에서 RemoveObject를 호출하고 RemoveObject는 DestroyWindow 함수로 이 객체와 연결된 윈도우를 파괴한다. 여기까지는 아주 정상적으로 수행되지만 그 다음이 문제다.
DestroyWindow로 윈도우를 파괴했으니 WM_DESTROY 메시지가 ShowMsgProc으로 전달되며 윈도우 프로시저는 객체 맵에 아직 남아 있는 뷰의 포인터를 제대로 찾는다. 그러나 이때 뷰의 vtable은 이미 CWindow의 것으로 바뀌어 버렸기 때문에 OnMessage를 제대로 찾지 못한다. ShowMsgProc이 가상 함수 테이블에서 찾는 OnMessage는 CWindow::OnMessage이며 이 함수는 실제 본체가 없는 순수 가상 함수이므로 결국 프로그램이 죽고 마는 것이다.
모델을 통해 우회적인 방법으로 원인을 분석해 보고자 했는데 문제가 발생하는 원인을 좀 직접적으로 길게 표현해 보면 이렇다. ShowMsgView가 파괴될 때 이 객체가 상속받은 파괴자 ~CWindow에서 호출하는 도우미의 RemoveObject 멤버 함수에서 호출하는 DestroyWindow에 의해 발생하는 WM_DESTROY 메시지를 처리하는 ShowMsgProc에서 찾는 pSM객체의 OnMessage 가상 함수의 포인터가 틀린 것이다. 말이 좀 꼬이는 것 같아 보이지만 원래 상속과 가상 함수의 호출 관계는 이렇게 복잡하다.
이것이 RemoveObject에서 DestroyWindow 함수를 호출하여 윈도우를 자동으로 파괴하지 못하는 이유이다. ShowMsgObj 예제의 경우 계층 관계가 없었고 자신의 파괴자를 자신이 가지므로 문제가 없었으나 객체를 분할하면서 계층이 생기게 되고 이 과정에서 상속받은 파괴자와 가상 함수간의 호출 문제가 발생한 것이다. SplitView 예제는 이 문제에 대한 해결책으로 윈도우 자동 파괴를 포기하고 RemoveObject에서는 순수하게 객체만 파괴하고 프레임의 DeleteView에서 객체를 삭제하기 전에 수동으로 DestroyWindow를 호출하도록 하였다.
만약 SplitView 예제의 DeleteView 함수에서 윈도우는 그대로 두고 객체만 delete 하면 어떻게 될까? 이렇게 하면 당장은 문제가 없는 것처럼 보이지만 프로그램을 종료할 때 다운된다. 왜냐하면 부모 윈도우가 파괴될 때 자식 윈도우를 같이 파괴하며 WM_DESTROY메시지가 전달되는데 이때 ShowMsgProc이 메시지를 받아야할 객체를 찾지 못하기 때문이다.
또한 객체와 윈도우의 파괴 순서를 바꾸어도 안된다. 만약 이 순서를 바꾸어 delete를 먼저 하고 DestroyWindow를 호출하면 객체가 맵에서 먼저 제거되었기 때문에 WM_DESTROY 메시지를 처리할 객체가 없는 치명적인 오류가 발생한다. 객체는 윈도우가 완전히 파괴될 때까지 남아 있어야 한다. 반드시 SplitView 예제의 DeleteView 함수대로 객체와 윈도우를 제거해야 한다. 그렇다면 현재 구조는 과연 안전한지 여러 경우를 점검해 보자.
①
ShowMsgTest 예제처럼 전역으로 선언된 msg 객체의 경우를 보자. 이 객체는 new 연산자로 할당한 것이 아니므로 delete를 명시적으로
호출할 필요가 없고 따라서 윈도우를 파괴할 시점이 애매한 것 같다. 이 객체는 호스트가 생성되기 전에 만들어지며 메인 윈도우가 만들어질 때 윈도우를
만든다. 종료될 때는 메인 윈도우와 함께 윈도우가 파괴되며 전역 객체 msg의 파괴자가 호출되어 객체도 파괴된다. 객체와 윈도우가 순서대로 생성,
파괴되므로 아무 문제가 없다.
②
new 연산자를 사용하여 전역 변수에 ShowMsg 객체를 생성하여 사용하고 delete할 경우를 보자. 이때도 객체를 파괴하기 전에
DestroyWindow 함수만 제대로 호출하면 역시 문제가 없다. 항상 객체 삭제 전에 윈도우를 먼저 제거하면 된다.
③ 객체를 지역 변수로 사용할 경우는 어떻게
될까? 물론 윈도우 객체를 지역 변수로 생성할 일은 실제로 없지만 일단 문법적인 차원에서 이론적 점검은 해 볼 필요가
있다. 아마 다음과 같은 코드가 작성될 것이다.
void func2()
{
CShowMsg m;
m.Create(0,0,0,0,WS_CHILD | WS_VISIBLE,2,hWndMain);
// do something
DestroyWindow(m.hWnd);
}
이때도 함수가 종료되기 전에 DestroyWindow 함수만 호출하면 아무 문제가 없다. 윈도우가 먼저 파괴되고 지역 변수 m이 범위를 벗어날 때 스택의 객체가 삭제된다.
④ 마지막으로 객체를 함수 내에서 new 연산자로 생성한 후 함수가 끝나기 전에 delete로 파괴할 경우이다. 이때의 코드는 다음과 같다.
void func()
{
CShowMsg *m;
m=new CShowMsg;
m->Create(0,0,0,0,WS_CHILD | WS_VISIBLE,2,hWndMain);
// do something
DestroyWindow(m->hWnd);
delete m;
}
이때도 delete전에 DestroyWindow만 제대로 호출하면 된다.
결국 모든 경우에 있어서 객체가 삭제되기 전에 윈도우만 제거한다면 별 문제가 없는 셈이다. 이 방식의 궁극적인 문제는 오직 하나뿐인데 객체를 delete하기 전에 DestroyWindow를 반드시 호출해야 한다는 것을 개발자가 기억해야 한다는 점이다. 즉, 윈도우 제거 방식이 자동이 아니라 수동이라는 점을 항상 염두에 두어야 한다. 사용자가 기억해야 할 사항은 최소한 작아야 하는데 그렇지 못하다는 점이 조금 아쉬울 뿐 그 외의 다른 문제는 없다.
개발자가 이 사실을 잊을 경우 프로그램이 다운되기는 하지만 개발중에 자신의 실수를 분명하게 알 수 있기 때문에 큰 문제는 아니다. 프로그램이 다운되는데 개발자가 이 문제를 모르고 지나갈 리가 없지 않은가? 때로는 확실하게 죽는 프로그램이 수정하기 더 쉽다. 그렇다면 객체가 파괴될 때 윈도우가 자동으로 파괴되도록 하는 방법은 과연 없는 것일까? 물론 꼭 그렇게 하고 싶다면 해결 방법은 여러 가지가 있다.
첫 번째로 이 문제는 상속받은 파괴자가 가상 함수를 제대로 찾지 못해 발생하는 것이므로 파괴자를 상속받지 않으면 된다. 각 클래스 계층이 고유의 파괴자를 가지기만 한다면 파괴자에서 해당 객체의 OnMessage를 정확하게 찾을 수 있을 것이다. VirtTest 예제의 CSub 클래스를 다음과 같이 수정하면 이 문제를 당장 해결할 수 있다.
class CSub : public CSuper
{
public:
~CSub() { func(); }
void func() { MessageBox(hWndMain,"CSub의 func","호출",MB_OK); }
};
그러나 이렇게 되면 매 클래스마다 동일한 내용의 파괴자를 가진다는 점이 낭비인데 보다시피 ~CSub()와 ~CSuper() 함수의 내용은 동일하다. CWindow 아래에 파생 클래스가 10개만 되어도 굉장히 보기 싫은 코드가 될 것이다. 뿐만 아니라 파생 클래스의 파괴자가 호출된 후 부모 클래스의 파괴자가 다시 호출되므로 func 함수가 두 번 호출되는 부작용이 생긴다. 이 부작용을 해결하려면 한 함수가 두 번 호출되더라도 종료 처리는 한번만 하도록 별도의 장치를 마련할 필요가 있다. SplitView 예제에 이 방식을 적용하고 싶다면 RemoveObject가 객체를 맵에서 중복 제거하지 않아야 한다.
두 번째 방법은 객체를 좀 다른 방식으로 파괴하는 것이다. 객체를 제거할 필요가 있을 때 delete 연산자를 쓰지 말고 DestroyWindow(또는 특별한 멤버 함수)만 사용한다. 각 윈도우는 자신이 제거될 때 마지막으로 WM_POSTNCDESTROY 메시지를 받는데 이때 delete this;로 자신을 제거하는 것이다. 즉, 마지막 순간까지 객체를 유지하고 있다가 윈도우가 완전히 파괴된 시점에서 객체를 제거하는 방법이다.
MFC도 SplitView 예제와 마찬가지의 문제를 가지고 있는데 MFC는 두 번째 방법으로 객체를 제거한다. 이 방법을 쓸 경우 new로 생성한 객체와 정적으로 생성한 객체를 구분해야 한다는 부담이 있으며 MFC는 m_bAutoDelete라는 별도의 멤버에 자신이 어떻게 생성된 객체인가를 기억하는 방법을 쓰고 있다. 이 방법도 물론 문제는 없지만 자신의 탄생 과정을 별도의 멤버에 기록해 놓는다는 점에서 역시 깔끔하지는 못하다. MFC는 상용 라이브러리이기 때문에 내부 구조야 어쨌건 최대한 사용자의 편의를 고려해야 하는 것이다.
SplitView 예제가 채택하고 있는 윈도우 제거 방식은 자동화된 윈도우 제거를 포기하는 대신 더 간단한 구조를 가지는 이점을 취하고 있다. 윈도우 자동 파괴는 못해서 안하는 것이 아니라 비용이 들거나 다른 부작용이 있기 때문에 하지 않는 것이다. 다행히 컨트롤의 사용자는 개발자이기 때문에 이 정도 주의 사항은 큰 문제가 되지 않을 것으로 판단하였다.
프레임과 뷰가 분할되었으므로 실행중에 뷰를 다른 것으로 교체할 수 있다. CShowMsg 프레임의 정보를 반드시 CShowMsgView로만 출력해야 한다는 법은 없다. 여기서는 다른 뷰 타입을 만들어 보고 뷰를 교체해 보자. 필요한만큼 뷰의 타입은 얼마든지 만들 수 있지만 가능성만 점검해 볼 것이므로 뷰 타입을 하나만 더 만들어 본다. ShowMsg.h에 다음 뷰 클래스를 선언한다.
class CShowMsgView2 : public CShowMsgView
{
private:
int mx,my;
public:
CShowMsgView2() { ViewType=VIEW2; }
BOOL Create(int x,int y,int w,int h,DWORD style,UINT id,HWND hParent);
LRESULT OnMessage(UINT iMessage,WPARAM wParam,LPARAM lParam);
};
새로 만들어지는 뷰는 반드시 기본 뷰인 CShowMsgView로부터 상속받아야 한다. 왜냐하면 프레임은 자신과 연결된 뷰의 목록을 arView 배열로 관리하며 이 배열의 타입이 CShowMsgView *이기 때문이다. 또한 새로 추가된 뷰는 기존의 뷰와 최소한의 동일한 인터페이스를 유지해야 프레임과 통신이 가능하므로 기본 뷰의 모든 것을 일단은 상속받아야 한다.
CShowMsgView2 도 간접적으로 CWindow로부터 파생되므로 결국 이것도 윈도우이고 도우미 객체의 맵에 같이 등록될 수 있다. 추가된 뷰는 고유한 보기 상태를 만들기 위해 기본 뷰에 더 필요한 멤버를 추가로 가질 수 있는데 CShowMsgView2는 기본 뷰에는 없는 mx, my 멤버를 추가로 가진다. 이 멤버는 프레임의 x,y,str값을 출력할 좌표값으로 사용된다. 생성자는 뷰의 타입을 VIEW2로 초기화하고 있다. 나머지 멤버 함수들의 코드는 다음과 같다.
BOOL CShowMsgView2::Create(int x,int y,int w,int h,DWORD style,UINT id,HWND hParent)
{
CreateWindow("ShowMsgView",NULL, style | WS_CLIPCHILDREN,
x,y,w,h,hParent,(HMENU)id,GetModuleHandle(NULL),this);
return TRUE;
}
LRESULT CShowMsgView2::OnMessage(UINT iMessage,WPARAM wParam,LPARAM lParam)
{
HDC hdc;
PAINTSTRUCT ps;
RECT crt;
TCHAR Mes[128];
switch(iMessage) {
case WM_CREATE:
mx=1;
my=1;
return 0;
case WM_KEYDOWN:
GetClientRect(hWnd,&crt);
switch (wParam) {
case VK_LEFT:
if (mx > 1)
mx--;
break;
case VK_RIGHT:
if (mx < crt.right-50)
mx++;
break;
case VK_UP:
if (my > 1)
my--;
break;
case VK_DOWN:
if (my < crt.bottom-10)
my++;
break;
}
InvalidateRect(hWnd,NULL,TRUE);
pFrame->UpdateViews(this);
return 0;
case WM_LBUTTONDOWN:
pFrame->x++;
pFrame->y++;
InvalidateRect(hWnd,NULL,TRUE);
pFrame->UpdateViews(this);
SetFocus(hWnd);
return 0;
case WM_PAINT:
GetClientRect(hWnd,&crt);
hdc=BeginPaint(hWnd, &ps);
SelectObject(hdc,GetStockObject(LTGRAY_BRUSH));
Rectangle(hdc,0,0,crt.right,crt.bottom);
wsprintf(Mes,"좌표:%d,%d,문자열:%s",pFrame->x,pFrame->y,pFrame->str);
TextOut(hdc,mx,my,Mes,lstrlen(Mes));
EndPaint(hWnd, &ps);
return 0;
case WM_DESTROY:
return 0;
}
return(DefWindowProc(hWnd,iMessage,wParam,lParam));
}
기본 뷰와는 다르다는 것을 보여 주기 위해 여러 가지 동작 방식을 의도적으로 바꾸어 보았다. 배경색을 옅은 회색으로 바꾸었으며 메시지를 출력하는 방법도 다르다. 첫번째 뷰는 프레임의 (x,y)에 str을 출력하지만 두번째 뷰는 프레임의 정보를 통째로 문자열화하여 자신이 가지고 있는 mx, my에 이 문자열을 출력한다. 사용자의 입력도 다른 방식으로 해석하는데 커서 이동키는 프레임의 x,y값을 바꾸는 것이 아니라 자신의 my, my를 변경하며 마우스 버튼을 누를 때 문서의 x,y좌표를 1씩 증가시킨다.
프레임은 이 클래스의 객체도 프레임의 멤버를 자유롭게 액세스할 수 있도록 프랜드로 지정해야 한다. 그리고 실행중에 뷰의 타입을 바꿀 수 있는 ChangeViewType 멤버 함수도 추가하였다.
class CShowMsg : public CWindow
{
friend class CShowMsgView;
friend class CShowMsgView2;
private:
int x;
int y;
TCHAR *str;
public:
CShowMsg();
CShowMsgView *arView[2];
BOOL Create(int x,int y,int w,int h,DWORD style,UINT id,HWND hParent);
LRESULT OnMessage(UINT iMessage,WPARAM wParam,LPARAM lParam);
void ChangeString(TCHAR *nstr);
CShowMsgView *CreateView(int Type);
void DeleteView(int nView);
void SplitView();
void UpdateViews(CShowMsgView *pView);
void ChangeViewType(int nView,int Type);
};
ChangeViewType 함수는 nView번째 뷰의 타입을 Type으로 변경한다. 코드는 다음과 같이 같다.
void CShowMsg::ChangeViewType(int nView,int Type)
{
DeleteView(nView);
arView[nView]=CreateView(Type);
SendMessage(hWnd,WM_SIZE,0,0);
}
DeleteView 함수를 호출하여 nView 번째 뷰를 삭제하고 CreateView로 Type 타입의 뷰를 다시 생성하여 arView[nView]에 그 포인터를 대입한다. 프레임과 뷰가 분할되어 있기 때문에 프레임은 그대로 있고 뷰만 파괴했다가 다시 생성하는 것이 가능해졌으며 이때 뷰의 타입을 바꿀 수 있다. 뷰를 교체한 후 WM_SIZE 메시지를 보내 새로 생성된 뷰를 프레임의 작업 영역에 재배치하였다. 뷰의 타입이 하나 더 늘어났으므로 CreateView 함수는 새로 추가된 뷰도 생성해야 한다.
CShowMsgView *CShowMsg::CreateView(int Type)
{
CShowMsgView *pView;
switch (Type) {
case VIEW1:
pView=new CShowMsgView;
break;
case VIEW2:
pView=new CShowMsgView2;
break;
}
pView->pFrame=this;
pView->Create(0,0,0,0,WS_CHILD | WS_VISIBLE,0,hWnd);
return pView;
}
switch 문에 case가 하나 더 늘어난 것 뿐이다. Type으로 전달된 타입의 뷰 객체를 생성하여 그 포인터를 리턴했다. 만약 뷰의 타입이 더 늘어난다면 이 함수도 새로 추가된 뷰를 지원할 수 있도록 확장되어야 한다. 마지막으로 뷰 교체를 테스트하기 위해 호스트를 다음과 같이 수정한다.
LRESULT CALLBACK WndProc(HWND hWnd,UINT iMessage,WPARAM wParam,LPARAM lParam)
{
switch(iMessage) {
case WM_CREATE:
msg.Create(0,0,0,0,WS_CHILD | WS_VISIBLE,1,hWnd);
CreateWindow("button","창 분할",WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON,
10,10,100,25,hWnd,(HMENU)1,g_hInst,NULL);
CreateWindow("button","뷰 교체",WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON,
120,10,100,25,hWnd,(HMENU)2,g_hInst,NULL);
return 0;
case WM_COMMAND:
switch (LOWORD(wParam)) {
case 1:
msg.SplitView();
break;
case 2:
if (msg.arView[0]->ViewType == VIEW1) {
msg.ChangeViewType(0,VIEW2);
} else {
msg.ChangeViewType(0,VIEW1);
}
break;
}
return 0;
....
뷰 교체라는 버튼을 새로 배치하고 이 버튼을 누르면 뷰의 타입을 토글한다. 이 예제는 활성 뷰를 관리하지 않으므로 무조건 첫 번째 뷰에 대해서만 타입을 교체했는데 활성 뷰를 관리한다면 활성 뷰의 타입을 변경할 수도 있다. 다음은 두 개의 뷰로 분할한 상태에서 첫번째 뷰의 타입을 변경해 본 것이다.
ApiEdit의 경우 텍스트 보기 모드와 16진 보기 모드 두 가지를 지원하게 될 것이다. 두 모드는 문서를 보여주는 방법과 편집하는 방법이 완전히 다르다. 이상으로 객체를 분할하는 간단한 예제를 만들어 봤는데 예제의 기능에 비해서 적용되는 문법은 심히 복잡하다. 충분히 이해될만큼 설명을 하기는 했지만 C++에 대해 웬만큼 정리가 되어 있지 않아서는 선뜻 이해하기 힘든 내용인데 만약 그렇다면 달력에 C++ 고급 문법을 공부할 일정을 잡아 놓아라.