ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Effective C++] 1장 - C++에 왔으면 C++의 법을 따릅시다
    쾌락없는 책임 (공부)/Effetive C++ 요약본 2022. 2. 16. 20:15
    반응형
    본 카테고리는 프로텍 미디어의 '이펙티브 C++'을 보고 요약하는 카테고리입니다.

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

     

     

    항목 1 : C++를 언어들의 연합체로 바라보는 안목은 필수

     

     현대 C++은 발전을 거듭해서 다중 패러다임 프로그래밍 언어로 불리기도 합니다. 이는 진짜로 여러 언어로 이루어진 게 아니라 '관점'을 다양하게 보고 설계를 하자는 이야기입니다.

     

    - C 관점에서 C++

    - 객체 지향 관점에서 C++

    - 탬플린 관점에서 C++

    - STL 관점에서 C++

     

     각 관점에 따라 설계 스타일이 달라지는데 C 관점에서는 값 전달 위주로, 객체지향 관점, 템플릿에서는 생성/소멸자로 상수 객체 참조자에 의한 전달(pass by reference to const)을 선호, STL로 넘어오게 되면서 반복자와 객체 함수가 C를 본뜬 거라 다시 값 참조를 선호하게 됩니다.


     

    항목 2 : define을 쓰거든 const, enum, inline을 떠올리자

     

     만약 define으로 변수를 하면 오류가 나올 때 #define A 123;에서 A 가 아니라 123이 오류라고 나오니 유지보수도 힘들고 매크로 함수는 괄호를 잘 쳐야 된다는 단점이 있어 여러모로 어렵습니다.

     

     일단 상수로 교체하게 되면 전체 코드에서 한 번만 올라오게 되니 사본이 여러 개 생기는 일도 없습니다. 다만 define -> const에서 주의할 점은 

     

    1. 포인터를 상수로 할 때는 const로, 포인터가 가리키는 대상까지 const 하는 게 보통

    2. 클래스 멤버로 상수를 정의하는 경우(클래스 상수 정의)는 상수의 유효 범위를 클래스로 한정하는 것으로 사본 개수를 한 개로 하고 싶다면 static으로 해야 한다.

     

     만약 2번에서 주소를 구한다고 하면 컴파일러가 맛가버리니 별도로 정의를 해줘야 합니다.

     

     그리고 2번에서 클래스 내 사용을 하겠다고 하면 '나열자 둔갑술(enum hack)' 이란 방식을 사용하면 됩니다.

    class Example{
    private:
        enum { Num = 5 }
        int arr[Num];
    };

     enum 답게 정수형 상수만 선언 가능한 단점이 있지만 deinfe과 비슷한 방식으로 사용이 되므로 주소를 취할 수도 없고 그렇다고 쓸데없는 메모리 할당은 없으니 유용하게 사용할 수 있습니다.

     

     

     

    항목 3 : 낌새만 보이면 const를 들이대 보자!

     

     const는 컴파일러가 확실하게 지켜주는 제약들 중 하나로 아래와 같은 일들을 할 수 있습니다

    - 클래스 밖에서는 전역, 네임스페이스의 유효 범위 지정

    - 파일, 함수, 유효 범위에서 static으로 선언한 객체에도 const 가능

    - 클래스 내부의 경우 정적, 비정적 멤버 모두 선언 가능

    - 포인터 자체를 산수로, 포인터가 가리키는 대상을 상수로 지정 가능

    char greeting[] = "Hello";
    
    // 비상수 포인터, 비상수 데이터
    char *p = greeting;
    
    // 비상수 포인터, 상수 데이터
    const char *p = greeting;
    
    // 상수 포인터, 비상수 데이터
    char * const p = greeting;
    
    // 상수 포인터, 상수 데이터
    const char * const p = greeting;

    그리고 const의 위치에 따라 상수가 되는 대상이 달라지는데 const가 * 왼쪽에 있으면 const가 가리키는 대상이 상수, 그 반대는 포인터 자체가 상수라는 뜻입니다.

    (const 타입이라고 생각)

     

    void f1(const Widget *pw)
    
    void f1(Widget const *pw)

    이 2가지는 프로그래머들의 스타일마다 차이가 있으니 눈으로 잘 익혀두라고 합니다.

     

    <STL iterator에서 const>

     반복자의 경우 동작 원리가 T* 이므로 변경이 불가능한 객체를 가리키는 반복자가 필요하면 const_iterator를 사용하면 됩니다.

    vector<int> vec;
    
    const vector<int>::iterator iter = vec.begin();
    //iter는 T* const처럼 동작합니다
    
    *iter = 10 // ok! iter가 가리키는 대상을 변경
    ++iter     // error, iter은 상수라서
    
    vector<int>::const_iterator cIter = vec.begin();
    // cIter는 const *T처럼 동작하게 됩니다.
    
    *cIter = 10;  // error! *cIter가 상수라서 불가
    ++cIter;      // cIter을 변경하는 것이므로 가능

     

    <함수에 사용하는 const>

    함수에 사용하게 되는 const는 반환 값, 매개변수, 멤버 함수, 함수 전체에 붙을 수 있습니다.

    class Rational{...};
    
    const Rational operator * (const Rational &lhs, const Rational &rhs);
    
    Rational a, b, c;
    ...
    // a*b의 결과에 대고 = ?
    (a * b) = c
    
    // 또는 실수로?
    if(a * b = c)

     함수 반환 값을 const로 하면 이런 실수들을 막을 수 있습니다.

     

    <상수 멤버 함수>

    "해당 멤버 함수가 상수 객체에 대해 호출될 함수이다"라는 뜻을 가지고 있는데 왜 여기서 const가 중요하게 작동할까요?

    - 클래스의 인터페이스를 이해하기 쉽게 할 수 있다 

        - 그 클래스로 만들어진 객체를 변경할 수 있는 함수이고 없는 함수는 무엇인지 사용자가 알 수 있음

    - 이 키워드를 통해 상수 객체를 사용하게 하자

     

    그리고 const 유무로 오버 로딩이 가능합니다!

    class TextBlock{
    public :
    	...
    	const char& operator[](std::size_t position) const{
    		return text[position];
    	}
    
    	char& operator[] (std::size_t position){
    		return text[position];
    	}
    
    private:
    	std::string text;
    }	
    ...
    TextBlock tb("Hello");
    cout << tb[0] << endl;
    
    const TextBlock ctb("Hello");
    cout << ctb[0] << endl;

     

    실제 프로그램에서 상수 객체가 생기는 경우는 상수 객체에 대한 포인터, 상수 객체에 대한 참조자로 객체가 전달될 때인데 아래 코드처럼 많이 사용하게 됩니다.

    void print(const TextBlock& ctb){
    	cout << ctb[0];
        ...
    }

     

    그리고 위 operator [] 함수를 오버 로딩해주면 상수 객체에 대해서 대입 연산이 일어나는 걸 막아줄 수도 있습니다.

     


    그리고 어떤 멤버 함수가 const 하는 건 무슨 의미를 가지게 될까요? 일단 상수성에는 2가지 종류가 있는데 비트 수준 상수성과 논리적 상수성입니다.

    - 비트 수준 상수성

       - 어떤 멤버 함수가 그 객체의 어떤 데이터 멤버도 바꾸면 안 된다.

       - 컴파일러 입장에서는 멤버에 대한 대입 연산만 체크하면 됩니다.

       - 그리고 상수 멤버 함수는 그 함수가 호출된 객체의 어떤 비정적 멤버도 호출할 수 없습니다.

    그런데 어떤 포인터가 가리키는 대상을 수정하는 멤버 함수는 const가 제대로 지켜지지 않게 됩니다. 

    class CTextBlock{
    public:
    // 부적절하지만 비트수준 상수성이 허용되는 함수
    	char& operator[](size_t position) const{
    		return ptext[position];
    	}
    
    private:
    	char *pText;
    }

    해당 객체에 대한 참조자를 반환하고 있습니다! 대신 컴파일러 입장에서는 함수 내부에서는 건드리는 게 없으니 허용을 해준다는 말이죠.

     

    그래서 논리적 상수성이 나오게 되는데 사용자가 알아차리지 못하는 선에서 몇 비트 정도 바꾸는 걸 허용하자는 것입니다. 이때 컴파일러는 비트 수준의 상수성을 지키기 때문에 상수 멤버 함수에서 변경을 하기 위해서는 mutable 키워드를 사용하게 됩니다.

     

    <상수 멤버 및 비상수 멤버 함수에서 코드 중복을 피하는 방법>

    멤버 함수를 상수, 비상수 버전으로 준비하면 코드가 계속해서 길어지고 유지보수도 힘들어지게 됩니다. 혹시나 여기서 따로 함수로 빼서 기능을 하는 멤버 함수를 만든 뒤 이걸 부른다고 하면 코드 중복은 여전하고 함수 호출이 2번이나 생기게 됩니다. 그래서 사용하는 방법이 상수/비상수 2가지 버전을 만든 위 const 껍데기를 캐스팅으로 날리면 됩니다.

     

    캐스팅이 썩 좋은 아이디어는 아니지만 그래도 코드 중복이 별로면 어쩔 수 없죠. 이때는 비상수 멤버 함수가 상수 버전을 호출할 수 있게 해 줍니다.

    class TextBlock{
    public:
    	...
    	const char& operator[] (size_t position) const{
    		...
    		...
    		...
    		return text[position];
    	}
    	char& operator[] (size_t position){
    		return    // 함수의 리턴에 캐스팅을 적용 -> const를 떼어냄
    			const_cast<char&>(
    				// *this 타입에 const를 붙입니다. 이후 const 버전 호출
    				static_cast<const TextBlock&>
    					(*this)[position]
    		);
    	}
    	...
    };

     

     이것의 반대로 상수 버전에서 비상수 버전을 호출하는건 예상한 방법이 아닙니다. 비상수 버전은 책임지는 게 없으니깐요.

     

     

     

    항목 4 : 객체를 사용하기 전에 반드시 그 객체를 초기화하자

     

     일단 기본 타입의 경우 0으로 자동 초기화가 된다는 이야기가 있는데 이는 확실하지 않습니다. 그래서 생성 후 =을 통해서 초기화를 잘해주는 게 중요한데요. 여기서 객체의 생성자 초기화는 대입과 초기화를 헷갈려하는 경우가 많습니다.

    class A{
    public:
    	A(int a){
        	m_a = a;
        }
    private:
    	int m_a;
    }

    위 코드는 초기화가 아니라 대입입니다.

    class A{
    public:
    	A(int a)
        	: m_a(a)
        {}
    };

    이게 C++에서 진짜 생성자로 초기화를 하는 것입니다. 만약 위 코드대로 대입을 한다면 생성자 호출 -> 디폴트로 초기화 -> 다시 본문에서 대입이라는 쓸데없는 과정들이 생기므로 아래와 같은 방식을 사용해주는 게 좋습니다.

     

     현업에서는 생성자가 상당히 많아져서 생성자 리스트가 중복되는 것들이 생길 수 있는데 그래서 대입이 가능한 멤버들은 별도의 함수로 넣은 뒤 이들에 대한 대입 연산을 따로 해주는 게 좋을 수도 있습니다.

     

     그리고 이런 초기화 리스트에서 초기화가 되는 순서는 객체를 구성하는 데이터 멤버가 선언된 순서대로 초기화가 됩니다. 그리고 기본 클래스는 파생 클래스보다 먼저 초기화가 되죠.

     

    <비지역 정적 객체의 초기화 순서는 개별 번역 단위에서 정해짐>

     일단 이 부분을 이해하기 위해서는 '정적 객체'에 대한 이해가 있어야 합니다. 간단하게 보면 프로그램이 끝날 때까지 살아있는 객체로 이 범주에 들어가는 5가지는 

    - 전역 객체

    - 네임스페이스 유효 범위에서 정의된 객체

    - 클래스 안에서 static으로 선언된 객체

    - 함수 안에서 static으로 선언된 객체

    - 파일 유효 범위에서 static으로 선언된 객체

    가 있습니다.

     

     이런 정적 객체가 여러 개 있을 때, 그리고 만약 한쪽이 다른 쪽을 참조하고 있다고 할 때 문제가 생기게 됩니다. 정적 객체들의 초기화 순서는 정해져 있지 않아서 입니다. 컴파일러 내에서 적절한 순서를 잡아주기 상당히 힘들기 때문입니다.

     

     이 문제는 살짝의 설계 변환만 있으면 되는데요. 비지역 정적 객체를 하나씩 맡는 함수를 준비하고 이 안에 각 객체를 넣는 것입니다. 디자인 패턴으로는 싱글톤 패턴이 되겠고 비지역 정적 객체가 지역 정적 객체로 바뀐 모습이 됩니다.

     

     정적 객체는 싱글톤 패턴의 단점을 그대로 답습하고 있기 때문에 멀티스레드 환경에서 상당히 어려워집니다. 비상수 정적 객체는 언제나 시한폭탄이고 참조자 반환 함수 아이디어를 사용한다고 해도 순서를 잘 맞춰줘야 할 것입니다.

    반응형

    댓글

Designed by Tistory.