ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Effective C++] 5장(1) - 구현
    쾌락없는 책임 (공부)/Effetive C++ 요약본 2022. 4. 2. 20:41
    반응형
    본 카테고리는 프로텍 미디어의 '이펙티브 C++'을 보고 요약하는 카테고리입니다.

    3판을 기준으로 하며 전체 내용이 아닌 간략한 내용만을 요약하고 있습니다.

     

     

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

     생성자, 소멸자를 끌고 다니는 타입으로 변수를 정의하면 2가지 비용을 물게 됩니다.

     

    - 프로그램 제어 흐름이 변수 정의에 닿을 때 생성자 호출 비용

    - 유효범위를 벗어날 때 소멸자가 호출되는 비용

     

     그리고 사용하지 않는 변수라면 비용이 쓸데없이 발생하게 됩니다.

    std::string encryptPassword(const std::string& password){
    	using namespace std;
    	
    	string encryptrd;  // 이걸 너무 빨리 선언함
    	
    	if(password.length() < Min)
    		throw logic_error("Password is too short");
    
    	...
    	return encrypted;
    }

     저기 if 문에서 예외 발생이 되면 encrypted는 사용되지 않은 변수가 됩니다. 그리고 이후에 만든다고 해도 만약 그냥 string encrypted로 하면 문제가 하나 더 있습니다. 이때 초기화 인자가 하나도 없어 기본 생성자 후 이후 값을 대입하게 되는 비효율적인 연산을 가지게 됩니다. 그래서 우리는 초기화 인자를 얻기 전까지 초기화를 미뤄야 한다는 뜻입니다.

     

     그런데 루프문에서는 조금 생각해야 할 것들이 있습니다.

    // A
    Widget w;
    for(unsigned i = 0; i < n; i++)
    	w = i;
    
    
    // B
    for(unsigned i = 0; i < n; i++)
    	Widget w(i);

     A 에서는 생성자 1번, 소멸자 1번, 대입 n번이 일어나게 되고 B에서는 생성자 n번, 소멸자 n번이 불리게 됩니다. 그래서 각 연산 비용을 한번 보고 고려를 잘해줘야 합니다.

     

     

     

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

     C++의 규칙 동작은 어떤 일이 있어도 타입 에러가 생기지 않도록 보장한다는 철학으로 설계가 되어 있습니다. 그런데 C++의 캐스팅은 이 온갖 일들의 원흉이 되는데 다른 언어를 쓰다가 오면 상당히 힘들어집니다.

     

     일단 평소 "(T) 표현식", T(표현식)" 은 C 스타일의 캐스팅이고 C++ 스타일은 const_cast, dynamic_cast, reinterpret_cast, static_cast 사용하게 됩니다. 일단 C++ 스타일의 캐스팅을 쓰는 게 좋은 게 눈에 보는 것과 검색하기 좋아서입니다.

     

     이런 캐스팅은 컴파일러에게 알려주는 것 외 타입 변환을 통해 런타임에 실행되는 코드가 만들어질 때도 있습니다.

    int x, y;
    ...
    double d = static_cast<double>(x)/y;

     여기서 x를 double로 만들 때 코드가 만들어지게 됩니다.

    class Base {...};
    class Derived : public Base {...};
    Derived d;
    Base *pb = &d;

     코드를 더 보면 두 클래스 포인터 값이 같지 않을 때 offset을 Derived* 포인터에 적용할 실제 Base* 포인터를 구하는 과정이 런타임에 이루어집니다. 객체 하나가 가질 수 있는 주소가 여러 개가 될 수 있음은 C++에서는 가능하니 알아둬야 합니다. ( 다중 상속뿐 아니라 단일 상속에서도 이런 일이 생기기도 합니다 )

     

     그리고 또 다른, 웃지 못할 이야기가 있습니다. 캐스팅이 들어가면 맞는 거 같은데 실제로는 틀린 경우가 있습니다. 예시를 하나 보고 갑시다. 

     많이 사용하는 응용프로그램 프레임워크를 하나 살펴보면 가상 함수를 파생 클래스에서 재정의해 구현할 때 기본 클래스 버전을 호출하는 문장을 가장 먼저 넣어달라는 요구사항이 있습니다. 그래서 아래 예시를 사용하는데 틀린 코드입니다.

    class SpecialWindow : public Window {
    public:
    	virtual void OnResize(){
    		// 캐스팅 후 호출하는데 호출되지 않음
    		static_cast<Window>(*this).OnResize();
    	}
    ...
    };

     위에서 캐스팅을 하면 *this의 기본 클래스 부분에 대한 사본이 임시로 만들어져 OnResize 함수는 그 사본에서 호출이 됩니다! 추가로 객체 수정 작업을 하면 기본 클래스의 사본에 대해서 수정이 이루어지게 됩니다. 그래서 위 오류를 해결하기 위해서는 캐스팅을 빼면 됩니다!

    class SpecialWindow : public Window {
    public:
    	virtual void OnResize(){
    		Window::OnResize();
    	}
    ...
    };

     

    캐스팅 연산자가 입맛 당기는 상황이라면 뭔가 꼬여가는 징조다

     위 말을 잘 알고 있어야 하는데 특히 dynamic_cast에서 더 적용이 되는 말입니다.

     

     dynamic_cast와 관련해서는 여러 이야기가 있지만 상당수의 경우 느리게 구현되어 있습니다. 어떤 환경에서는 클래스 이름에 대한 비교 연산으로 strcmp를 사용하는 곳도 있습니다. 그래서 수행 성능이 중요한 경우 항상 주의해서 사용해야 합니다.

     

     이런 dynamic_cast를 사용하는 경우가 있는데 바로 '파생 클래스 함수를 호출하고 싶은데 기본 클래스에 대한 포인터만 있는 경우'가 주입니다. 이런 문제를 해결하기 위해서는 방법이 2가지 정도 있습니다.

     

    파생 클래스 객체에 대한 포인터를 컨테이너에 담아둬 기본 클래스 인터페이스로 조작하는 일이 없게

      예시로 책의 코드의 Window 객체에서 blink 기능이 파생 클래스인 SpecialWindow에만 있다면

    typedef
    	std::vector<std::tr1::shared_ptr<Window>> VPW;
    
    VPW winPtrs;
    
    ...
    
    for(VPW::iterator iter = winPtrs.begin();
    		iter != winPtrs.end();
    		++iter) {
    	if(SpecialWindow *psw = dynamic_cast<specialWindow*>(iter->get()))
    		psw->blink();
    }

     이렇게 dynamic_cast를 사용하지 말고

    typedef
    	std::vector<std::tr1::shared_ptr<SpecialWindow>> VPSW;
    
    VPSW winPtrs;
    
    ...
    
    for(VPSW::iterator iter = winPtrs.begin();
    		iter != winPtrs.end();
    		++iter) {
    	(*iter)->blink();
    }

     이렇게 사용하자는 의도입니다. 단점이 있다면 Window의 파생 클래스 모두에 대해서 할 수 없다는 점입니다.

     

    파생 클래스 모두에 대해 할 수 없다는 단점 해결을 위해 원하는 조작을 가상 함수 집합으로 정리해 기본 클래스에
    class Window {
    public:
    	virtual void blink() {};
    };
    ...
    typedef
    	std::vector<std::tr1::shared_ptr<Window>> VPW;
    
    VPW winPtrs;
    
    ...
    
    for(VPW::iterator iter = winPtrs.begin();
    		iter != winPtrs.end();
    		++iter) {
    	(*iter)->blink();
    }

     둘 다 아주 일반화된 방법은 아니지만 많은 상황에서 dynamic_cast를 줄일 수 있습니다.

     

    피해야 하는 상황 - 폭포식 dynamic_cast
    class Window {...};
    ...
    typedef std::vector<std::tr1::shared_ptr<Window>> VPW;
    VPW winPtrs;
    
    ...
    
    for(VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter) {
    	if(SpecialWindow1 *psw1 = dynamic_cast<specialWindow1*>(iter->get()))
    	{...}
    	else if(SpecialWindow2 *psw2 = dynamic_cast<specialWindow2*>(iter->get()))
    	{...}
    }

     이러면 뭐 하나 좋을 수 없는 코드가 만들어집니다. Window 계통이 바뀔 때마다 뭔가 넣고 뺄 게 없는지 계속 봐야 하기 때문이죠. 그래서 이런 코드를 보면 방법을 바꿔야 합니다.

     

     

     

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

     클래스 객체를 사용할 때 주로 '메모리 부담을 줄여주고 싶다'라고 생각을 하게 됩니다.

     만일 '사각형'을 사용하는 프로그램이 있고 이를 추상화한 클래스가 있다고 합시다. 이때, 사각형 영역을 정의하는 꼭짓점을 클래스 자체에 넣지 말고 구조체를 만들어 사각형에서 구조체를 가리키게 하면 어떨까 합니다.

    class Point{
    public:
    	Point(int x, int y);
    	...
    	void SetX(int newVal);
    	void SetY(int newVal);
    	...
    };
    
    struct RectData{
    	Point ulhc;
    	Point lrhc;
    };
    
    class Rectangle{
    	...
    private:
    	std::tr1::shared_ptr<RectData> pData;
    };

     여기서 Rectangle 클래스 사용자는 영역 정보를 알아내 사용하기 위해서 이걸 알아내는 함수가 안에 들어가 있습니다. 여기서 참조 전달 방식을 사용해서 영역 정보를 리턴하는 형태가 되었다고 합시다.

    class Rectangle{
    public:
    	Point& UpperLeft() const { return pData->ulhc; }
    	Point& LowerRight() const { return pData->lrhc; }
    ...

     일단 컴파일은 잘 되는데 굉장히 자기모순적인 코드가 들어 있게 됩니다. const를 붙여 놓고 참조자를 반환해 private 변수를 마음대로 바꿔먹을 수 있게 되었습니다.

     

     여기서 두 가지 교훈을 얻을 수 있죠.

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

    - 어떤 객체에서 호출한 상수 멤버 함수의 참조자 반환 값의 실제 데이터가 객체 밖에 있다면 이 함수 호출부에서 데이터 수정이 가능하다.

     

     참조자 외 포인터나 반복자도 마찬가지입니다. 이런 것들은 전부 핸들이고 언제든 캡슐화를 무너뜨릴 수 있습니다. 흔한 경우는 아니지만 간단하게 해결이 가능합니다.

    class Rectangle{
    	const Point& UpperLeft() const { return pData->ulhc; }
    	const Point& LowerRight() const { return pData->lrhc; }
    };

     이러면 읽기만 되고 쓰기는 불가능하게 됩니다. 쓸려면 컴파일러가 막아두게 되거든요.

     

     그래도 내부 데이터에 대한 핸들이 있어서 찜찜하긴 합니다. 무효 참조핸들 문제가 있는데 바로 실제 데이터가 없을 때 나오는 문제입니다.

    class GUIObject {...};
    
    const Rectangle BoundingBox(const GUIObject& obj);
    ...
    GUIObject *pgo;
    ...
    const Point *pUpperLeft = &(boundingBox(*pgo).upperLeft());

     여기서 마지막 문장에서 임시 Rectangle 객체가 만들어지는데 임시 객체에 대해서 upperLeft가 불리게 됩니다. 이로 인해 임시 객체의 내부 데이터(Point 중 하나)에 대한 참조자가 나오게 됩니다. 여기서 문제는 이 문장이 끝나면 함수 반환 값은 소멸된다는 것으로 임시 객체의 Point 객체들도 다 사라지게 됩니다.

     

     

     그래서 이번 항목 결론은 '내부에 대한 핸들을 피하자'입니다. 절대로 피하자는 이야기는 아니라는 것이죠. 위에서 온갖 단점을 적었지만 필요한 경우가 있습니다.

     operator [] 연산자는 string, vector 등의 클래스에서 원소를 참조하는 용도인데 실제로 내부 참조자를 반환하는 방식으로 동작하게 됩니다. 물론 이 원소 데이터는 컨테이너가 사라질 때 함께 사라지는 데이터입니다.

    반응형

    댓글

Designed by Tistory.