Post

[Effective C++] 4. 설계 및 선언 [2/4]

[Effective C++] 4. 설계 및 선언 [2/4]

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


항목 20 : ‘값에 의한 전달’보다는 ‘상수객체 참조자에 의한 전달’ 방식을 택하는 편이 대개 낫다.


기본적으로 C++는 함수로부터 객체를 전달받거나 함수에 객체를 전달할 때 ‘값에 의한 전달’ 방식을 사용한다. (C에서 물려받은 특성 중 하나) 특별히 다른 방식을 지정하지 않는 한, 함수 매개변수는 실제 인자의 사본을 통해 초기화되며, 어떤 함수를 호출한 쪽은 그 함수가 반환한 값의 사본을 돌려받는다. 이들 사본을 만들어내는 원천이 바로 복사 생성자이다. 이 점 때문에 값에 의한 전달이 고비용의 연산이 되기도 한다. 어째서 그럴까? 아래 클래스 계통을 보고 생각해 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Person
{
public:
    Person();
    virtual ~Person();
    ...
    
private:
    std::string name;
    std::string address;
};

class Student : public Person
{
public:
    Student();
    ~Student();
    ...
    
private:
    std::string schoolName;
    std::string schoolAddress;
};

이제 아래의 코드를 봐 보자. validateStudent라는 함수를 호출하고 있는데, 이 함수는 Student 인자를 값으로 전달받고 이 인자가 유효화됐는가를 알려 주는 값을 반환한다.

1
2
3
bool validateStudent(Student s)
Student plato;
bool platoIsOK = validateStudent(plato);

이 함수가 호출될 때 어떤 일이 일어날까?

확실한 것부터 알아보자. plato로부터 매개변수 s를 초기화시키기 위해 Student의 복사 생성자가 호출될 것이다. 게다가 s는 validateStudent가 복귀할 때 소멸될 것이다. 정리하면, 이 함수의 매개변수 전달 비용은 Student의 복사 생성자 호출 한 번, 그리고 Student의 소멸자 호출 한 번이 될 것이다.

아직 끝이 아니다. Student 객체에는 String 객체 두 개가 들어 있기 때문에, Student 객체가 생성될 때마다 이들 String 객체도 생성될 것이다. 게다가 Student 객체는 Person 객체로부터 파생되었기 때문에, Student 객체가 생성되면 Person 객체도 먼저 생성되어야 한다. Person 객체 안에는 또 String 객체 두 개가 들어 있기 때문에, Person 객체가 매번 생성될 때 String 생성자가 두 번 더 불리게 된다. 최종 결과는 갑갑하다.

단지 Student 객체 하나를 값으로 전달했을 뿐인데

  1. Student의 복사 생성자 호출 1회
  2. Person 복사 생성자 호출 1회
  3. String 복사 생성자 호출 4회
  4. Student 소멸자 호출 1회
  5. Person 소멸자 호출 1회
  6. String 소멸자 호출 4회

총 생성자 6회, 소멸자 6회가 일어나게 된다.

틀린 것도 아니고 또 바라지 않았던 동작도 아니긴 하다. 어쨌든 우리가 원한대로 제대로 생성되고 소멸되고 있다. 하지만, 생성자 소멸자 호출을 몇 번씩 하지 않고 넘어갈 수 있다면 더 좋을 것이다. 그러기 위해서 상수객체에 대한 참조자로 전달하게 만드는 것이다.

1
bool validateStudent(const Student& s)

