Post

[Effective C++] 5. 구현 [2/3]

[Effective C++] 5. 구현 [2/3]

이 글은 제 개인적인 공부를 위해 작성한 글입니다.
틀린 내용이 있을 수 있고, 피드백은 환영합니다.


항목 28 : 내부에서 사용하는 객체에 대한 ‘핸들’을 반환하는 코드는 되도록 피하자


사각형을 사용하는 어떤 응용 프로그램을 만들고 있다. 사각형은 좌측 상단 및 우측 하단을 꼭짓점 두 개로 나타낼 수 있다, 이것을 추상화한 Rectangle 클래스를 만들었는데, 이 클래스의 객체를 썼을 때의 메모리 부담을 최대한 줄이고 싶다는 생각이 우리 머리를 스쳤다. 사각형의 영역을 정의하는 꼭짓점을 Rectangle 자체에 넣으면 안될 것 같고, 이것들을 별도의 구조체에 넣은 후에 Rectangle이 이 구조체를 가리키도록 하면 어떨까 하는 생각이 들었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct Point
{
public:
    Point(int x, int y);
    ...
    void setX(int newVal);
    void setY(int newVal);
    ...
 
};

struct RectData
{
    Point ulhc; // ulhc = 좌측 상단(upper left-hand corner)
    Point lrhc; // lrhc = 우측 하단(lower right-hand corner)
};

class Rectangle
{
...
private:
    std::tr1::shared_ptr<RectData> pData;
};

Rectangle 클래스의 사용자는 분명히 영역정보를 알아내어 쓸 떄가 있을 것이므로, Rectangle 클래스에는 upperLeft 및 lowerRight 함수가 멤버 함수로 들어있다. 그런데 그러고 보니 Point가 사용자 정의 타입인 것이 눈에 들어오면서 항목 20에서 읽었던 내용이 떠오르기 시작한다. 사용자 정의 타입을 전달할 때는 값에 의한 전달보다 참조에 의한 전달방식을 쓰는 편이 더 효율적이라고 누군가가 부르짖었던 것 같다. 그래서 이들 두 멤버 함수는 포인터로 물어둔 Point 객체에 대한 참조자를 반환하는 형태로 만들어졌다.

1
2
3
4
5
6
7
8
class Rectangle
{
public:
    ...
    Point& upperLeft() const { return pData->ulhc; }
    Point& lowerRight() const { return pData->lrhc; }
    ...
};

컴파일은 잘 된다. 그런데 결정적으로 틀렸다. 조금만 들여다보면 자기모순적인 코드 임을 알 수 있다. 우선 upperLeft 함수와 lowerRight 함수가 어떻게 선언되어 있는지 보자. 상수 멤버 함수이다. 원래 Rectangle의 꼭짓점 정보를 알아낼 수 있는 방법만 사용자에게 제공하고, Rectangle 객체를 수정하는 일은 할 수 없도록 설계되었다. 그런데 이 함수들이 반환하는 게 어떤 건지 보자. private 멤버인 내부 데이터에 대한 참조자이다. 호출부에서 이 참조자를 써서 내부 데이터를 맘대로 수정해도 좋다는 뜻이 되어버린다! 그러니까 다음과 같이 쓰면,

1
2
3
4
5
6
Point coord1(0, 0);
Point coord2(100, 100);

const Rectangle rect(coord1, coord2); // (0,0) ~ (100,100)

rect.upperLeft().setX(50); // (50, 0) ~ (100, 100)

upperLeft를 호출한 쪽은 rec의 은밀한 곳에 숨겨진 Point 데이터 멤버를 참조자로 끌어와 척척 바꿀 수 있다는 것이다. 하지만 rec은 상수 객체로 선언된 것 아닌가?

우리는 여기서 두 가지 교훈을 얻을 수 있다.

