본문 바로가기
프로그래밍언어/C++

[Effective C++] 항목 26~28

by 목가 2018. 4. 10.
반응형

항목 26. 변수 정의는 늦출 수 있는 데까지 늦추는 근성을 발휘하자.

 - 생성자 혹은 소멸자를 끌고다니는 타입으로 변수를 정의하면 반드시 물게 되는 비용이 두 가지 있다. 하나는 프로그램 제어 흐름이 변수의 정의에 닿을 때 생성자가 호출되는 비용이고, 또 하나는 그 변수가 유효범위를 벗어날 때 소멸자가 호출되는 비용이다. 아래의 예제에서 encryped 부분을 자세히 보면


std::string EncryptPassword( const std::string password )

{

    using namespace std;

 

    string encryped;

 

    if( password.length() < MIN_PASSWORD_LENGTH )

    {

        throw logic_error( "Password is too short" );

    }

 

    encryped = password;

    ...                                                                                     // encryped와 관련된 일을 한다.

 

    return encryped;

}


encryped 객체가 사실 이 함수에서 완전히 안 쓰인다고는 말할 수 없지만, 예외가 발생되면 이 변수는 분명히 사용하지 않게 된다. 즉 EncryptPassword 함수가 예외를 던지더라도 encryped 객체의 생성과 소멸에 대한 비용이 발생한다.

위의 예제를 아래와 같이 변경하면 정의를 최대한으로 미룰 수 있고 또한 정의함과 동시에 초기화를 해줍니다.


std::string EncryptPassword( const std::string password )

{

    using namespace std;

 

    if( password.length() < MIN_PASSWORD_LENGTH )

    {

        throw logic_error( "Password is too short" );

    }

 

    std::string encryped( password );                        // 진짜로 필요해질 때까지 정의를 미룬다.

 

    ...                                                                        // encryped와 관련된 일을 한다.

 

    return encryped;

}


루프에 대해서 아래의 예제를 한번 살펴보면

A 방식 : 루프 바깥쪽에 정의

Widget w;

for(int i = 0; i<n;++i)

{

    w=i에 따라 달라지는값;

    ....

}

B방식 : 루프 안쪽에 정의

for(int i = 0; i < n ; ++i)

{

    Widget w(i에 따라 달라지는 값);

    ....

}


A 방식은 생성자 1번 + 소멸자 1번 + 대입 n번  -> 대입에 들어가는 비용이 생성자-소멸자 쌍보다 적게 나올 때                                                                         단, 프로그램의 이해도와 유지보수성이 안좋아질 수 있음

B 방식은 생성자 n번, 소멸자 n번   ->  대입이 생성자-소멸자 쌍보다 비용이 덜 들고, 전체 코드에서                                                                        수행 성능에 민감한 부분을 건드리는 중이라고 생각하지 않을 때


이것만은 잊지 말자!

변수 정의는 늦출 수 있을 때까지 늦추자. 프로그램이 더 깔끔해지며 효율이 좋아진다.



항목 27. 캐스팅은 절약, 또 절약! 잊지 말자.

- C++는 네 가지로 이루어진 새로운 형태의 캐스트 연산자를 독자적으로 제공한다.

const_cast : 객체의 상수성을 없애는 용도로 사용된다. 이런 기능을 가진 C++ 스타일의 캐스트는 이것밖에 없다.


dynamic_cast : 이른바 안전한 다운캐스팅을 할 때 사용하는 연산자이다. 즉, 주어진 객체가 어떤 클래스 상속 계통에 속한 특정 타입인지 아닌지를 결정하는 작업에 쓰인다. 런타임 비용이 높다.


reinterpret_cast : 포인터를 int로 바꾸는 등의 하부 수준 캐스팅을 위해 만들어진 연산자로서, 이것의 적용 결과는 구현환경에 의존적이다. 이런 캐스트는 하부 수준 코드 외에는 거의 없어야 한다.


static_cast : 암시적 변환을 강제로 진행할 때 사용한다. 흔히들 이루어지는 타입 변환을 거꾸로 수행하는 용도로도 쓰인다. 상수 객체를 비상수 객체로 캐스팅하는 데 이것을 쓸 수는 없다.


C++ 스타일의 캐스트는 코드를 읽을 때 알아보기 쉽기 때문에 소스 코드의 어디에서 C++의 타입 시스템이 망가졌는지를 찾아보는 작업이 편리해 진다. 캐스트를 사용한 목적을 더 좁혀서 지정하기 때문에 컴파일러 쪽에서 사용 에러를 진단할 수 있다.


class Base { ... };

 

class Derived : public Base

{...};

 

Derived d;

 