이렇게 하면 순식간에 훨씬 효율적인 코드로 바뀐다. 새로 만들어지는 객체 같은 것이 없기 때문에, 생성자 소멸자가 전혀 호출되지 않는다. 여기서 새겨둬야 할 부분이 매개변수 선언문에 있는 const이다. 이것이 아주 중요하다. 원래는 validateStudent는 Student 매개변수를 값으로 받도록 되어 있기 때문에, 호출부에서는 함수로 전달된 Student 객체에 어떤 변화가 생기더라도 그 변화로부터 안전하게 보호를 받는다는 점을 알고 있다. 그도 그럴 것이 validateStudent가 상대하는 Student 객체는 원본이 아닌 사본이니까이다. 그런데 이제는 Student 객체의 전달 방식이 참조에 의한 전달이다. 매개변수 앞에 const가 붙은 건 바로 그 때문인데, 이것이 붙지 않으면 validateStudent 함수로 넘어간 Student 객체가 변할지도 모른다는 걱정을 호출부가 해야 한다.

참조에 의한 전달 방식으로 매개변수를 넘기면 복사손실 문제(slicing problem)가 없어지는 장점도 있다. 파생 클래스 객체가 기본 클래스 객체로서 전달되는 경우는 드물지 않게 접할 수 있는데, 이때 이 객체가 값으로 전달되면 기본 클래스의 복사 생성자가 호출되고, 파생 클래스 객체로 동작하게 해 주는 특징들이 잘려 떨어지고 만다. 우리의 손에 남는 것은 기본 클래스 객체 뿐이다. 당연한 것이 기본 클래스 생성자가 만들었으니까이다. 우리가 원한 결과는 이것이 아닐 것이다. 예를 하나 더 들어보자. 그래픽 기반의 윈도우 시스템을 구현한 클래스 라이브러리를 써서 어떤 작업을 하고 있다고 가정하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Window
{
public:
    ...
    std::string name() const;
    virtual void display() const;
};

class WindowWithScrollBar : public Window
{
public:
    ...
    virtual void display() const;
};

이 Window 클래스로 만들어지는 객체는 이름을 갖고 있으며, 이 이름은 name 멤버 함수로 얻어낸다. 게다가 화면 표시도 가능한데, display 멤버 함수를 호출하면 윈도우를 화면에 뿌릴 수 있다. 그리고 display가 가상 함수로 선언되어 있다는 점에서, 그저 그런 Window 객체의 화면표시 방식은 WindowWithScrollBar 객체에서 그보다 잘 빠진 모습으로 바뀔 수 있다는 단서도 발견할 수 있다.

이제 이것을 가지고 어떤 윈도우의 이름을 출력하고 그 윈도우를 화면에 표시하는 함수를 하나 만들어보자. 우선 틀리게 구현한 버전을 가지고 이야기를 해보자.

1
2
3
4
5
void printNameAndDisplay(Window w)
{
    std::cout << w.name();
    w.display();
}

매개변수가 복사손실에 당하고 말았다. 이 함수에 WindowWithScrollBar 객체를 넘긴다면 어떤 일이 생길지 생각해 보았는가?

1
2
WindowWithScrollBar wwsb;
printNameAndDisplay(wwsb);

매개변수 w가 생성되기는 하는데(값에 의한 전달), Window 객체로 만들어지면서 wwsb가 WindowWithScrollBar 객체의 구실을 할 수 있는 부속 정보가 전부 잘려 나가 버린다. printNameAndDisplay 함수 안에서 이리저리 굴러다닐 w는 어떤 타입의 객체가 넘겨지든 아랑곳없이 Window 클래스 객체의 면모만을 갖게 된다. 특히, printNameAndDisplay 안에서 호출되는 display는 Window::display가 될 것이다.

복사 손실 문제에서 도망가려면, w를 상수객체에 대한 참조자로 전달하도록 만들면 된다.

1
2
3
4
5
void printNameAndDisplay(const Window& w)
{
    std::cout << w.name();
    w.display();
}

이제 w는 어떤 종류의 윈도우가 넘겨지더라도 그 윈도우의 성질을 가지게 된다.