첫째, 클래스 데이터 멤버는 아무리 숨겨봤자 그 멤버의 참조자를 반환하는 함수들의 최대 접근도에 따라 캡슐화 정도가 정해진다는 점이다. 말이 좀 어렵지만, 지금의 경우를 놓고 설명하면 ulhc와 lrhc는 private으로 선언되어 있는데, 실질적으로는 public 멤버이다. 왜냐하면 이들의 참조자를 반환하는 함수들이 public으로 선언되어 있기 때문이다.

둘째, 어떤 객체에서 호출한 상수 멤버 함수의 참조자 반환 값의 실제 데이터가 그 객체의 바깥에 저장되어 있다면, 이 함수의 호출부에서 그 데이터의 수정이 가능하다는 점이다.

지금은 참조자를 반환하는 멤버 함수만 붙들고 열심히 이야기하고 있는데, 만약 이들이 포인터나 반복자를 반환하도록 되어 있었다 해도 마찬가지 이유로 인해 마찬가지 문제가 생긴다. 참조자, 포인터 및 반복자는 어쨌든 모두 핸들(handle, 다른 객체에 손을 댈 수 있게 하는 매개자)이고, 어떤 객체의 내부요소에 대한 핸들을 반환하게 만들면 언제든지 그 객체의 캡슐화를 무너뜨리는 위험을 무릅쓸 수 밖에 없다. 우리도 보았지만, 이것 때문에 상수 멤버 함수조차도 객체 상태의 변경을 허용하는 지경에까지 이를 수 있다.

어떤 객체의 ‘내부요소’라고 하면 흔히들 데이터 멤버만 생각하는 사람들이 많은데, 일반적인 수단으로 접근이 불가능한 (protected이나 private으로 선언된) 멤버 함수도 객체의 내부요소에 들어간다. 그러니 어떻겠는가? 이들에 대한 핸들도 반환하지 말아야 한다. 즉, 외부 공개가 차단된 멤버 함수에 대해, 이들의 포인터를 반환하는 멤버 함수를 만드는 일이 절대로 없어야 한다는 말이다. 이런 함수가 하나라도 들어가는 순간부터 실질적인 접근 수준이 바뀐다. 당연히, 멤버 함수 포인터를 반환한느 함수의 접근도에 맞춰지는 것이다. protected 혹은 private 멤버로 선언된 함수라 해도 사용자 측면에서는 얼마든지 이들의 포인터를 얻어내어 호출해 버릴 수 있다.

하지만 멤버 함수의 포인터를 반환하는 함수가 그렇게 흔치 않은 것이 사실이므로, 다시 Rectangle 클래스와 그의 멤버 함수인 upperLeft 및 lowerRight의 이야기로 돌아가자. 이들 멤버 함수가 가진 문제 두 개는 위에서 함께 봤는데, 이렇게 하면 둘 다 간단히 해결된다. 반환 타입에 cosnt를 붙여주자.

1
2
3
4
5
6
7
8
class Rectangle
{
public:
    ...
    const Point& upperLeft() const { return pData->ulhc; }
    const Point& lowerRight() const { return pData->lrhc; }
    ...
};

이렇게 설계하면 사용자는 사각형을 정의하는 꼭짓점 쌍을 읽을 수는 있지만 쓸 수는 없게 된다. 말하지만 upperLeft와 lowerRight에 const를 붙여 선언한 게 이젠 거짓말이 아니라는 이야기다. 호출부에서 객체의 상태를 바꾸지 못하도록 컴파일러 수준에서 막고 있다. 그리고 캡슐화 문제를 보면, 사용자들이 Rectangle을 구성하는 Point를 들여다보도록 하자는 것은 처음부터 알고 시작한 설계이기 때문에, 이 부분은 의도적인 캡슐화 완화라고 할 수 있다. 이보다 더 중요한 부분은 느슨하게 만든 데에도 제한을 두었다는 것이다. 읽기 접근만 주어지고, 쓰기 접근은 여전히 금지다.