Base* b = &d; // Derived*에서 Base*의 암시적 변환이 이루어진다.


타입 변환이 있으면 런타임에 실행되는 코드가 만들어지는 경우가 적지 않다. 이런 경우가 되면, 포인터의 offset을 Derived* 포인터에 적용하여 실제의 Base* 포인터 값을 구하는 동작이 바로 런타임에 이루어 진다.


객체 하나가 가질 수 있는 주소가 오직 한 개가 아니라 그 이상이 될 수 있다. C++에서는 다중 상속이 사용되면 이런 현상이 항상 생기지만, 단일 상속인데도 이렇게 되는 경우가 있다. C++를 쓸 때는 데이터가 어떤 식으로 메모리에 박혀 있을 거라는 섣부른 가정을 피해야 한다.


객체의 메모리 배치구조를 결정하는 방법과 객체의 주소를 계산하는 방법은 컴파일러마다 천차만별이다. 


캐스팅이 들어가면, 보기엔 맞는 것 같지만 실제로는 틀린 코드를 쓰고도 모르는 경우가 많아진다.


class Window

{

public :

    virtual void OnResize()

    {

        ...

    }

};

 

class SpecialWindow : public Window

{

public :

    virtual void OnResize()

    {

        static_cast<Window>(*this).OnResize(); // 동작이 되지 않는다.

    }

};


캐스팅이 일어나면서 *this의 기본 클래스 부분에 대한 사본이 임시적으로 만들어지게 되어 있는데, 지금의 OnResize()는 바로 이 임시 객체에서 호출된 것이다. 다시 말해, SpecialWindow만의 동작을 수행하기도 전에 기본 클래스 부분의 사본에 대고 Window::OnResize를 호출하는 것이다.


class SpecialWindow : public Window

{

public :

    virtual void OnResize()

    {

        Window::OnResize();

    }

};


그냥 현재 객체에 대고 OnResize의 기본 클래스 버전을 호출하도록 만들면 된다.


dynamic_cast 연산자가 쓰고 싶어지는 때가 있다. 파생 클래스 객체임이 분명한 녀석에 있어서 이에 대해 파생 클래스의 함수를 호출하고 싶은데, 그 객체를 조작할 수 있는 수단으로 기본 클래스의 포인터밖에 없을 경우는 적지 않게 생긴다.


class Window{ ... }

 

class SpecialWindow : public Window

{

public :

    void Blink(){}

};

 

std::vector< std::tr1::shared_ptr<Window> > winPtr;

 

for( size_t i = 0; i < winPtr.size(); ++i ) // 주 : 책에는 iterator를 사용했는데 좀더 간결한 코드를 위해 직접접근을 이용함

{

    if( SpecialWindow* sw = dynamic_cast<SpecialWindow*>( winPtr[i].get() ) )

    {

        sw->Blink();

    }

}


Window에서 뻗어 나온 자손들을 전부 기본 클래스 인터페이스를 통해 조작할 수 있는 다른 방법이 없는 것은 아니다. 원하는 조작을 가상 함수 집합으로 정리해서 기본 클래스에 넣어두면 된다.


class Window

{

public :

    virtual void Blink();

};

 

class SpecialWindow : public Window

{

public :

    virtual void Blink(){ ... } // 이 클래스에서 Blink가 동작을 수행한다.

};

 

std::vector< std::tr1::shared_ptr<Window> > winPtr;

 

for( size_t i = 0; i < winPtr.size(); ++i )

{

    winPtr[i].get()->Blink(); // dynamic_cast를 사용하지 않는다.

}


정말 잘 작성된 C++ 코드는 캐스팅을 거의 쓰지 않는다. 캐스팅 역시, 그냥 막 쓰기에는 꺼림칙한 문법 기능을 써야 할 때 흔히 쓰이는 수단을 활용해서 처리하는 것이 좋다. 캐스팅을 해야 하는 코드를 내부 함수 속에 몰아 놓고, 그 안에서 일어나는 일들은 이 함수를 호출하는 외부에서 알 수 없도록 인터페이스로 막아두는 식으로 해결하면 된다.


이것만은 잊지 말자!

- 다른 방법이 가능하다면 캐스팅은 피해야 한다. 특히 수행 성능에 민감한 코드에서 dynamic_cast는 몇 번이고 다시 생각해야 한다. 설계 중에 캐스팅이 필요해졌다면, 캐스팅을 쓰지 않는 다른 방법을 시도해봐야 한다.

- 캐스팅이 어쩔 수 없이 필요하다면, 함수 안에 숨길 수 있도록 하자. 이렇게 하면 최소한의 사용자는 자신의 코드에 캐스팅을 넣지 않고 이 함수를 호출할 수 있게 된다.