C++ 컴파일러의 동작 원리에 관심 있는 분이라면, 참조자는 보통 포인터를 써서 구현된다는 사실을 알고 있을 것이다. 즉, 참조자를 전달한다는 것은 결국 포인터를 전달한다는 것과 일맥상통한다는 이야기다. 이렇게 따져 보면, 전달하는 객체의 타입이 기본제공 타입(int 등)일 경우에는 참조자로 넘기는 것보다 값으로 넘기는 편이 더 효율적일 때가 많다. 그러니까 ‘값에 의한 전달’ 및 ‘상수객체의 참조에 의한 전달’ 중 하나를 선택해야 할 때, 기본제공 타입에 대해서는 ‘값에 의한 전달’을 선택하더라도 엉터리가 아니라는 이야기이다. 이 점은 STL의 반복자와 함수 객체에도 마찬가지이다. 예전부터 반복자와 함수 객체는 값으로 전달되도록 설계해 왔기 때문이다. 참고로, 반복자와 함수 객체를 구현할 때는 반드시 1. 복사 효율을 높일 것과 2. 복사손실 문제에 노출되지 않도록 만드는 것이 필수이다.

기본 제공 타입은 작다. 이 점에 착안해서 좀 앞서 나가는 사람들이 있다. 타입 크기만 작으면 전부 ‘값에 의한 전달’을 할 수 있다고 생각하는 것이다. 사용자 정의 타입이라도 말이다. 그냥 크기가 작으니까 그 객체의 복사 생성자 호출 비용이 저비용이란 뜻이 아니다. 데이터 멤버라고 해 봐야 달랑 포인터 하나인 객체가 꽤 많이 널려 있긴 하지만, 이런 객체를 복사하는 데는 그 포인터 멤버가 가리키는 대상까지 복사하는 작업도 따라다녀야 한다. 크기가 작아도 비쌀 수 있다.

그럼, 객체가 크기가 작고 복사 생성자도 그다지 비싸지 않게 만들어졌다고 가정하자. 하지만 그럼에도 수행 성능 문제가 발목을 잡을 수 있다. 컴파일러 중에는 기본제공 타입과 사용자 정의 타입을 아예 다르게 취급하는 것들이 있다. 기본제공 타입과 사용자 정의 타입의 하부 표현구조가 같아도 말이다. 이를테면 진짜 double은 레지스터에 넣어 주지만, double 하나로만 만들어진 사용자 정의 타입 객체는 레지스터에 넣지 않는 것이다. 이런 개발 환경에서 일하는 사람들은 차라리 참조에 의한 전달을 쓰는 편이 낫다. 포인터(참조자)만큼은 레지스터에 확실히 들어가니까.

크기가 작다고 해서 작은 사용자 정의 타입을 무조건 값으로 전달할 수 없는 이유가 하나 더 있다. 사용자 정의 타입의 크기는 언제든 변화에 노출되어 있다는 것이다. 지금은 크기가 작을지 몰라도 나중에는 커질지도 모르는 노릇이다. 개발도구의 내부 구현은 언제든 바뀔 수 있다. 심지어 우리가 다른 C++ 구현 환경으로 바꿀 때조차도 사정이 변할 수 있다.

마무리하자면, 일반적으로 ‘값에 의한 전달’이 저비용이라고 가정해도 괜찮은 유일한 타입은 기본제공 타입, STL 반복자, 함수 객체 타입, 이렇게 세 가지뿐이다. 이 외에 타입에 대해서는 상수객체 참조차에 의한 전달이 낫다.

‘값에 의한 전달’보다는 ‘상수객체 참조자에 의한 전달’을 선호하자. 대체적으로 효율적일 뿐만 아니라 복사손실 문제까지 막아준다.
기본제공 타입, STL 반복자, 함수 객체는 ‘값에 의한 전달’이 더 적절하다.


항목 21 : 함수에서 객체를 반환해야 할 경우에 참조자를 반환하려고 들지 말자.


‘값에 의한 전달’에 숨겨진 효율을 알아챈 프로그래머들 중에, 기존 코드에 멀쩡하게 들어 있는 ‘값에 의한 전달’ 부분이 비효율적이라 생각하여 이것들을 바꾸려는 개발자들을 종종 보게 된다. 급기야 실제로 있지도 않는 객체의 참조자를 넘기기 시작하는데, 정말 좋지 않다.

