Home EFFICIENT C++_생성자와 소멸자
Post
Cancel

EFFICIENT C++_생성자와 소멸자

상속

이상적으로는 생성자와 소멸자는 오버헤드를 가지지 않아야 한다. 생성자와 소멸자는 오직 필수적인 초기화와 정리 작업을 수행하며, 일반적인 컴파일러는 이것들을 인라인 함수로 만들것이다.

하지만, 상속과 합성 구현은 가끔 혹은 절대 사용되지 않는 연산을 수행하기도 한다. 상속과 합성 구현은 코드의 재사용을 위한 기술이다. 즉, 코드의 재사용과 성능과의 줄다리기 문제이다.

코드의 재사용은 특정 상황에서 실제로 필요하지 않은 연산을 수행한다. 불필요한 연산을 수행하는 함수는 성능에 영향을 주게 된다. 상속 기반 디자인과 생성자/소멸자의 부하 사이의 연관성을 살펴보자.

이를 위해서 쓰레드 동기화와 관련된 예제를 사용해서 성능을 비교해보자. 공유 리소스를 관리하기 위해서 간단한 코드를 보자면 아래와 같다.

1
2
pthread_mutex_lock(&mutex);
sharedCounter++;pthread_mutex_unlock(&mutex);

공유 리소스를 보호하기 위해서 sharedCounter 값을 변경시키는 곳 주변에만 잠금을 수행하면 된다.

간단하면서 효율적인 코드이긴 하지만 여기에도 단점은 존재한다.

유지 보수 관점에서 버그를 양산할 가능성이 높아진다. 사용자가 잠금을 얻었다면 반환구문을 실행하기 전에 잠금을 풀어야 한다.

또한 잠금을 얻은 상황에서 예외가 발생한다면 예외를 받아서 수동으로 잠금을 해제해주어야 한다. C++에서는 위의 두가지 문제점에 좋은 해결책을 제공한다. 잠금을 얻고 풀기 위한 루틴을 클래스로 구현하면 위의 문제가 쉽게 해결이 된다.

동기화 관련 로직의 다양성을 위해서 Lock 부모 클래스와 구현체인 DerivedMutex로 구성했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Lock {
public:
    Lock(pthread_mutex_t& lock) {};
    virtual ~Lock() {};
};

class DerivedMutex : public Lock {
public:
    DerivedMutex(pthread_mutex_t& lock)
            : Lock(lock), myLock(lock) {acquire();}
    ~DerivedMutex() {release();}

private:
    int acquire() {return pthread_mutex_lock(&myLock);}
    int release() {return pthread_mutex_unlock(&myLock);}
    pthread_mutex_t& myLock;
};

사용 코드는 아래와 같다.

1
2
3
4
{
    DerivedMutex m(mutex);
    sharedCounter++;
}

위와 같이 클래스를 사용할 경우 얻어지는 이점은 다음과 같다.

  • 여러 반환 시점을 포함하고 있는 복잡한 루틴의 유지 보수
  • 예외로부터 복구
  • 잠금의 다형성
  • 로깅의 다형성

이제 성능의 차이를 비교해 보자.

1
2
3
4
5
6
// 시간 측정
for (int i = 0; i < 50000000; i ++)   
{
    // 테스트 코드
}
// 시간 측정 후, 최종 걸린 시간 측정

위의 코드로 측정한 결과.

클래스 없이 간단하게 잠금을 얻고 해제한 버전의 수행 시간은 약 1.6초가 걸린다.

하지만, 클래스에 상속을 사용한 버전의 경우 약 2.3초가 걸리는 것을 확인했다. 즉, 성능이 약 40% 감소한것을 볼 수 있다. 상속을 이용한 클래스를 사용한 경우, 한줄의 코드에서 다음의 생성자를 호출한다.

  • Lock
  • DerivedMutex

실제적으로 할일을 모두 마치고 소멸자 호출은 다음과 같다.

  • DerivedMutex
  • Lock 현재 상황에서 Lock 클래스는 확장성을 위한 인터페이스만 제공하고 있는 상황이다. 하지만 DerivedMutex 외의 구현체가 없다.

이런 상황에서 굳이 Lock 클래스를 사용하지 않고 독립적인 클래스를 작성해서 비교해보자.

1
2
3
4
5
6
7
8
9
10
class SimpleMutex {
public:
    SimpleMutex(pthread_mutex_t& lock) : myLock(lock) {acquire();}
    ~SimpleMutex(){release();}
private:
    int acquire() {return pthread_mutex_lock(&myLock);}
    int release() {return pthread_mutex_unlock(&myLock);}

    pthread_mutex_t& myLock;
};

상속을 사용하지 않고 DerivedMutex와 동일한 기능을 하는 클래스이다. 사용법 또한 동일하다. 하지만 필요없는 Lock 생성자&소멸자 호출은 발생하지 않는다. 위의 테스트 코드를 통해서 성능을 측정해보면 약 1.7초가 걸리는 것을 확인했다.

부모 클래스의 생성자와 소멸자에서 하는 일이 아무것도 없지만 불필요한 호출은 성능적으로 불이익이 발생함을 알수 있다.

합성

상속과 같이 객체 합성도 객체 생성과 소멸에 관련된 유사한 성능 문제를 가지고 있다. 객체가 생성(소멸)될 때, 해당 객체가 포함한 모든 객체들도 생성(소멸)되어야 한다.

