ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Effective C++] 구현
    프로그래밍 기초/C++ 2022. 7. 27. 07:28

      구현 

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

    변수 사용 비용과 문제가 되는 부분

    객체는 생성될 때와 범위를 벗어나 소멸될 때 각각 생성자와 소멸자를 호출하는 비용이 고정적으로 발생한다.

    따라서 다음과 같은 경우에는 사용하지도 않을 객체의 생성, 소멸 비용을 지불하고 있게 된다.


    std::string encryptPassword(const std::string& password)

    {

        using namespace std;

        string encrypted;

     

        // encrypted를 사용하기도 전에 함수를 벗어날 가능성이 존재한다.

        if (password.length() < MinimumPasswordLength) {

            throw logic_error("Pssword is too short");

        }

     

        return encrypted;

    }


     

    변수를 옮기더라도 실제 값을 초기화하는 부분이 따로 존재한다면 효율이 떨어지게 된다.

    따라서 변수의 위치는 실제 사용되는 부분과 값을 초기화해주는 위치를 모두 고려해야 한다.

     

    위의 코드는 다음과 같이 수정할 수 있을 것이다.


    std::string encryptPassword(const std::string& password)

    {

        using namespace std;

     

        if (password.length() < MinimumPasswordLength) {

            throw logic_error("Pssword is too short");

        }

     

        string encrypted(password);

     

        return encrypted;

    }


     

    반복문에서의 변수 초기화

    반복문에서 변수를 사용할 때는 다음 두 가지 방법을 고민할 수 있고 각각의 비용은 다음과 같다.

    • 루프 바깥에서 변수를 정의하고 내부에서는 사용만 하는 방법 생성자 1번 + 소멸자 1번 + 대입 n번
    • 루프 내부에 변수를 정의하고 사용하는 방법 생성자 n번 + 소멸자 n번

     

    두 방법 중 비용이 더 적게 들어가는 것을 파악해서 사용하면 된다.

     

    개인적으로는 반복문 내부에서만 사용하는 변수의 경우 for문의 정의문 쪽에 정의해서 사용하는 것을 선호한다.


    // 이렇게 사용하면 for문 범위에서만 변수가 유효하다는 것을 보장할 수 있다.

    for (int i = 0, w = 0; i < 10; i++)

    {

        w += i;

    }


     

    캐스팅은 절약, 또 절약! 잊지 말자

    C++의 캐스팅 방법

    C++ 에는 C 버전의 구형 스타일 캐스트와 C++ 버전의 신형 스타일 캐스트가 존재한다.

    • 구형 스타일 캐스트 C 스타일
      • (T) 표현식
      • T(표현식)
    • 신형 스타일 캐스트 C++ 스타일
      • const_cast<T>(표현식) 객체의 상수성을 없애는 용도
      • dynamic_cast<T>(표현식) 안전한 다운 캐스팅에 사용, 런타임 비용이 굉장히 높다.
      • reinterpret_cast<T>(표현식) 하위 수준 캐스팅에 사용, 일반적으로 사용되지 않는다.
      • static_cast<T>(표현식) 암시적 변환을 강제로 진행할 때 사용

     

    신형 스타일의 캐스트는 다음과 같은 장점이 있다.

    1. 코드를 읽을 때 알아보기 쉽기 때문에 타입 시스템이 망가졌을 때 디버깅이 쉽다.
    2. 목적을 좁혀서 캐스팅하기 때문에 컴파일러에서 에러를 체크할 수 있다.

     

    C++에서 캐스트를 조심해야 하는 이유

    C++를 쓸 때는 객체가 가질 수 있는 주소가 하나가 아닐 수도 있다.

     

    다음 코드는 Derived*가 가리키는 주소와 Base*일 때 가리키는 주소가 다를 수 있음을 보여준다.


    class Base { ... };

    class Derived: public Base { ... };

     

    Derived d;

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


     

    ※ C++를 쓸 때는 데이터가 어떤 식으로 메모리에 박혀 있을 거라는 섣부른 가정을 피해야 한다.

     

    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)

    {

        // Window 클래스가 바뀌거나 파생 클래스가 생겼을 때마다 관리를 해 줘야 하고 느리기까지 하다.

        if (SpecialWindow1 *psw1 = dynamic_cast<SpecialWindow1*>(iter->get())) { ... }

        else if (SpecialWindow2 *psw2 = dynamic_cast<SpecialWindow2*>(iter->get())) { ... }

        else if (SpecialWindow3 *psw3 = dynamic_cast<SpecialWindow3*>(iter->get())) { ... }

    }


     

    dynamic_cast를 피하는 방법

    두 가지 방법으로 dynamic_cast를 줄일 수 있다.

    • 파생 클래스의 포인터를 컨테이너에 담아 사용하는 방법
    • 가상 함수를 만들어 필요한 구현이 자연스럽게 호출되도록 하는 방법

     

    ※ dynamic_cast를 쓰기 적절해 보인다면 뭔가 잘못되가고 있는 것이다. 코드를 다시 확인하자.

     

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

    함수의 반환형에 의해 캡슐화 변동

    기본적으로 멤버 변수를 private으로 선언했다고 하더라도 public함수가 해당 멤버 변수를 참조형 또는 포인터로 반환한다면 이 멤버 변수는 public으로 봐야 한다.

     

    즉, 객체의 내부 요소는 함수의 핸들 반환 시에도 캡슐화의 정도가 달라지게 된다.

     

    다음과 같이 사각형을 나타내는 클래스가 있을 때 사용자가 사각형의 정보를 볼 수 있게 하기 위해 참조형을 반환한다면 반환된 값을 통해 수정될 수 있는 위험이 있다.


    struct RectData {

        Point ulhc;

        Point lrhc;

    };

    class Rectangle {

        ...

    private:

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

    public:

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

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

    };


    ※ 어떤 값을 읽기 전용으로 반환하고 싶으면 const를 붙이는 것을 잊지 말자.

     

    핸들 반환 시 무효 참조 핸들이 되는 문제

    반환된 핸들을 사용하려고 할 때 해당 핸들의 원본이 제거되어 있을 수 있다.

     

    이 것은 const를 붙이든 붙이지 않든 동일하게 발생하는데, 반환된 핸들을 저장해 두고 사용하기 전에 객체 내부에서 해당 핸들이 참조하는 값이 제거되었을 때 외부에서는 손 쓸 방법이 없다.

     

    따라서 핸들을 외부로 반환하는 것은 굉장히 조심스럽게 접근해야 한다.

     

    예외 안전성이 확보되는 그날 위해 싸우고 또 싸우자!

    예외 안전성을 가진 함수의 동작

    예외 안전성을 가진 함수는 다음과 같이 동작해야 한다.

    • 자원이 새지 않도록 만든다.
    • 자료구조가 더럽혀지는 것을 허용하지 않는다.

     

    다음의 예제의 new Image(imgSrc)에서 에러가 발생하면 이 동작이 모두 정상적으로 작동하지 않는다.


    class PrettyMenu {

    public:

        ...

        void changeBackground(std::istream& imgSrc); // 이미지 변경 함수

        ...

    private:

        Mutext mutext;

        Image* bgImage; // 현재 배경

        int imageChanges; // 이미지 변경 횟수

    }

     

    void PrettyMutext::changeBackground(std::istream& imgSrc)

    {

        lock(&mutex); // mutext가 unlock이 호출되지 않을 수 있다.

        delete bgImage;

        ++imageChanges; // 이미지가 변경되지 않았는데 변경 횟수만 증가할 수 있다.

        bgImage = new Image(imgSrc);

        unlock(&mutex);

    }


     

    이러한 문제는 [Effective C++] 자원관리 - 자원 관리 클래스의 복사 동작에 대해 진지하게 고찰하자 항목에 나오는 Lock을 사용하면 막을 수 있다.


    void PrettyMutext::changeBackground(std::istream& imgSrc)

    {

        Lock m1(&mutex);

        delete bgImage;

        ++imageChanges;

        bgImage = new Image(imgSrc); 

    }


     

    예외 안전성을 갖춘 함수가 제공해야 하는 보장

    예외 안전성을 갖춘 함수는 다음의 세 가지 보장 중 하나를 제공해야 한다.

    • 기본적인 보장 함수 동작 중에 예외가 발생하면, 실행 중인 프로그램에 관련된 모든 것들을 유효한 상태로 유지한다.
    • 강력한 보장 함수 동작 중에 예외가 발생하면, 프로그램의 상태를 절대로 변경하지 않는다.
    • 예외 불가 보장 예외를 절대로 던지지 않는다.

     

    기본적인 보장의 경우 프로그램의 상태가 어떻게 될지 예상이 불가능하다.

    가령 changeBackground의 경우 예외 발생 시 bgImage가 예전 이미지가 될지 새 이미지가 될지 알 수 없다.

     

    예외 불가 보장의 경우 C++가 아닌 C로 구현해야 할 가능성이 높아진다.

    단적으로 메모리 할당을 실패할 경우에도 bad_alloc 예외가 던져진다.

     

    따라서 세 가지 보장 중 강력한 보장이 가장 실용적이라고 할 수 있다.

     

    강력한 보장 전략과 한계

    강력한 보장을 제공하기 위한 도구로 스마트 포인터와 복사 후 맞바꾸기 전략을 쓸 수 있다.


    struct MPImpl {

        std::tr1::shared_ptr<Image> bgImage;

        int imageChanges;

    }

     

    class PrettyMenu {

    public:

        ...

        void changeBackground(std::istream& imgSrc); // 이미지 변경 함수

        ...

    private:

        Mutext mutext;

        std::tr1::shared_ptr<MPImpl> pImpl;

    }

     

    void PrettyMutext::changeBackground(std::istream& imgSrc)

    {

        using std::swap;

     

        Lock m1(&mutex);

        std::tr1::shared_ptr<MPImpl> pNew(new PMImpl(*pImpl)); // 기존 값의 복사본을 생성한다.

        pNew->bgImage.reset(new Image(imgSrc)); // 복사본의 값을 수정한다.

        ++pNew->imageChanges;

        swap(pImpl, pNew); // 복사본과 기존 값을 교환한다.

    }


     

    이런 구현을 통해 에러가 발생하더라도 기존 값이 유지된다는 보장을 할 수 있게 된다.

     

    그러나 모든 함수가 강력한 보장을 할 수는 없다.

    가령 데이터베이스가 변경되는 경우에는 강력한 보장이 불가능할 것이다.

     

    ※ 강력한 보장은 가능한 경우 제공하고 기본적인 보장은 제공할 수 있도록 해야 한다.

     

    인라인 함수는 미주알고주알 따져서 이해해 두자

    인라인 함수의 장, 단점

    인라인 함수는 함수 호출 문을 본문으로 바꿔치기하는 것이기 때문에 속도 면에서 이득을 볼 수 있다.

    다만 긴 함수를 인라인으로 사용해 여기저기에서 복사된다면 목적 코드의 크기가 커지는 문제가 생긴다.

     

    반대로 간단한 처리를 하는 함수 명을 길게 설명식으로 사용하고 인라인 함수로 바꾼다면 오히려 목적 코드의 크기가 작아질 수도 있다.

     

    ※ 인라인 문은 컴파일러에 요청하는 것이기에 컴파일러에 따라 인라인화 될 수도 안 될 수도 있다.

     

    인라인 함수의 위치

    인라인 함수와 템플릿은 기본적으로 헤더에 들어 있어야 한다.

     

    템플릿 인스턴스화는 인라인과 별개이며 템플릿을 인라인화 하는 경우 inline을 붙이지만 그렇다고 구현하는 함수 템플릿에 필요도 없는데 inline을 붙일 필요는 전혀 없다.

     

    인라인 함수 잘 쓰는 법

    다음의 두 가지 접근 법이 있다.

    1. 인라인 처리하지 않기 어차피 대부분은 컴파일러가 알아서 처리해 주므로 넘길 수 있을 것이다.
    2. 꼭 해야 하는 함수 또는 간단한 함수만 인라인 처리 하기 아주 미묘한 부분까지 최적화하고 싶은 경우 조심스럽게 접근해야 한다.

     

    파일 사이의 컴파일 의존성을 최대로 줄이자

    컴파일 의존성이란

    하나의 클래스가 수정되었을 때 그 클래스를 사용하는 클래스가 함께 컴파일되어야 하는 경우 컴파일 의존성이 있다고 한다.

     

    다음 Person 클래스에서 사용된 변수의 클래스가 수정된다면 Person 뿐만 아니라 Person을 사용하는 모든 클래스도 함께 다시 컴파일되어야 한다.


    class Person {

    public:

        Person(const std::string& name, const Date& birthday, const Address& addr);

        std::string name() const;

        std::string birthDate() const;

        std::string address() const;

        ...

    private:

        std::string theName;

        Date theBirthDate;

        Address theAddress;

    };


     

    ※ 표준 라이브러리 헤더는 대부분의 경우 컴파일 시 병목 요인이 되지 않는다.

     

    컴파일 의존성을 피하는 방법

    다음의 두 가지 방법이 존재한다.

    • 핸들 클래스 헤더를 선언을 위한 헤더와 구현을 위한 헤더로 분리하는 방법.
    • 인터페이스 클래스 순수 가상 함수를 만들어 인터페이스로 사용하는 방법.

     

    먼저 핸들 클래스의 경우 다음과 같이 선언을 Person.h에 두고 실제 구현은 PersonImpl.h에 두어 Person.h의 함수에서 PersonImpl.h의 함수를 호출하도록 한다.


    std::string Person::name() const

    {

        // 헤더의 함수와 실제 구현의 함수는 같은 이름을 사용하여 유지 보수가 쉽도록 한다.

        return pImple->name();

    }


     

    인터페이스를 사용하는 경우 가상 클래스를 만들고 그 클래스를 상속받는 클래스를 구현하는 식으로 사용한다.


    class Person {

    public:

        virtual ~Person();

     

        virtual std::string name() const = 0;

        virtual std::string birthDate() const = 0;

        virtual std::string address() const = 0;

        ...

    };

     

    class RealPerson : public Person {

        Person(const std::string& name, const Date& birthday, const Address& addr)

                : theName(name), theBirthDate(birthday), theAddress(addr) { }

     

        virtual ~RealPerson() { }

     

        std::string name() const;

        std::string birthDate() const;

        std::string address() const;

        ...

    private:

        std::string theName;

        Date theBirthDate;

        Address theAddress;

    };


     

    여기에서 사용자가 RealPerson을 사용하는 대신 Person의 인터페이스로 호출할 수 있도록 Person에서 RealPerson을 생성해서 돌려주는 함수를 만들어줘야 할 것이다.


    class Person {5

        ...

        // 함수 구현부에서 RealPerson을 초기화해 반환해주면 된다.

        static std::tr1::shared_ptr<Person> create(const std::string& name, const Date& birthday,

                                                              const Address& addr);

        ...

    };


     

    선언과 구현을 분리할 때의 단점

    두 가지 방법은 컴파일 의존성을 완화시킬 수 있지만 각각 단점이 존재한다.

     

    먼저, 핸들 클래스의 경우 접근할 때 요구되는 간접화 연산이 한 단계 증가하고 객체 하나를 저장하는데 필요한 메모리 크기에 구현부 포인터 크기가 추가로 필요하게 된다.

     

    인터페이스 클래스의 경우 함수 호출마다 가상 테이플 점프 비용이 소모되고 인터페이스 클래스의 파생 클래스는 모두 가상 테이블 포인터를 가지고 있어야 한다.

     

    또한 두 방식 모두 인라인 함수를 적절하게 사용할 수 없게 된다는 단점이 존재한다.

     

    댓글

Designed by Tistory.