유리수를 나타내는 클래스가 하나 있다고 가정하자. 이 클래스에는 두 유리수를 곱하는 멤버 함수가 선언되어 있다.

1
2
3
4
5
6
7
8
9
10
class Rational
{
public:
    Rational(int numerator = 0, int denominator = 1); // 생성자가 explicit이 아닌 이유는 항목 24에서 확인하자
    ...
private:
    int n, d; // 분자 및 분모
    
    friend const Rational operator*(const Rational& lhs, const Rational& rhs);
};

이 클래스의 operator*는 곱셈 결과를 값으로 반환하도록 선언되어 있다. Rational 객체의 생성과 소멸에 들어가는 비용을 신경 쓰지 않고 이렇게 했다면 우리는 직무유기를 하고 있을지도 모른다. 꼭 이런 객체에 비용을 날릴 필요가 없으면 실제로도 그러고 싶지 않을 것이다. 정말 여기에 비용을 들여야 할까?

값이 아닌 참조자를 반환할 수 있으면 비용 부담은 확실히 없을 것이다. 하지만 참조자에 대해 한 번만 더 생각해 보자. 참조자는 그냥 이름이다. 존재하는 객체에 붙는 다른 이름이라는 것이다. 우리가 지금까지 보아온 참조자 선언문을 전부 떠올려 보면, “이게 무엇에 대한 이름이게?”라고 묻는 부분이 반드시 붙어 왔을 것이다. 당연하게도, 참조자는 어떤 것에 대한 ‘또 다른’ 이름이어야 하기 때문이다. 다시 operator*를 들여다 보자. 이 함수가 참조자를 반환하도록 만들어졌다면, 이 함수가 반환하는 참조자는 반드시 이미 존재하는 Rational 객체의 참조자여야 한다 이 객체에는 곱셈이 가능한 두 객체의 곱이 들어 있어야 하는 것은 말할 필요도 없다.

그럼, 반환될 객체는 어디에 있을까? operator* 호출 전에 어디엔가 생겼겠지 라고 생각하는 사람은 없어야 한다. 무슨 말이냐면,

1
2
3
Rational a(1, 2); // a = 1/2
Rational b(3, 5); // b = 3/5
Rational c = a * b; // c = 3/10

위의 코드에서 10분의 3이라는 값을 가진 유리수가 이미 생겨 주지 않을까 하는 기대를 걸면 난감하다는 이야기이다. C++ 세상엔 거저가 없다. 그 유리수 객체에 대한 참조자를 operator*에서 반환할 수 있으려면, 그 유리수 객체를 직접 생성해야 한다는 말이다.

함수 수준에서 새로운 객체를 만드는 방법은 딱 두 가지뿐이다. 스택에 만드는 것 혹은 힙에 만드는 것이다. 우선 전자의 방법부터 보자. 스택에 객체를 만들려면 지역 변수를 정의하면 된다. 그럼 이 방법을 써서 operator*를 구현해 보자.

1
2
3
4
5
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
    Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
    return result;
}

이런 방법은 손에 묻히는 것조차도 피했으면 좋겠다. 생성자가 불리는 게 싫어서 시작한 일인데, 결국 result가 다른 객체처럼 생성되어야 한다. 그뿐인가? 더 심각한 문제도 있다. 이 연산자 함수는 result에 대한 참조자를 반환하는데, result는 지역 객체이다. 다시 말해 함수가 끝날 때 덩달아 소멸되는 객체이다. 그러니까, 이 operator*는 현재 온전한 Rational 객체에 대한 참조자를 반환하지 않는다. 지금 이 참조자가 가리키고 있는 대상은 소멸자가 호출되어 버린 Rational 객체였던 놈이다. 당연히, 반환 타입에 속아 이 함수를 호출한 쪽은 그 즉시 미정의 동작에 빠지게 된다. 실제로, 지역 객체에 대한 참조자나 포인터를 반환하는 함수는 어떤 함수든지 프로그램의 핵폭탄이 된다.