앞에서 이야기한 Trace 클래스를 보면 다음과 같다.

1
2
class Trace {public:    Trace(const char *name);
…
private:    string theFunctionName;};

위의 예제는 Trace 객체가 생성되면 string 하위 객체도 생성된다. 소멸자도 동일하다. 현재 구현에서는 string 객체의 생성 소멸을 조정 할 수는 없다.

1
2
3
4
5
class Trace {
public:
    Trace(const char *name);
    …
private:    string theFunctionName;
};

위의 예제는 string 객체의 생성과 소멸을 사용자가 조정 가능하다.

1
2
3
4
5
6
7
8
class Trace {public:
    Trace(const char *name) : theFunctionName(0) {
        if() {
            theFunctionName = new string(name);
        }
    }
    …
private:    string *theFunctionName;
};

또한 위와 같이 부분 초기화를 하고, 실제로 사용할 경우에 string 객체를 생성 혹은 소멸 시킬 수 있다. 위의 세가지 형태는 사용 패턴에 따라서 무엇이 가장 효율적인지는 달라지게 된다. 항상 theFunctionName을 사용한다면 첫번째 형태가, theFunctionName을 종종 사용하게 된다면, 객체를 생성하는것 보다 0을 포인터에 대입하는 것이 훨씬 부담이 적기 때문에 세번째 형태가 효율적이다.

지연 생성

C++ 언어는 변수를 처음으로 사용하기 전까지 생성하지 않아야 한다. 이 변수는 Primitive Type일 수도 있지만, 클래스일 수도 있다.

우리는 지금까지 클래스의 불필요한 생성자와 소멸자의 호출과 불필요한 함수의 호출이 성능에 어떤 영향을 줄 수 있는지 확인했다. 그렇다면, 당연히 우리는 실제로 사용하기 전까지는 변수의 생성을 지연하는 것이 타당하다는 것을 이해할 것이다.

중복 생성

다음과 같이 string 을 구현한 SuperString이 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class SuperString {
public:
    SuperString(const char *s = 0);
    SuperString(const SuperString &s);
    SuperString&operator=(const SuperString& s);
    ~SuperString() {delete [] str;}

private:
    char *str;
};

inline SuperString::SuperString(const char *s) : str(0) {
    if (s != 0) {
        str = new char[strlen(s) + 1];
        strcpy(str, s);
    }
}

inline SuperString::SuperString(const SuperString &s) : str(0) {
    if (s.str) {
        str = new char[strlen(s.str) + 1];
        strcpy(str, s.str);
    }
}
SuperString& SuperString::operator=(const SuperString &s) {
    if (str != s.str) {
        delete [] str;
        if (s.str) {
            str = new char[strlen(s.str) + 1];
            strcpy(str, s.str);
        }
    }
    else {
        str = 0;
    }

    return *this;
}

SuperString을 사용하는 Person 클래스는 아래와 같다.

1
2
3
4
5
6
class Person {
public:
    Person(const char *s) { name = s; }
private:
    SuperString name;
};

위와 같은 경우 Person 클래스의 생성자의 실행을 의사코드로 표현하면 아래와 같다.

1
2
3
4
5
6
7
8
Person::Person(const char *s)
{
    name.SuperString::SuperString();    // 생성자: name 멤버를 초기화한다. 
    SuperString _tmp;                   // 임시 객체
    _tmp.SuperString::SuperString(s);   // s로부터 SuperString 객체를 생성
    name.SuperString::operator=(_tmp);  // _tmp를 name에 대입한다.
    _tmp.SuperString::~SuperString();   // 임시 객체를 위한 소멸자
}

버전 1의 실행 의사코드를 보면 불필요한 초기화와 임시객체의 생성 및 소멸 그리고 대입이 수행되고 있다.

이는 초기화 리스트를 사용하지 않아서 중복된 생성과 불필요한 대입연산이 추가된 경우이다. 이 경우는 아래와 같이 초기화 리스트를 사용해 해결이 된다.

1
2
3
4
5
6
class Person {
public:
    Person(const char *s) : name(s) { }
private:
    SuperString name;
};

버전 1과 버전 2의 성능을 비교해보면 버전 1이 약 3.5초 버전 2가 약 1.7초로 약 2배의 성능 차이가 발생함을 알수 있다.

키 포인트- 생성자와 소멸자는 직접 작성한 C 코드 만큼 효율적이여야 하지만, 실제적으로 과잉 연산의 형태를 가진 오버헤드를 종종 포함하고 있다.

  • 객체의 생성(소멸)은 부모와 멤버 객체의 재귀적인 생성(소멸)을 발생시킨다. 복잡한 계층엥서 객체 생성(소멸)의 조합을 조심해야 한다.
  • 객체의 생성과 소멸은 CPU 주기를 소모한다. 꼭 필요하지 않으면 객체를 생성하지 말자. 객체는 실제로 사용하기 전까지 생성을 지연하는 것이 좋다.
  • 생성자 본문을 들어가기 이전에 객체 멤버를 초기화한다. 이는 초기화 리스트를 사용하여 초기화를 명시적으로 지정할 수 있으며, 이는 임시 객체의 생성과 대입 연산자를 호출하는 오버헤드를 줄일 수 있다.
This post is licensed under CC BY 4.0 by the author.

Efficient C++_로깅과 관련된 성능 이야기

EFFICIENT C++_가상 함수