뭔가 한 것 같지만 그래도 찝찝하다. upperLeft와 lowerRight 함수를 보면 내부 데이터에 대한 핸들을 반환하고 있는 부분이 남아 있다. 이것을 남겨두면 다른 쪽에서 문제가 될 수 있다. 가장 큰 문제가 무효참조 핸들(dangling handle)로서, 핸들이 있기는 하지만 그 핸들을 따라 갔을 때 실제 객체의 데이터가 없는 것이다. 핸들이 물고 있는 객체가 기약도 없이 어디론가 증발하는 현상은 함수가 객체를 값으로 반환할 경우에 가장 흔하게 발생된다. 에를 하나 들어보자. 어떤 GUI 객체의 사각 테두리 영역을 Rectangle 객체로 반환하는 함수가 있다고 가정하자.

1
2
class GUIObject { ... };
const Rectangle boudingBox(const GUIObject& obj);

이 상태에서 어떤 사용자가 이 함수를 사용한다고 생각해 보자.

1
2
3
GUIObject* pgo; // pgo를 써서 임의의 GUIObject를 가리키도록 한다.
...
cosnt Point* pUpperLeft = &(boundingBox(*pgo).upperLeft());

가장 마지막 문장을 보면, boundingBox 함수를 호출하면 Rectangle 임시 객체가 새로 만들어진다. 이 객체는 겉으로 드러나는 이름 같은 것이 없으므로 일단 temp라고 부르자. 다음엔 이 temp에 대해 upperLeft가 호출될 텐데, 이 호출로 인해 temp의 내부 데이터, 정확히 말하면 두 Point 객체 중 하나에 대한 참조자가 나온다. 마지막으로 이 참조자에 & 연산자를 건 결과 값(주소)이 pUpperLeft 포인터에 대입되는 것이다. 여기까지는 대충 행복하지만, 아직 끝나기엔 이르다. 이 문장이 끝날 무렵, boundingBox 함수의 반환 값(임시 객체인 temp)이 소멸된다는 사실을 넘어가면 안 된다. temp가 소멸되니, 그 안에 들어 있는 Point 객체들오 덩달아 없어질 것이다. 자 그러면, pUpperLeft 포인터가 가리키는 객체는 이제 날아가고 없게 된다. 다시 말해 이 문장은 pUpperLeft에게 객체를 달아 줬다가 주소 값만 남기고 몽땅 빼앗아 간 거란 말이다.

객체의 내부에 대한 핸들을 반환하는 함수는 어떻게든 위험하다는 말이 이래서 나오는 것이다. 핸들이 무엇이냐는 상관없다. 포인터/참조자/반복자 모두 위험하기는 마찬가지다. 핸들에 const를 붙였느냐 안 붙였느냐도 상관없다. 핸들을 반환하는 함수가 상수 멤버냐 아니냐도 상관없다. 핸들을 반환하는 함수라는 사실, 그것 빼고는 아무것도 중요하지 않다. 일단 바깥으로 떨어져 나간 핸들은 그 핸들이 참조하는 객체보다 더 오래 살 위험이 있기 때문이다.

그렇다고 해서 핸들을 반환하는 멤버 함수를 절대로 두지 말라는 이야기가 아니다. 이번 항목의 제목을 보면, ‘피하자’이다. 어쩌다 보면 필요할 때도 있다 예를 들어 operator[] 연산자는 string이나 vector 등의 클래스에서 개개의 원소를 참조할 수 있게 만드는 용도로 제공되고 있는데, 실제로 이 연산자는 해당 클래스에 들어 있는 개개의 원소 데이터에 대한 참조자를 반환하는 식으로 동작한다. 물론 이 원소 데이터는 컨테이너가 사라질 때 같이 사라지는 데이터다. 하지만 이런 함수는 예외적인 것이고 일반적인 규칙은 아니다.

어떤 객체의 내부요소에 대한 핸들(참조자, 포인터, 반복자)을 반환하는 것은 되도록 피하자. 캡슐화 정도를 높이고, 상수 멤버 함수가 객체의 상수성을 유지한 채로 동작할 수 있도록 하며, 무효참조 핸들이 생기는 경우를 최소화할 수 있다.


항목 29 : 예외 안전성이 확보되는 그날 위해 싸우고 또 싸우자!


작성 중 ~


참고

This post is licensed under CC BY 4.0 by the author.