자, 다음은 후자의 방법을 살펴보자. 함수가 반환할 객체를 힙에 생성해 뒀다가 그 녀석의 참조자를 반환하는 것은 어떨까? 힙 기반 객체는 new 연산자를 통해 생성할 수 있다. 그럼 operator* 함수를 힙 기반으로 만들어 보자.

1
2
3
4
5
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
    Rational* result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
    return *result;
}

여전히 생성자가 한 번 호출되기는 매한가지이다. new로 할당한 메모리를 초기화할 때 생성자가 호출되니 말이다. 그러나 이것 말고 다른 문제가 하나 더 있다. 여기서 new로 생성한 객체를 대체 누가 delete로 소멸시켜 줄 것인가?

이 함수를 호출하는 사람이 아무리 메모리 누출을 막으려고 노력해도 한계가 있다. 아래의 코드를 보자. 말도 안 되는 코드가 아닌데도 메모리 누출엔 속수무책이다.

1
2
Rational w, x, y, z;
w = x * y * z; // operator*(operator*(x, y), z) 와 같다.

여기서는 한 문장 안에서 operator* 호출이 두 번 일어나고 있기 때문에, new에 짝을 맞추어 delete를 호출하는 작업도 두 번 필요하다. 그런데 operator의 사용자 쪽에서 이렇게 할 수 있는 합당한 방법이 없다. operator로부터 반환되는 참조자 뒤에 숨겨진 포인터에 대해서는 사용자가 어떻게 접근할 방법이 없기 때문이다. 자원 누출이 보장되는 코드이다.

이야기를 살짝 돌려보자. 지금까지 살펴본 두 개의 코드는 한 가지 문제를 똑같이 가지고 있었다. 아마 스택 기반으로 하든 힙 기반으로 하든 operator에서 반환되는 결과는 반드시 생성자를 꼭 한 번 호출했을 거다. 그런데, 필요 없는 생성자 호출을 피해 보자는 것이 처음 세운 목표이다. 그렇다면 Rational 객체를 정적 객체로 함수 안에 정의해 놓고 이것의 참조자를 반환하는 식으로 operator를 구현하는 것은 어떨까?

1
2
3
4
5
6
7
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
    static Rational result; // 정적 객체
    result.n = lhs.n * rhs.n;
    result.d = lhs.d * rhs.d;
    return result;
}

정적 객체를 사용하는 설계가 항상 그러하듯, 이 코드 역시 스레드 안전성 문제가 얽혀 있다. 하지만 이보다 더 확실한 약점이 있는데, 아래에 멀쩡한 코드를 보며 고민해 보자.

