[Effective C++] 4. 설계 및 선언 [3/4]
이 글은 제 개인적인 공부를 위해 작성한 글입니다.
틀린 내용이 있을 수 있고, 피드백은 환영합니다.
항목 22 : 데이터 멤버가 선언될 곳은 private 영역임을 명심하자
이번 항목에서는 먼저 데이터 멤버가 어째서 public이면 안 되는지에 대한 이유를 알아보고, public 데이터 멤버에 대한 모든 이야기가 protected 데이터 멤버에도 똑같이 적용되는 모습을 함께 확인해보자. 여기까지 이야기가 끝나면 데이터 멤버는 반드시 private 멤버이어야 한다는 결론을 자연스럽게 볼 수 있을 것이고, 그렇게 되면 이번 항목은 끝이다.
그렇다면 public 데이터 멤버는 왜 안될까?
우선 문법적 일관성이 첫 번째 이유가 되겠다(항목 18도 같이 읽어두자). 데이터 멤버가 public이 아니라면, 사용자 쪽에서 어떤 객체에 접근할 수 있는 유일한 수단은 멤버 함수 일 것이다. 자, 어떤 클래스의 공개 인터페이스 있는 것들이 전부 함수뿐이라면, 그 클래스의 멤버에 접근하고 싶을 때 괄호를 붙여야 하는지 말아야 하는지를 기억하지 못해서 사용자가 고민할 필요도 없을 것이다. 전부 함수로 되어 있으니까 그냥 쓰기만 하면 된다.
문법적 일관성에 대한 이야기가 그리 마음에 다가오지 않는 독자도 분명히 있을 것이다. 그럼, 함수를 사용하면 데이터 멤버의 접근성에 대해 훨씬 정교한 제어를 할 수 있다는 사실을 어떻게 생각하는가? 만일 어떤 데이터 멤버를 public으로 내놨다면 모두가 이 멤버에 대해 읽기 및 쓰기 권한을 갖게 되지만, 이 값을 읽고 쓰는 함수가 있으면 접근 불가/읽기 전용/읽기 쓰기 접근을 우리가 직접 구현할 수 있다. 심지어 쓰기 전용 접근도 필요하면 구현할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class AccessLevels
{
public:
...
int getReadOnly() const { return readOnly; }
void setReadWrite(int value) { readWrite = value; }
int getReadWrite() const { return readWrite; }
void SetWriteOnly(int value) { writeOnly = value; }
private:
int noAccess; // 접근 불가
int readOnly; // 읽기 전용
int readWrite; // 읽기 쓰기
int writeOnly; // 쓰기 전용
이렇게 세밀한 접근 제어는 나름대로의 중요성을 갖고 있다. 어떤 식으로든 외부에 노출시키면 안 되는 데이터 멤버들이 꽤 많기 때문이다. 사실 모든 데이터 멤버에 읽기 및 쓰기 함수를 달아 줄 일은 극히 드물다.
더 중요한 거는 캡슐화(encapsulation)이다. 함수를 통해서만 데이터 멤버에 접근할 수 있도록 구현해 두면, 데이터 멤버를 나중에 계산식으로 대체할 수도 있을 것이고, 사용자는 절대로 이 클래스를 넘보는 잘난 사람이 되지 못한다는 것이다.
예를 하나 들어 보자. 자동화 장치를 사용해서 자동차가 지나가는 속도를 모니터링하는 프로그램을 만들고 있다고 가정해 보자. 이 프로그램이 실행되면, 자동차가 지나갈 때마다 속도를 계산한 후에 지금까지 수집한 데이터 집합에 그 속도를 추가한다.
1
2
3
4
5
6
class SpeedDataCollection
{
public:
void addValue(int speed); // 새 데이터 값 추가
double averageSoFar() const; // 평균 속도 반환
}
이제 avarageSoFar 멤버 함수를 어떻게 구현할지 생각해 보도록 하자. 한 가지 방법으로, 지금까지 수집한 속도 데이터 전체의 평균값을 담는 어떤 데이터 멤버를 클래스 안에 넣어두는 방안이 있다. avarageSoFar 함수는 호출될 때마다 그 데이터 멤버의 값을 반환하기만 하면 된다. 또 다른 방법으로는 호출될 때마다 평균값을 계산하는 방법이 있다. 수집한 데이터를 매번 죽 훑어가는 코드가 들어가게 된다.
첫 번째 방법(현재의 평균값 유지하기)를 사용하면, SpeedDataCollection 객체 하나의 크기가 좀 커진다. 평균값을 유지하기 위한 공간 할당이 필요할 테니까 말이다. 현재의 평균값, 누적 총합, 데이터의 개수 등이 데이터 멤버로 들어가야 할 것이다. 하지만 이런 방법으로 구현한 avarageSoFar 함수는 효율 면에서 꽤나 좋을 것이다. 현재의 평균값을 반환하기만 하는 인라인 함수(항목 30 참조)인데 느릴 리가 없다. 이와 반대로 호출될 때마다 평균값을 계산하는 방법을 쓰면 함수 자체의 속도는 느려지지만, SpeedDataCollection 객체 하나의 크기는 첫 번째 방법보다 작아질 것이다.
어느 방법이 최고인지, 정답이 있겠는가?? 쓸 수 있는 메모리가 적은 환경이고, 평균값이 그다지 자주 필요하지 않은 응용 프로그램을 만드는 경우에는 평균값 계산을 매번 하는 편이 아마 더 좋을 것이다. 평균값을 빈번하게 사용해야 하고, 속도가 아주 중요하며, 메모리 크기에 많이 구애받지 않는 환경이라면 평균값을 유지하는 방법이 바람직할 것이다. 어쨌든 중요한 포인트는 “평균값 접근에 멤버 함수를 통하게 한다”(다른 말로 평균값을 캡슐화한다)라는 점인데, 이렇게 함으로써 내부 구현을 언제든지 바꿀 수 있게 되고, 사용자 쪽에서는 기껏 해 봤자 컴파일만 다시 하면 끝난다.
데이터 멤버를 함수 인터페이스 뒤에 감추게 되면 구현상의 융통성을 전부 누릴 수 있다. 예를 들어 이런 것들이 간편해진다. 데이터 멤버를 읽거나 쓸 때 다른 객체에 알림 메세지를 보낸다든지, 클래스의 불변속성 및 사전 조건/사후 조건을 검증한다든지, 스레드 환경에서 동기화를 건다든지 하는 일이다. 델파이 및 C# 등의 언어를 쓰다가 C++로 넘어온 프로그래머들이 이 이야기를 들으면 ‘프로퍼티(property)’와 똑같은 것이라고 말할 것이다. 비록 C++에서는 괄호쌍을 더 붙여 주어야 한다는 점이 다르지만…
캡슐화에 대해 위에서 말한 부분은 우리가 처음 들었을 때 어떻게 느꼈을지 모르겠지만 그보다 훨씬 중요하다고 생각해야 한다. 사용자로부터 데이터 멤버를 캡슐화하여 숨기면, 클래스의 불변속성을 항상 유지하는 데 절대로 소홀해질 수 없게 된다. 불변속성을 보여줄 수 있는 통로가 멤버 함수밖에 없으니까 말이다. 그뿐 아니라 캡슐화는 현재의 구현을 나중에 바꾸기로 결정할 수 있는 권한을 예약하는 셈이다. 이러한 결정을 숨기지 않으면, 우리가 클래스의 소스 코드를 갖고 있더라도 public으로 되어 있는 부분에는 손을 대기가 힘들다는 사실에 속이 상할 수도 있다. 손댔다가는 사용자 코드가 깨질테니까. 무릇 C++ 세상에서 public이란 ‘캡슐화되지 않았다’는 뜻이며, 실질적인 측면에서 이야기할 때 ‘캡슐화되지 않았다’라는 말은 ‘바꿀 수 없다’라는 의미를 담고 있다. 널리 쓰이는 클래스일수록 특히 그렇다 사실 널리 쓰이는 클래스들이야말로 캡슐화가 가장 필요할 것이다. 내부 구현을 더 좋은 쪽으로 개선할 수 있는 기능의 혜택을 가장 많이 볼 수 있는 것들이니 말이다.
protected 데이터 멤버의 경우에도, 앞서 말한 사정과 비슷하다. 솔직히 말해서 똑같다. 문법적 일관성과 세밀한 접근 제어에 관한 이야기라면 public 데이터 멤버처럼 protected 데이터 멤버에도 그대로 적용할 수 있다. 그러나 캡슐화는 어떻게 되는 걸까? 어쨌든 public 데이터 멤버보다는 더 많이 가려져 있지 않는가? 실질적인 측면에서 말하면, 아니다.
항목 23을 보면 이런 내용이 나와 있다. 어떤 것이 바뀌면 깨질 가능성을 가진 코드가 늘어날 때 캡슐화의 정도는 그에 반비례해서 작아진다. 그러니까 데이터 멤버가 바뀌면, 다시 말해 클래스에서 제거되면 깨질 수 있는 코드의 양에 반비례해서 그 데이터 멤버는 캡슐화 정도가 감소한다는 것이다.
어떤 클래스에 public 데이터 멤버가 있고, 이것을 제거한다고 가정하자. 이 멤버에 매달려 있는 얼마나 많은 코드가 망가질까? 이것을 사용하는 사용자 코드는 전부 무사할 수 없을 것이다. 모르긴 해도 파악조차 힘들만큼 엄청 많을 것이다. 그렇기 때문에 public 데이터 멤버는 완전히 사용자에게 누드쇼를 한 거나 다름없다.
자, 이젠 어떤 protected 데이터 멤버를 제거한다고 가정해 보자. 이번엔 코드가 얼마나 망가질까? 이것을 사용하는 파생 클래스는 전부 망가질 것이다. 역시 파악조차 힘들만큼 많을 것이다 조금 가렸다고 가린게 아니다. protected 데이터 멤버나 public 멤버나 그게 그거란 말이다. 데이터 멤버가 바뀌면 이 멤버에 의존하는 다른 코드들이 헤아릴 수 없으리만치 망가지는 것은 마찬가지이기 때문이다. 아무리 생각해도 직관적으로 말이 안될 것이다. 하지만 라이브러리 구현 경험이 굉장히 많은 개발자가 말하는 것을 들어보면 정말이다. 어떤 데이터 멤버를 일단 public 혹은 protected로 선언했으며 사용자가 그것을 사용하기 시작했으면, 그때부터 그 멤버는 완전히 큰일난거다. 그 멤버에 대해 무엇을 바꾸기란 무척 힘들어진다. 바꿨다간 엄청난 양의 코드를 다시 써야 할 것이고, 테스트도 다시 해야 하고, 문서도 바꾸고, 컴파일도 다시 해야 한다. 캡슐화의 관점에서 쓸모 있는 접근 수준은 private(캡슐화 제공)와 private가 아닌 나머지(캡슐화 없음), 이렇게 둘 뿐이라고 생각해야 한다.
데이터 멤버는 private 멤버로 선언합시다. 이를 통해 클래스 제작자는 문법적으로 일관성 있는 데이터 접근 통로를 제공할 수 있고, 필요에 따라서는 세밀한 접근 제어도 가능하며, 클래스의 불변속성을 강화할 수 있을 뿐 아니라, 내부 구현의 융통성도 발휘할 수 있다.
protected는 public보다 더 많이 ‘보호’받고 있는 것이 절대 아니다.
항목 23 : 멤버 함수보다는 비멤버 비프렌드 함수와 더 가까워지자.
웹브라우저를 나타내는 클래스가 하나 있다고 가정하자. 웹브라우저 클래스라면 이런 저런 함수를 통해 제공하는 기능들이 많을 것이다. 웹브라우저로 다운로드한 파일들을 임시 저장한 캐시를 비우는 함수, 방문한 URL의 기록을 없애는 함수, 시스템이 갖고 있는 쿠키를 전부 제거하는 함수도 그 중에 속해있을 것이다.
1
2
3
4
5
6
7
8
9
class WebBrowser
{
public:
...
void clearCache();
void clearHistory();
void removeCookies();
...
};
하지만 사용자 중에는 이 세 동작을 한 번에 하고 싶은 사람들도 상당수 있기 때문에, 세 함수를 모아서 불러주는 함수도 준비해 둘 수 있을 것이다.
1
2
3
4
5
6
7
class WebBrowser
{
public:
...
void clearEverything(); // clearCache, clearHistory, removeCookies를 한 번에 하는 함수
...
};
물론 이 기능은 비멤버 함수로 제공해도 된다. 웹브라우저 객체의 멤버 함수를 순서대로 불러주기만 하면 된다.
1
2
3
4
5
6
void clearBrowser(WebBrowser& browser)
{
browser.clearCache();
browser.clearHistory();
browser.removeCookies();
}
어느 쪽이 더 괜찮을까? 멤버 버전 함수인가, 아니면 비멤버 버전인 함수일까??
객체 지향 법칙에 관련된 이야기를 찾아보면 데이터와 그 데이터를 기반으로 동작하는 함수는 한 데 묶여 있어야 하며, 멤버 함수가 더 낫다고들 한다. 하지만 불행히도 이 제안은 틀렸다. 객체 지향 방법이 무엇인가에 대해 제대로 이해하지 못한 상태에서 나온 제안이다. 분명히 객체 지향 법칙은 할 수 있는 만큼 데이터를 캡슐화하라고 주장하고 있다. 그러나 멤버 버전인 clearEverything 함수는 비멤버 버전인 clearBrowser보다 캡슐화 정도에서 오히려 형편없다. 이것 말고도, 비멤버 함수를 사용하면 WebBrowser 관련 기능을 구성하는 데 있어서 패키지 유연성이 더 높아지는 장점이 있는데다가, 이로 인해 얻게 되는 추가적인 이점으로 컴파일 의존도를 낮추고 WebBrowser의 확장성도 높일 수 있다. 그래서 비멤버 방법이 멤버 함수보다 여러모로 낫다는 이야기가 나오는 것이다. 어째서인지 하나씩 짚어 보도록 하자.
이야기는 캡슐화부터 시작한다. 어떤 것을 캡슐화하면, 우선 외부에서 이것을 볼 수 없게 된다. 캡슐화하는 것이 늘어나면 그만큼 밖에서 볼 수 있는 것들이 줄어든다. 밖에서 볼 수 있는 것들이 줄어들면, 그것들을 바꿀 때 필요한 유연성이 커진다. 변경 자체가 영향을 줄 수 있는 범위가 ‘변경된 것을 볼 수 있는 것들’로 한정되기 때문에 당연한 거다. 그러니까 캡슐화되는 것들이 많아지면, 그것들을 변경할 수 있는 여유도 많아진다. 바로 이것 때문에 우선 무엇보다도 캡슐화에 가치를 두는 것이다. 쉽게 말해, 이미 있는 코드를 바꾸더라도 제한된 사용자들밖에 영향을 주지 않는 융통성을 확보할 수 있다는 뜻이다.
어떤 객체의 모습을 그 객체의 데이터로 설명할 수 있다고 생각해 보자. 이 데이터를 직접 볼 수 있는(다시 말해 접근할 수 있는) 코드가 적으면 적을수록 그 데이터는 많이 캡슐화된 것이고, 그 객체가 가진 데이터의 특징을 바꿀 수 있는 자유도가 그만큼 높은 것이다. 데이터의 특징이라면 예를 들어 데이터 멤버의 개수, 타입 등등이겠다. 데이터 한 조각을 들여다볼 수 있는 코드가 얼마나 되는지에 대한 개략적인 측정값으로서, 데이터를 접근할 수 있는 함수의 개수를 셀 수 있을 것이다. 그러니까, 어떤 데이터를 접근하는 함수가 많으면 그 데이터의 캡슐화 정도가 낮다는 이야기이다.
항목 22를 보시면 데이터 멤버는 private 멤버이어야 한다는 잔소리가 좔좔 나온다. private 멤버가 아니면 이 데이터 멤버에 접근할 수 있는 함수의 개수를 프로그래머의 손으로 어떻게 제한할 수가 없기 때문이다. 이런 데이터 멤버는 캡슐화의 보호막이 전혀 없다고 봐도 된다. private 멤버로 되어 있는 데이터의 경우, 여기에 접근할 수 있는 함수의 개수는 예측이 쉽다. 그 클래스의 멤버 함수의 개수에 프렌드 함수의 개수를 더하면 딱 맞다. private 멤버는 멤버 함수 및 프렌드 함수만 접근할 수 있기 때문이다. 자, 똑같은 기능을 제공하는데 멤버 함수를 쓸 것이냐, 아니면 비멤버 비프렌드 함수를 쓸 것이냐를 이제 다시 생각해 보자. 캡슐화 정도가 더 높은 쪽을 고른다면 단연 후자일 것이다. 왜냐하면 비멤버 비프렌드 함수는 어떤 클래스의 private 멤버 부분을 접근할 수 있는 함수의 개수를 늘리지 않는다. 이것으로 clearBrowser(비멤버 비프렌드 함수)가 clearEverything(멤버 함수)보다 어째서 더 바람직한지에 대한 이유가 설명될 것 같다. WebBrowser 클래스에 대한 캡슐화 정도가 더 높은 것을 고른 것이다.
여기서 주의해야 할 부분 두 가지를 알아보자. 첫째, 이 이야기는 비멤버 비프렌드 함수에만 적용된다는 것이다. 프렌드 함수는 private 멤버에 대한 접근권한이 해당 클래스의 멤버 함수가 가진 접근권한과 똑같기 때문에, 캡슐화에 대한 영향 역시 같다. 캡슐화라는 관점에서 보았을 때, 위의 선택은 멤버 함수와 비멤버 함수 사이의 선택이 아니다. 잘 읽어보자. 멤버 함수와 비멤버 비프렌드 함수 사이의 선택이란 말이다.
주의해야 할 점 두 번째는, 캡슐화에 대한 이런저런 이야기 때문에 “함수는 어떤 클래스의 비멤버가 되어야 한다”라는 주장이 “그 함수는 다른 클래스의 멤버가 될 수 없다”라는 의미가 아니라는 것이다. 모든 함수를 클래스 안에 넣지 않으면 큰일 나는 언어(C# 등)에 길들여진 프로그래머라면 안도의 한숨을 쉴지도 모른다. 이를테면, clearBrowser 함수를 다른 유틸리티 클래스 같은 데의 정적 멤버 함수로 만들어도 된다는 이야기이다. 어쨌든 이 함수가 WebBrowser 클래스의 멤버 혹은 프렌드가 아니기만 하면 된다. WebBrowser가 가진 private 멤버의 캡슐화에 영향을 주지 않는다는 점이 중요한 것이다.
C++로는 더 자연스런 방법을 구사할 수 있다. clearBrowser를 비멤버 함수로 두되, WebBrowserStuff와 같은 네임스페이스 안에 두는 것이다.
1
2
3
4
5
6
namespace WebBrowserStuff
{
class WebBrowser { ... };
void clearBrowser(WebBrowser& wb);
...
}
사실 이건 자연스러움보다 몇 걸음 더 나아간 방법이라고 볼 수 있다. 왜냐하면 네임스페이스는 클래스와 달리 여러 개의 소스 파일에 나뉘어 흩어질 수 있기 때문이다. 지금 이 부분은 굉장히 중요한데, clearBrowser 같은 함수들은 편의상 준비한 함수들이기 때문이다. 멤버도 아니고 프렌드도 아니기에, WebBrowser 사용자 수준에서 아무리 애를 써도 얻어낼 수 없는 기능은 이들도 제공할 수 없다. 예를 들어, clear-Browser가 없다고 해도 사용자는 그냥 clearCache, clearHistory, removeCookies를 알아서 불러주면 되는 것이다.
WebBrowser처럼 응용도가 높은 클래스는 이런 종류의 편의 함수가 꽤 많이 생길 수 있다. 즐겨찾기에 관련된 함수라든지, 인쇄에 관련된 함수가 있을 수도 있고, 쿠키 관리용 함수도 충분히 가능하다. 일반적인 경우에는 웬만한 사용자라면 이들 편의 함수들 중 몇 개만 알고 있거나 관심을 둘 것이다. 즐겨찾기 기능에만 관심 있는 사용자가 구태여 다른 함수들, 뭐 이를테면 쿠키 관련 편의 함수에 대한 컴파일 의존성을 고민할 이유가 없단 말이다. 이것들을 나누어 놓는 쉽고 깔끔한 방법은, 즐겨찾기 관련 편의 함수를 하나의 헤더 파일에 몰아서 선언하고, 쿠키 관련 편의 함수는 다른 헤더 파일에 몰아서 선언하고, 인쇄 관련 편의 함수는 제3의 헤더에 몰아서 선언하는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// "webbrowser.h" - WebBrowser 클래스 자체에 대한 헤더
// 그리고 WebBrowser에 관련된 핵심 기능들이 선언되어 있음
namespace WebBrowserStuff
{
class WebBrowser { ... };
... // 핵심 관련 기능. 이를테면 거의 모든 사용자가 써야 하는 비멤버 함수들이 여기에 들어간다
}
// "webbrowserbookmarks.h"
namespace WebBrowserStuff
{
... // 즐겨찾기 관련 편의 함수들이 여기에 들어간다
}
// "webbrowsercookies.h"
namespace WebBrowserStuff
{
... // 쿠키 관련 편의 함수들이 여기에 들어간다
}
사실 표준 C++ 라이브러리가 이러한 구조로 구성되어 있다. std 네임스페이스에 속한 모든 것들이 <C++StandardLibrary> 헤더 같은 것에 모조리 들어가 한 통으로 섞여 있지 않고, 몇 개의 기능과 관련된 함수들이 수십 개의 헤더에 흩어져 선언되어 있다. vector 기능만 필요한 사용자는 굳이 를 #include하지 않아도 된다. 이렇게 하면, 사용자가 실제로 사용하는 구성요소에 대해서만 컴파일 의존성을 고려할 수 있게 되는 거다. 반면 클래스 멤버 함수로 오게 되면 이야기가 암울해진다. 이런 식으로 기능을 쪼개는 것 자체가 불가능하다. 하나의 클래스는 그 전체가 통으로 정의되어야 하고 여러 조각으로 나눌 수가 없기 때문이다.
편의 함수 전체를 여러 개의 헤더 파일에(그러나 하나의 네임스페이스에) 나누어 놓으면 편의 함수 집합의 확장도 손쉬워진다. 해당 네임스페이스에 비멤버 비프렌드 함수를 원하는 만큼 추가해 주기만 하면 그게 확장이다. 예를 들어, WebBrowser를 잘 쓰고 있던 사용자가 어쩌다가 다운로드 이미지에 관련된 편의 함수를 만들어야 하겠다고 작정했다면, 헤더 파일 하나를 새로 만든 후에 WebBrowserStuff 네임스페이스를 만들고 그 안에 관련 함수의 선언문만 끼워 넣으면 끝이라는 거다. 이렇게 새로 추가된 함수는 기존의 다른 편의 함수들처럼 지금 바로 사용할 수 있으며 WebBrowserStuff의 구성요소로 바로 합쳐진다. 이런 부분 역시 클래스로는 제공이 불가능한 기능이다. 클래스 정의 자체를 사용자가 확장할 수는 없으니까! 아 물론 새로운 클래스를 파생시킬 수 있기는 하다. 하지만 파생 클래스는 기본 클래스 안에 캡슐화된 멤버에 대한 접근권한이 없기 때문에, 이런 식의 확장 기능은 항공권으로 치면 이등석 티켓 정도이다. 게다가 항목 7에서도 말했지만 모든 클래스가 기본 클래스로 쓸 용도로 설계된 것은 아니다.
멤버 함수보다는 비멤버 비프렌드 함수를 자주 쓰도록 하자. 캡슐화 정도가 높아지고, 패키징 유연성도 커지며, 기능적인 확장성도 늘어진다.
참고