- 구형 스타일의 캐스트를 쓰려거든 C++ 스타일의 캐스트를 선호하자. 발견하기도 쉽고, 설계자가 어떤 역할을 의도했는지가 더 자세하게 들어난다.



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

 - 핸들의 정의 -> 핸들은 구체적인 어떤 대상을 접근하기 위한 용도로 쓰이는 매개체이다.

핸들로서 레퍼런스 변수와 포인터 변수, 반복자가 여기에 해당한다.


사각형을 사용하는 어떤 프로그램을 만들 때

class Point                          //점을 나타내는 클래스

{

public :

    Point( int x, int y );

 

    void SetX( int newVal );

    void SetY( int newVal );

};

 

struct RectData                   //Rectangle에 쓰기 위한 점 데이터

{

    Point ulhc;                     //좌측 상단

    Point lrhc;                     //우측 하단

};

 

class Rectangle

{

  ....... 

private :

    std::tr1::shared_ptr< RectData > data;

};


여기서 Point는 사용자 정의 타입으로 참조에 의한 전달 방식이 더 효율적이다.

class Rectangle

{

    public:

    ....

        Point& upperLeft() const { return pData->ulhc;}

        Point& lowerRight() const { return pData->lrhc;};

    ....

};

위의 예제는 컴파일은 잘 되지만 잘 못된 예제 입니다.

upperLeft, lowerRight 함수는 상수 멤버로 꼭짓점 정보를 알아낼 수 있지만 수정하는 일은 없도록 설계되어 있습니다.

하지만 위의 예제를 보면 호출부에서 내부 데이터를 수정해도 좋다라는 의미로 받아들여 집니다. (private로 선언된)


Point coord1(0, 0);

Point coord2(100, 100);


const Rectangle rec(coord1, coord2) ;               //rec은 (0, 0)부터 (100,100)의 영역에 있는 상수 Rectangle 객체 입니다.

rec.upperLeft().setX(50);                              //이제 이 rec은 (50, 0)부터 (100, 100) 의 영역에 있게 됩니다.


위의 예제를 보면 rec은 상수 객체로 선언되어 있지만 Point 데이터 멤버를 참조자로 끌어와 바꿀 수 있다.


위의 예제에서 볼 수 있듯이 아래의 두가지를 꼭 기억하자!

첫째, 클래스 데이터 멤버는 아무리 숨겨봤자 그 멤버의 참조자를 반환하는 함수들의 최대 접근도에 따라 캡슐화 정도가 정해진다.

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


위의 class Rectangle을 아래와 같이 변경하면 캡슐화 완화 및 객체의 상태 변경을 막을 수 있습니다.

class Rectangle

{

    public:

    ....

       const Point& upperLeft() const {return pData->ulhc;}

       const Point& lowerRight() const {return pData->lrhc;}

   ....

}

하지만 위의 예제에서 무효 참조 핸들이라는 가장 큰 문제가 발생할 수 있습니다.

무효참조 핸들이란? -> 핸들이 있기는 하지만 그 핸들을 따라갔을 때 실제 객체의 데이터가 없는 것을 의미한다.

핸들을 반환하게 되면 자주 발생하는 현상이다.

예를들면

class GUIObject {.....};


const Rectangle boundingBox(const GUIObject& obj);      //Rectangle 객체를 값으로 반환합니다.

이 상태에서 사용자가 다음과 같이 사용하면

GUIObject *pgo;                            //pgo를 써서 임의의 GUIObject를 가리키도록 합니다.

...

const Point *pUpperLeft = &(boundingBox(*pgo).upperLeft()); //pgo가 가리키는 GUIObject의 사각 테두리 영역으로부터 좌측 상단 꼭짓점의 포인터를 얻습니다.


이부분의 마지막 부분을보면 pUpperLeft 포인터가 가리키는 객체는 없어지는데 이는 pUpperLeft에게 객체를 달아 줬다가 주소값만 남기고 몽땅 날아가 버리는 경우가 발생합니다.


핸들반환 멤버함수를 절대로 두지 말라는 이야기는 아니라 꼭 필요할 때는 사용하되 충분한 검토가 필요하다.


이것만은 잊지 말자!

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

반응형

'프로그래밍언어 > C++' 카테고리의 다른 글

[Effective C++] 항목 35~37  (0) 2018.04.10
[Effective C++] 항목 29~31  (0) 2018.04.10
[Effective C++] 항목 22 ~ 25  (0) 2018.01.23
[Effective C++] 항목 18 ~ 21  (0) 2018.01.23
[Effective C++] 항목 13 ~ 17  (0) 2018.01.23

댓글