1
2
3
4
5
6
7
8
9
10
11
12
bool operator==(const Rational& lhs, const Rational& rhs)
{
    Rational a, b, c, d;
    ...
    if ((a * b) == (c * d))
    {
         유리수 쌍의 곱이 서로 같으면 적절한 처리를 수행
    }
    else
    {
        다르면 적절한 처리를 수행
    }

위의 코드에서 ((a * b) == (c * d)) 표현식이 항상 true를 내게 된다. a, b, c, d에 어떤 값이 들어가도 말이다. 이해하기 쉽게 표현식을 아래처럼 함수 형태로 동등하게 바꿔보자.

1
if (operator==(operator*(a, b), operator*(c, d)))

operator== 함수가 호출될 때를 곰곰히 따져 보자. 이때 분명 두 개의 operator* 함수 호출이 활성화 되어 있을 것이고, 각각의 호출을 통해 operator* 안에 정의된 정적 Rational 객체의 참조자가 반환될 것이다. operator==이 비교하는 피연산자는 operator* 안의 정적 Rational 객체의 값, 그리고 operator* 안의 정적 Rational 객체의 값이다. 이 둘은 당연하게도 항상 같다.

이 정도면 operator* 등의 함수에서 참조자를 반호나하는 것만큼 시간낭비는 없다고 생각해도 되지만, 어떤 사람은 정적 데이터가 하나니까 이런 문제가 발생하는 것이고 정적 배열을 쓰면 괜찮지 않을까 생각할지도 모르겠다.

하지만 이 방법도 옳지 않다. 정적 배열의 크기인 n부터 정해야 할 것이고, n은 작아도 고민, 커도 고민이다. n이 너무 작으면 반환 값을 저장할 공간이 부족해질 것이다. 정적 데이터 하나를 사용한 설계에서 별로 나아진 게 없는 상태이다. 그렇다고 n이 너무 크면 프로그램의 수행 성능이 떨어진다. 함수가 가장 처음 실행될 때 배열 안의 모든 객체가 생성되는 걸 잊지 말자. 비록 딱 한 번뿐이지만, 이때 생성자가 n번 호출되고 유효범위가 끝날 때 소멸자가 n번 호출된단 말이다. 수행 성능을 높이기 위해 어떻게든 하는 작업이 최적화인데, 지금 하고 있는 일은 부적화(pessimization)라고 불러야 할 것이다. 이것으로 끝나지 않는다. 배열 안의 객체에 값을 어떻게 넣을 것이며, 비용이 얼마나 들지 생각해 보자. 객체 사이에 값을 옮기는 가장 직접적인 수단은 대입일 것이고, 그렇다면 대입에 어떤 비용이 들어갈까? 전부는 아니지만, 많은 타입의 경우 소멸자와 생성자가 한 번씩 호출되어야 한다.(이전 값을 없애는 데와 새 값을 복사해 오는 데) 이쯤 되면 달라도 한참 다른 길로 들어선 것 같다. 생성자와 소멸자 호출을 피하는 것이 목표였는데..

새로운 객체를 반환해야 하는 함수를 작성하는 방법에는 올바른 길이 있다. 바로 ‘새로운 객체를 반환하게 만드는 것’이다. 그러니까 Rational의 operator*는 아래처럼 혹은 아래와 비슷하게 작성해야 한다.

1
2
3
4
inline const Rational operator*(const Rational& lhs, const Rational& rhs)
{
    return Rational(lhs.n * rhs.n, lhs.d * rhs.d);
}

이 코드에도 반환 값을 생성하고 소멸시키는 비용이 들어 있지 않냐고 할 수 있는데, 맞다. 그러나 끝까지 따져 보면 여기에 들어가는 비용은 올바른 동작에 지불되는 작은 비용이다. 모든 프로그램이 언어가 그러하듯, C++에서도 다 컴파일러 구현자들이 가시적인 동작 변경을 가하지 않고도 기존 코드의 수행 성능을 높이는 최적화를 적용할 수 있도록 배려해 두었다. 그 결과, 몇몇 조건하에서는 이 최적화 매커니즘에 의해 operator*의 반환 값에 대한 생성과 소멸 동작이 안전하게 제거될 수 있다. (RVO, return value optimization 반환값 최적화라고 부른다) 우리의 컴파일러가 이 기능을 갖고 있으면 위 코드는 여전히 본래의 의도대로 동작할 것이다. 그것도 생각보다 빨리.

마무리하자면, 참조자를 반환할 것인가 아니면 객체를 반환할 것인가를 결정할 때, 이것만은 잊지말자. 어떤 선택을 하든 올바른 동작이 이루어지도록 만드는 것! 이것이 진짜로 우리가 할 일이다. 우리가 선택한 결과를 최대한 저비용으로 만들려면 어떻게 해야 하는지 파악하느라 끙끙대는 일은 컴파일러 제작사에게 맡기자.

지역 스택 객체에 대한 포인터나 참조자를 반환하는 일, 혹은 힙에 할당된 객체에 대한 참조자를 반환하는 일, 또는 지역 정적 객체에 대한 포인터나 참조자를 반환하는 일은 그런 객체가 두 개 이상 필요해질 가능성이 있다면 절대로 하지 말자.


참고

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