자원은 사용을 마치고 나면 시스템에 돌려주어야 하는 모든 것이다.

 

 

13. 자원 관리에는 객체가 그만!

어떤 함수 내에서 팩토리 함수 등으로 객체의 생성과 삭제가 이루어진다고 했을 때, 해당 함수가 객체의 delete 문에 항상 도달한다는 보장이 없다.

팩토리 함수로 얻어낸 자원이 항상 해제되도록 하려면 자원을 객체에 넣고 그 자원 해제를 소멸자가 맡으면 된다. 소멸자는 함수를 떠날 때 호출되도록 만들면 언제나 저절로 해제된다.

 

개발에서 사용되는 상당수의 자원은 힙에서 동적으로 할당되고 하나의 블록 또는 함수 안에서만 쓰이는 경우가 잦기 때문에 블록이나 함수를 빠져나올 때 자원이 해제되는 것이 맞다.

이 때, 스마트 포인터를 사용하면 된다.

 

void f()
{
    std::auto_ptr<Investment> pInv(createInvestment()); // 팩토리 함수 호출
    ...
} // auto_ptr의 소멸자를 통해 pInv를 삭제한다

 

자원 관리에 객체를 사용하는 방법의 중요한 두 가지 특징

 

◾ 자원을 획득한 후에 자원 관리 객체에게 넘긴다

자원 획득과 자원 관리 객체의 초기화는 한 문장에서 이루어지는 것이 일상적이다. (RAII)

 

◾ 자원 관리 객체는 자신의 소멸자를 사용해서 자원이 확실히 해제되도록 한다

 

auto_ptr은 자신이 소멸될 때 자기가 가리키고 있는 대상에 대해서 자동으로 delete를 실행하기 때문에 어떤 객체를 가리키는 auto_ptr의 개수가 둘 이상이면 절대로 안된다.

그래서 auto_ptr로 객체를 복사하면 원본 객체는 null로 바뀐다.

 

덧붙여서 STL 컨테이너는 auto_ptr의 원소가 될 수 없다.

 

auto_ptr을 쓸 수 없는 상황이라면 대안으로 shared_ptr가 있다.

auto_ptr과 달리 같은 자원을 여럿이 가리킬 수 있으며 참조 갯수가 0이 되면 해당 자원을 자동으로 삭제한다.

복사 동작이 의도한 대로 이루어지기 때문에 STL 컨테이너도 사용 가능하다.

단, 순환 참조가 발생하는 경우 참조 갯수가 0으로 줄어들지 않아서 자원이 해제되지 않는 문제가 발생할 수 있으므로 유의해야한다.

 

참고사항

Effective C++은 모던 C++이 적용되기 이전에 작성되었기 때문에 현재와 상이한 내용들이 있다.

auto_ptr은 C++11부터 사용 중지 권고, C++17부터 삭제되었다. 대신 unique_ptr이 등장했다.

또한 스마트 포인터의 배열의 삭제도 잘 이루어진다. (C++11/14와 17이 다르긴 하지만)

참고 : https://karupro.tistory.com/65

 

◾ 자원 누출을 막기 위해, 생성자 안에서 자원을 획득하고 소멸자에서 그것을 해제하는 RAII 객체를 사용합시다.

◾ 일반적으로 널리 쓰이는 RAII 클래스는 shared_ptr, 그리고 auto_ptr입니다. 이 둘 가운데 shared_ptr이 복사 시의 동작이 직관적이기 때문에 대개 더 좋습니다. 반면 auto_ptr은 복사되는 객체(원본 객체)를 null로 만들어 버립니다.

 

 

14. 자원 관리 클래스의 복사 동작에 대해 진지하게 고찰하자

모든 자원이 힙에서 생기지는 않는다.

예를 들어 Mutex가 있다. 뮤텍스 잠금을 관리하는 클래스를 만들고자 할 때 기본적으로 RAII 법칙을 따라서 구성하게 된다.

하지만 만약 이 객체의 복사가 이루어진다면?

 

◾ 복사 금지

◾ 관리 자원 참조 카운팅 수행

◾ 관리 자원을 실제로 복사 (깊은 복사)

◾ 관리하는 자원의 소유권을 옮김 (=unique_ptr)

 

위 4가지 중 하나의 선택지를 고를 수 있다.

 

◾ RAII 객체의 복사는 그 객체가 관리하는 자원의 복사 문제를 안고 가기 때문에, 그 자원을 어떻게 복사하느냐에 따라 RAII 객체의 복사 동작이 결정됩니다.

◾ RAII 클래스에 구현하는 일반적인 복사 동작은 복사를 금지하거나 참조 카운팅을 해 주는 선으로 마무리하는 것입니다. 하지만 이 외의 방법들도 가능하니 참고해 둡시다.

 

 

15. 자원 관리 클래스에서 관리되는 자원은 외부에서 접근할 수 있도록 하자

스마트 포인터는 보통 역참조 연산자도 오버로딩하고 있기 때문에 관리하는 실제 포인터에 대한 암시적 변환도 쉽게 할 수 있다.

하지만 임의의 자원 관리 클래스를 만들어서 사용중이라면 외부에서 자원에 접근하는 방법을 제공해야 한다.

 

참고로 RAII 클래스는 데이터 은닉이 목적이 아니기때문에 자원 접근 함수를 열어두어도 캡슐화에 위배되지 않는다.

 

◾ 실제 자원을 직접 접근해야 하는 기존 API들도 많기 때문에, RAII 클래스를 만들 때는 그 클래스가 관리하는 자원을 얻을 수 있는 방법을 열어 주어야 합니다.

◾ 자원 접근은 명시적 변환 혹은 암시적 변환을 통해 가능합니다. 안전성만 따지면 명시적 변환이 대체적으로 더 낫지만, 고객 편의성을 놓고 보면 암시적 변환이 괜찮습니다.

 

 

16. new 및 delete를 사용할 때는 형태를 반드시 맞추자

단일 객체의 메모리 배치구조와 객체 배열에 대한 메모리 배치구조가 다르다. 대개 배열의 경우 최상단에 원소의 개수가 같이 박혀서 들어간다.

delete 호출 시 소멸자를 한번만 호출하고 delete[] 호출 시 앞쪽의 메모리 몇 바이트를 읽어서 배열 크기라고 해석한 뒤에 해당하는 횟수만큼 소멸자를 호출한다.

 

가급적 배열 타입을 typedef 타입으로 만들지 않는 것이 좋다.

 

◾ new 표현식에 []를 썼으면, 대응되는 delete 표현식에도 []를 써야 합니다. 마찬가지로 new 표현식에 []를 안 썼으면, 대응되는 delete 표현식에도 []를 쓰지 말아야 합니다.

 

 

17. new로 생성한 객체를 스마트 포인터에 저장하는 코드는 별도의 한 문장으로 만들자

int priority();
void processWidget(std::shared_ptr<Widget> pw, int priority);

processWidget(new Widget, priority()); // 컴파일 에러
processWidget(std::shared_ptr<Widget>(new Widget), priority()); // 컴파일 가능!.. 하지만 자원을 흘릴 가능성이 있다

 

스마트 포인터의 생성자는 explicit로 선언되어 있기 때문에 동적 할당된 포인터가 스마트 포인터 타입의 객체로 바뀌는 암시적인 변환이 존재하지 않는다.

명시적 변환으로 인자를 주게 될 경우 컴파일은 되지만 자원을 흘릴 가능성이 생긴다.

priority 호출, new Widget 실행, shared_ptr 생성자 호출 총 3가지 연산을 실행하는데 각각의 연산 순서가 컴파일러마다 다르기 때문이다.

만약 new Widget과 shared_ptr 사이에 priority 호출이 끼어들게 되고 priority 호출에서 예외 발생 시, 포인터가 유실될 수 있다.

 

해결 방법은 객체의 동적 할당과 스마트 포인터에 담는 코드를 하나의 독립적인 문장으로 만들고 스마트 포인터를 인자로 넘겨주면 된다.

 

std::shared_ptr<Widget> pw(new Widget);
processWidget(pw, priority());

 

◾ new로 생성한 객체를 스마트 포인터로 넣는 코드는 별도의 한 문장으로 만듭시다. 이것이 안 되어 있으면, 예외가 발생될 때 디버깅하기 힘든 자원 누출이 초래될 수 있습니다.

'도서 > Effective C++' 카테고리의 다른 글

6. 상속, 그리고 객체 지향 설계  (0) 2022.12.04
5. 구현  (0) 2022.12.03
4. 설계 및 선언  (0) 2022.12.01
2. 생성자, 소멸자 및 대입 연산자  (0) 2022.11.30
1. C++에 왔으면 C++의 법을 따릅시다  (0) 2022.11.27

5. C++가 은근슬쩍 만들어 호출해 버리는 함수들에 촉각을 세우자

복사 생성자, 복사 대입 연산자, 소멸자는 직접 선언하지 않으면 컴파일러가 암시적으로 public 인라인 함수로 만들어버린다.

멤버 변수가 레퍼런스이거나 상수라면 복사 대입 연산자는 자동으로 만들어지지 않고 컴파일이 거부된다.

부모 클래스가 복사 대입 연산자를 private으로 선언한 경우에 자식 클래스는 복사 대입 연산자를 가질 수 없게 된다.

 

◾ 컴파일러는 경우에 따라 클래스에 대해 기본 생성자, 복사 생성자, 복사 대입 연산자, 소멸자를 암시적으로 만들어 놓을 수 있습니다.

 

 

6. 컴파일러가 만들어낸 함수가 필요 없으면 확실히 이들의 사용을 금해 버리자

복사 생성자, 복사 대입 연산자 등 컴파일러가 암시적으로 만들어 낸 함수가 필요 없으면(또는 절대 사용해서 안된다면) private으로 선언하면 된다. friend를 통한 접근까지 완벽하게 막으려면 정의를 하지 않으면 된다. 접근 시 링커 에러가 발생한다.

링크 시점 에러를 컴파일 시점으로 옮기려면 복사 생성자와 복사 대입 연산자를 선언 및 정의한 부모 클래스를 private으로 상속 받으면 된다.

 

◾ 컴파일러에서 자동으로 제공하는 기능을 허용치 않으려면, 대응되는 멤버 함수를 private로 선언한 후에 구현은 하지 않은 채로 두십시오. Uncopyable과 비슷한 기본 클래스를 쓰는 것도 한 방법입니다.

 

 

7. 다형성을 가진 기본 클래스에서는 소멸자를 반드시 가상 소멸자로 선언하자

기본 클래스 포인터를 통해 파생 클래스가 삭제될 때, 기본 클래스의 소멸자가 비가상 소멸자라면 프로그램의 동작은 미정의 사항이 되어버린다. 파생 클래스의 소멸자가 호출되지 못하고 메모리의 누수가 발생해버린다.

그렇기 때문에 파생될 여지가 있는 기본 클래스의 소멸자는 반드시 가상 소멸자로 선언해주어야 한다.

 

◾ 가상함수가 하나라도 포함된 클래스는 가상 함수 테이블 포인터가 추가되기 때문에 객체의 크기가 커진다. 가상 함수가 하나라도 포함된 클래스에만 가상 소멸자를 추가해주도록 하자.

 

◾ STL 컨테이너는 모두 비가상 소멸자이므로 상속 받아서 사용하는 것은 자제하도록 한다.

 

◾ 추상 클래스를 만들고 싶은데 순수 가상 함수로 만들 함수가 하나도 없다면 소멸자를 순수 가상 함수로 만들면 된다. 이 때, 순수 가상 소멸자라도 반드시 구현을 해두어야 한다.

 

모던 C++은 순수 가상 함수가 아니더라도 클래스에 abstract 키워드를 붙임으로써 추상 클래스임을 명시할 수 있다. 

 

 

◾ 다형성을 가진 기본 클래스에는 반드시 가상 소멸자를 선언해야 합니다. 즉, 어떤 클래스가 가상 함수를 하나라도 갖고 있으면, 이 클래스의 소멸자도 가상 소멸자이어야 합니다.

◾ 기본 클래스로 설계되지 않았거나 다형성을 갖도록 설계되지 않은 클래스에는 가상 소멸자를 선언하지 말아야 합니다.

 

 

8. 예외가 소멸자를 떠나지 못하도록 붙들어 놓자

예외가 발생할 소지가 있는 문제에 대해서는 소멸자에서 모든것을 처리하게 하지 않고 사용자가 대처할 기회를 가질 수 있게 하는 것이 좋다.

 

 

class DBConn {
public:
    void close()
    {
        db.close();
        close = true;
    }
    ~DBConn()
    {
        if (!close)
        {
            try {
                db.close(); // 사용자가 연결을 안 닫았다면 여기서 시도
            }
            catch (...) {
                // close 호출 실패 로그 작성
                // 프로그램 종료 또는 예외 삼키기
            }
        }
    }
private:
    DBConnection db;
    bool closed;
};

 

close 호출의 책임을 소멸자에서 사용자에게로 넘김으로써 예외를 처리할 기회를 준다.

예외가 발생한다면 그 예외는 소멸자가 아닌 다른 함수에서 비롯된 것이어야만 한다.

 

◾ 소멸자에서는 예외가 빠져나가면 안 됩니다. 만약 소멸자 안에서 호출된 함수가 예외를 던질 가능성이 있다면, 어떤 예외이든지 소멸자에서 모두 받아낸 후에 삼켜 버리든지 프로그램을 끝내든지 해야 합니다.

◾ 어떤 클래스의 연산이 진행되다가 던진 예외에 대해 사용자가 반응해야 할 필요가 있다면, 해당 연산을 제공하는 함수는 반드시 보통의 함수(즉, 소멸자가 아닌 함수)이어야 합니다.

 

 

9. 객체 생성 및 소멸 과정 중에는 절대로 가상 함수를 호출하지 말자

기본 클래스의 생성자가 호출되는 동안에는 가상 함수가 절대 파생 클래스 쪽으로 내려가지 않는다.

 

class Transaction
{
public:
    Transaction() { logTransaction(); }
    virtual void logTransaction() const = 0;
};

class BuyTransaction: public Transaction
{
public:
    virtual void logTransaction() const { ... }
};

BuyTransaction b;
// Transaction 생성자 -> logTransaction() -> BuyTransaction 생성자
// 하지만 logTransaction은 순수 가상 함수..?

파생 클래스의 객체가 생성될 때 호출 순서는 기본 클래스의 생성자부터이고 기본 클래스의 생성자가 호출되는 동안에는 파생 클래스의 정보를 모르는 상태이다. 이 때 만큼은 객체의 타입이 기본 클래스가 된다. 그래서 BuyTransaction의 logTransaction 함수가 아닌 Transaction의 logTransaction 함수가 호출 된다. 순수 가상 함수이므로 링크 에러가 발생하게 된다.

 

순수 가상 함수가 아니라 그냥 가상 함수이고 구현까지 되어있다면 오류가 전혀 발생하지 않기 때문에 디버깅에 애를 먹을 수 있다.

 

미초기화된 데이터 멤버는 정의되지 않은 상태에 있기 때문에 기본 클래스의 생성과 소멸이 진행되는 동안에는 가상 함수가 파생 클래스 쪽으로 내려가지 않는 것이다.

 

◾ 생성자 혹은 소멸자 안에서 가상 함수를 호출하지 마세요. 가상 함수라고 해도, 지금 실행중인 생성자나 소멸자에 해당되는 클래스의 파생 클래스 쪽으로는 내려가지 않으니까요.

 

 

10. 대입 연산자는 *this의 참조자를 반환하게 하자

대입 연산은 여러 개가 사슬처럼 엮일 수 있다.

 

int x, y, z;
x = y = z = 15; // x = (y = (z = 15));

 

대입 연산이 연쇄적으로 이루어지려면 반드시 참조자가 반환되어야 한다는 것을 직관적으로 알 수 있다.

 

◾ 대입 연산자는 *this의 참조자를 반환하도록 만드세요.

 

 

11. operator=에서는 자기대입에 대한 처리가 빠지지 않도록 하자

 

w = w; // 자기 대입
a[i] = a[j] // 자기 대입 가능성 있음
*px = *py // 자기 대입 가능성 있음

 

Widget& Widget::operator=(const Widget& rhs)
{
    if (this == &rhs) return *this;
    
    delete pb;
    pb = new Bitmap(rhs.pb); // 만약 자기 자신이면 삭제된것으로 생성을 시도함
    
    return *this;
}

 

자기 참조의 위험이 있는 코드는 일치성 검사를 통해 처리해 주어야 한다.

만약 자기 참조가 허용된다면 자기 자신을 삭제 후 다시 할당하는 문제가 발생할 여지가 있다.

위의 경우는 동적 할당에서 예외가 발생하면 문제가 발생할 여지가 여전히 남는다.

또한 일치성 검사가 많이, 자주 일어날수록 비용이 커진다.

 

대부분 operator=를 예외에 안전하게 구현하면 대개 자기대입에도 안전한 코드가 나오게 되어있다.

 

 

Widget& Widget::operator=(const Widget& rhs)
{
    Bitmap *pOrig = pb;
    pb = new Bitmap(*rhs.pb);
    delete pOrig;
    
    return *this;
}

 

일치성 검사 대신 문장 순서를 바꾸는 것만으로도 효율적이지는 않지만 예외에 안전해 질 수 있다.

 

 

class Widget
{
    void swap(Widget& rhs);
};

Widget& Widget::operator=(const Widget& rhs)
{
    Widget temp(rhs); // rhs의 사본 생성
    swap(temp); // *this와 사본을 교체
    
    return *rhis;
}

 

또는 복사 후 맞바꾸기(copy and swap)를 통해 효율성과 예외 안전성까지 챙길 수 있다.

객체를 복사하는 코드가 함수 본문에서 생성자로 옮겨졌기 때문에 더 효율적인 코드를 생성할 수 있는 여지가 생기고 객체의 교체가 이루어지기 전에 예외가 발생하면 원본을 그대로 유지할 수 있기 때문에 예외에도 안전해진다.

 

◾ 복사 후 맞바꾸기

어떤 객체를 수정하고 싶으면 그 객체의 사본을 만들어서 사본을 수정한 뒤에 맞바꾸는 것

 

◾ operator=을 구현할 때, 어떤 객체가 그 자신에 대입되는 경우를 제대로 처리하도록 만듭시다. 원본 객체와 복사대상 객체의 주소를 비교해도 되고, 문장의 순서를 적절히 조정할 수도 있으며, 복사 후 맞바꾸기 기법을 써도 됩니다.

◾ 두 개 이상의 객체에 대해 동작하는 함수가 있다면, 이 함수에 넘겨지는 객체들이 사실 같은 객체인 경우에 정확하게 동작하는지 확인해 보세요.

 

 

12. 객체의 모든 부분을 빠짐없이 복사하자

복사 생성자와 복사 대입 연산자를 통틀어서 객체 복사 함수라고 한다.

객체 복사 함수를 명시적으로 구현할 때, 두 가지를 유의해야 한다.

 

  1. 데이터 멤버가 추가될 때 마다 복사 함수에 추가해 주어야 한다.
  2. 파생 클래스를 복사할 때 기본 클래스의 복사 함수도 같이 호출해 주어야 한다.

 

PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs) // 복사 생성자
: Customer(rhs),
  priority(rhs.priority)
{
    ...
}

PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs) // 복사 대입 연산자
{
    Customer::operator=(rhs);
    priority = rhs.priority;
    
    return *this;
}

 

복사 함수의 기능이 서로 비슷하기 때문에 복사 대입 연산자에서 복사 생성자를 호출할 수 있을 것 같지만 그렇지 않다. 초기화와 대입이라는 서로 다른 매커니즘을 가지기 때문이다.

대신 양쪽에서 겹치는 부분은 별도의 멤버 함수로 분리한 후에 호출하는 방법을 이용할 수 있다.

보통 private으로 선언되어있고 init으로 시작하는 경우가 많다.

 

◾ 객체 복사 함수는 주어진 객체의 모든 데이터 멤버 및 모든 기본 클래스 부분을 빠뜨리지 말고 복사해야 합니다.

◾ 클래스의 복사 함수 두 개를 구현할 때, 한쪽을 이용해서 다른 쪽을 구현하려는 시도는 절대로 하지 마세요. 그 대신, 공통된 동작을 제3의 함수에다 분리해 놓고 양쪽에서 이것을 호출하게 만들어서 해결합시다.

'도서 > Effective C++' 카테고리의 다른 글

6. 상속, 그리고 객체 지향 설계  (0) 2022.12.04
5. 구현  (0) 2022.12.03
4. 설계 및 선언  (0) 2022.12.01
3. 자원 관리  (0) 2022.11.30
1. C++에 왔으면 C++의 법을 따릅시다  (0) 2022.11.27

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

C++이 단일 언어가 아닌 여러개의 하위 언어를 제공한다고 생각하자.

 

◾ C

◾ 객체 지향 개념의 C++

◾ 템플릿 C++

◾ STL

 

◾ C++을 사용한 효과적인 프로그래밍 규칙은 경우에 따라 달라집니다. 그 경우란, 바로 C++의 어떤 부분을 사용하느냐입니다.

 

 

2. #define을 쓰려거든 const, enum, inline을 떠올리자

#define을 상수로 교체할 때 상수 포인터를 정의하는 경우와 클래스 멤버를 상수로 정의하는 경우를 유의해야한다.

 

우선 클래스 멤버를 #define으로 정의하는 것 자체가 불가능 하다는 것을 알아야한다.

클래스 멤버를 상수로 정의하는 경우, 상수의 유효 범위를 클래스로 한정짓고자 한다면 해당 상수를 멤버 변수로 만들어야한다. 다만 사본 개수가 한 개를 넘지 못하게 하려면 정적 멤버로 만들어주어야 한다.

 

class GamePlayer
{
private:
    static const int NumTurns = 5; // 구식 컴파일러에서는 오류 발생
    int scores[NumTurns];
};

 

구식 컴파일러에서는 나열자 둔갑술을 대신 사용할 수 있다.

 

class GamePlayer
{
private:
    enum { NumTurns = 5 };
    int scores[NumTurns];
};

 

◾ 단순한 상수를 쓸 때는, #define보다 const 객체 혹은 enum을 우선 생각합시다.

◾ 함수처럼 쓰이는 매크로를 만들려면, #define 매크로보다 인라인 함수를 우선 생각합시다.

 

 

3. 낌새만 보이면 const를 들이대 보자!

const가 * 왼쪽에 있으면 포인터가 가리키는 대상이 상수, 오른쪽에 있으면 포인터 자체가 상수이다

 

void f1(const Widget *pw);
void f2(Widget const *pw); // 둘 다 같은 의미

 

매개변수, 지역 객체를 수정할 수 없게 하는 것이 목적이라면 const를 반드시 붙여주도록 한다. 실수 발생시 컴파일 에러 발생으로 오류를 쉽게 잡을 수 있다.

 

◾ 상수 멤버 함수

객체를 reference-to-const로 전달하면 실행 성능을 높일 수 있다. (상수 멤버 함수 필요)

또한 const 키워드가 있고 없고의 차이만 있는 멤버 함수들은 오버로딩이 가능하다.

 

const char& operator[](std::size_t position) const // 상수 객체
char& operator[](std::size_t position) // 비상수 객체

 

어떤 멤버 함수가 상수 멤버라는것은 비트수준(물리적) 상수성, 논리적 상수성을 만족한다는 의미이다.

하지만 상수 멤버 함수는 물리적 상수성은 만족하지만 논리적 상수성은 만족하지 못한다.

 

class CTextBlock {
public:
    char& operator[](std::size_t position) const
    { return pText[position]; }
private:
    char *pText;
};
...
const CTextBlock cctb("Hello"); // 상수 객체 선언
char *pc = &cctb[0]; // 상수 버전 연산자 호출
*pc = 'J'; // 값이 바뀐다..?

 

 

컴파일러 입장에서는 비트수준 상수성을 만족하기 때문에 컴파일 오류 없이 정상 동작한다.

우리는 추가적으로 논리적 상수성을 지켜서 프로그래밍 해야 한다.

 

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

두 멤버 함수가 상수/비상수 차이만 있을 뿐 로직과 기능이 동일하다면 비상수 버전이 상수 버전을 호출하도록 구현하면 코드 중복을 피할 수 있다.

 

class TextBlock{
public:
    const char& operator[](std::size_t position) const
    {
        ...
        return text[position];
    }
    char& operator[](std::size_t position)
    {
        return const_cast<char&>( // 캐스팅으로 const 제거
            static_cast<const TextBlock&> // *this 타입에 const 추가
            (*this)[position]; // 상수 버전 호출
        );
    }
};

 

 

두 번의 캐스팅 없이 그냥 operator[]를 호출하면 자기 자신(비상수)을 재귀적으로 무한하게 호출한다.

그래서 *this를 const 캐스팅 시켜서 상수 버전을 호출하고 다시 캐스팅을 통해 const를 떼버린다.

 

반대로 상수 버전에서 비상수 버전을 호출하는것은 상수 멤버가 깨지기 때문에 불가능하다.

 

◾ const를 붙여 선언하면 컴파일러가 사용상의 에러를 잡아내는 데 도움을 줍니다. const는 어떤 유효범위에 있는 객체에도 붙을 수 있으며, 함수 매개변수 및 반환 타입에도 붙을 수 있으며, 멤버 함수에도 붙을 수 있습니다.

◾ 컴파일러 쪽에서 보면 비트수준 상수성을 지켜야 하지만, 여러분은 개념적인(논리적인) 상수성을 사용해서 프로그래밍해야 합니다.

◾ 상수 멤버 및 비상수 멤버 함수가 기능적으로 서로 똑같게 구현되어 있을 경우에는 코드 중복을 피하는 것이 좋은데, 이때 비상수 버전이 상수 버전을 호출하도록 만드세요.

 

 

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

대입과 초기화를 헷갈려서는 안된다.

대입의 경우 기본 생성자-복사 생성자 순으로 이뤄지지만 초기화는 인자가 직접 생성자의 인자로 쓰이기 때문에 호출 비용이 줄어든다.

데이터 멤버를 기본 생성자로 초기화 하는 경우에도 초기화 리스트를 사용하는 것이 좋다.

 

기본제공 타입의 멤버가 상수나 참조자인 경우에는 반드시 초기화가 이루어져야 한다.

 

C++은 기본 클래스가 파생 클래스보다 먼저 초기화되고 클래스 데이터 멤버는 선언된 순서대로 초기화된다.

 

비지역 정적 객체의 초기화 순서는 개별 번역 단위에서 정해진다.

◾ 정적 객체: 함수 내부의 정적 객체를 제외한 나머지

◾ 번역 단위: obj 파일을 만드는 기반 소스 파일

 

서로 다른 번역 단위에 정의된 비지역 정적 객체들의 상대적인 초기화 순서는 정해져 있지 않다.

 

초기화 순서를 정하고싶다면 비지역 정적 객체를 하나씩 맡는 함수를 만들어서 지역 정적 객체로 바꾸면 된다. (≒싱글톤)

 

class FileSystem { ... };
FileSystem& tfs()
{
    static FileSystem fs; // 지역 정적 객체 정의/초기화
    return fs; // 참조자 반환
}

 

 

다중스레드 시스템에서 비상수 정적 객체(지역/비지역)는 문제가 발생할 수 있다.

참조자 반환 함수를 모두 호출해주면 초기화에 관계된 경쟁 상태가 발생하지 않는다.

 

◾ 기본제공 타입의 객체는 직접 손으로 초기화합니다. 경우에 따라 저절로 되기도 하고 안되기도 하기 때문입니다.

◾ 생성자에서는, 데이터 멤버에 대한 대입문을 생성자 본문 내부에 넣는 방법으로 멤버를 초기화하지 말고 멤버 초기화 리스트를 즐겨 사용합시다. 그리고 초기화 리스트에 데이터 멤버를 나열할 때는 클래스에 각 데이터 멤버가 선언된 순서와 똑같이 나열합시다.

◾ 여러 번역 단위에 있는 비지역 정적 객체들의 초기화 순서 문제는 피해서 설계해야 합니다. 비지역 정적 객체를 지역 정적 객체로 바꾸면 됩니다.

'도서 > Effective C++' 카테고리의 다른 글

6. 상속, 그리고 객체 지향 설계  (0) 2022.12.04
5. 구현  (0) 2022.12.03
4. 설계 및 선언  (0) 2022.12.01
3. 자원 관리  (0) 2022.11.30
2. 생성자, 소멸자 및 대입 연산자  (0) 2022.11.30

7.2 무선 랜과 네트워크의 특징

 

◾ 신호 세기의 감소

물체를 통과하거나 송신자에게서 거리가 멀어질수록 전자기파 신호의 세기는 감소한다.

 

◾ 다른 출발지로부터의 간섭

동일한 주파수 대역으로 전송되는 무선 신호들은 서로 간섭하게 된다.

 

◾ 다중경로 전파

송수신자간에 전송되는 전자기파가 물체나 지표에 부딪혀서 갈라져서 길이가 다른 여러개의 경로를 거쳐간다.

 

충돌 검출이 매우 어렵기때문에 CSMA/CD는 사용할 수 없다.

 

 

7.3 WiFi: 802.11 무선 랜

 

1) 802.11 구조

서브넷, AS라는 구성단위가 존재하는 것처럼 802.11의 기본 구성단위는 BSS이다.

 

BSS 내부에서 AP의 도움 없이 호스트들끼리 데이터를 교환하는 것을 애드 혹 네트워크라고 한다.

하나의 서브넷에는 다수의 BSS가 존재할 수 있다.

 

 

2) 802.11 MAC 프로토콜

CSMA/CA 프로토콜을 사용한다. 충돌 검출이 아닌 충돌 회피(collision avoidance)를 한다.

또한 무선 채널은 비트 오류율이 상대적으로 크기 때문에 이더넷과 달리 링크 계층 ACK/재전송(ARQ) 방식을 사용한다.

 

짧은 임의의 대기시간을 거친 후에 프레임 전송이 시작되면 중단되지 않고 끝까지 전송된다. 단, ACK 메시지를 받지 못한다면 프레임이 AP에 도달하지 못했다고 판단하고 다시 CSMA/CA 프로토콜로 채널 접근을 한 뒤에 재전송을 한다.

일정 횟수의 재전송 이후에도 ACK 피드백을 수신하지 못하면 포기하고 프레임을 폐기한다.

여기서의 ACK 피드백은 트랜스포트 계층의 ACK 피드백이 아닌 링크 계층만의 ACK 피드백이다.

 

두 스테이션이 서로 숨은 터미널이거나 임의의 백오프 값이 너무 가까울 경우 충돌이 계속 발생할 수 있다.

 

◾ 숨은 터미널 해결 방안: RTS(Request to Send)와 CTS(Clear to Send)

크기가 아주 작은 RTS와 CTS 제어 프레임을 AP와 주고 받아서 채널 접근을 예약한다.

한 스테이션이 RTS로 채널 접근을 예약하면 AP는 접속한 모든 스테이션들에게 CTS 프레임을 브로드캐스팅해서 채널이 예약중이라고 알린다.

 

 

 

3) IEEE 802.11 프레임

프레임에 주소가 4개가 존재한다. 4번째 주소는 애드 혹 네트워크에서 사용되므로 우선은 무시한다.

1번은 수신자(AP), 2번은 송신자(노드)의 MAC 주소이고 3번은 AP가 연결된 라우터의 MAC 주소이다.

AP에게 전달된 프레임은 라우터를 거쳐서 이더넷 프레임으로 변환되어야 하기 때문에 주소가 3개 사용된다.

 

Type은 RTS, CTS, ACK, data 4가지 중 한가지를 명시한다.

 

주소를 여러개 받는 이유는 AP가 링크 계층 장비이기 때문이다.

만약 이더넷 프레임처럼 주소를 2개만 사용한다면 목적지 경로를 찾을 때, IP 패킷의 목적지를 보고 포워딩 테이블을 참조해서 출력 링크를 선택해야 하는데, 네트워크 계층이 없어서 IP 패킷을 해석할 수 없으므로 경로를 찾을 수 없다.

 

ARP, 포워딩 테이블은 네트워크 계층에 존재한다.

 

AP는 BSS 내부의 호스트들과 무선으로 연결될때는 MAC가 존재하지만 라우터와 유선으로 연결되는 네트워크 인터페이스에는 MAC 주소가 없는 스위치로 취급된다.

 

 

4) 동일한 IP 서브넷 내에서의 이동성

동일한 서브넷 내에서 BSS를 이동하는 경우 IP는 변하지 않고 유지된다.

TCP/IP 통신은 송수신자의 주소와 포트가 모두 필요하기 때문에 정보가 변하지 않으면 TCP 연결은 계속 유지된다.

다만 스위치 테이블의 정보는 업데이트 되어야한다.

 

 

5) 802.11의 진전된 특징

◾ 802.11 전송률 적응

일반적으로 AP로부터 거리가 멀어질수록 SNR(signal-to-noise rate)은 낮아지고 BER(bit error rate, 비트 오류율)은 높아진다.

전송률이 낮을수록 SNR대비 BER이 낮아지는데(인코딩 기법이 다름), 프레임 전송 시 ACK 피드백을 연속적으로 일정 횟수 받지 못하면 전송률을 낮추고 연속적으로 일정 횟수 받으면 전송률을 높여서 실시간으로 전송률을 적응시킨다.

 

TCP 혼잡제어 기법과 같은 개념을 공유한다.

'이론 > 네트워크' 카테고리의 다른 글

링크 계층: 링크, 접속망, 랜  (0) 2022.11.24
네트워크 계층: 제어 평면  (0) 2022.11.20
네트워크 계층 : 데이터 평면  (0) 2022.11.19
트랜스포트 계층 (2)  (0) 2022.11.18
트랜스포트 계층 (1)  (0) 2022.11.18

네트워크 계층까지는 두 호스트간의 통신에만 관심이 있기 때문에 연결 링크는 추상화 되어있었다.

하지만 실제로는 여러개의 호스트가 하나의 회선을 공유하는 경우가 있기 때문에 다수의 호스트에서 회선을 동시에 사용한다면 충돌이 일어난다.

 

네트워크 계층은 호스트간 데이터 전송에 관심이 있었다면 링크 계층은 충돌에 관심이 있는 계층이다.

첫 라우터까지 충돌없이 데이터가 잘 전송되는지가 주요 관심사이다.

 

 

6.1 링크 계층 소개

 

링크 계층 프로토콜을 실행하는 장치를 노드라고 한다. 호스트, 라우터, 스위치, WiFi AP 등이 해당된다.

통신 경로상의 인접한 노드들을 연결하는 통신 채널은 링크라고 한다.

 

대부분의 링크 계층은 네트워크 인터페이스 카드에 구현된다.

 

 

6.3 다중 접속 링크와 프로토콜

 

네트워크 링크는 점대점 링크와 브로드캐스트 링크 두 종류가 있다.

이 중 브로드캐스트 링크는 하나의 공유된 브로드캐스트 채널에 다수의 송신 노드 및 수신 노드들이 연결되고 한 노드가 프레임을 전송하면 연결된 모든 노드들이 그 프레임을 수신한다.

이 때 동시에 여러 노드가 프레임을 송수신하면 프레임이 충돌하는 다중 접속 문제가 발생하게 된다.

 

다중 접속 문제를 해결하기 위해 브로드캐스트 채널로 보내는 노드들의 전송을 조정하기 위한 다중 접속 프로토콜이 존재한다.

채널 분할 프로토콜, 랜덤 접속 프로토콜, 순번 프로토콜 세 가지로 분류할 수 있다.

 

 

1) 채널 분할 프로토콜

◾ 시분할 다중 접속 (time division multiple access, TDMA)

시간을 시간 프레임으로 나누고 각 시간 프레임을 N개의 시간 슬롯으로 나누어서 N개의 노드에게 할당한다.

노드는 전송할 프레임이 있을 때마다 자신에게 할당된 시간 슬롯 동안만 프레임 비트를 전송한다.

시간을 공평하게 배분받기 때문에 매우 공정하지만 프레임을 전송할 노드가 단 하나라고 해도 전송률이 평균 R/N으로 제한되고 자신의 차례를 항상 기다려야 하는 단점이 있다.

 

◾ 주파수분할 다중화 (frequency division multiple access, FDMA)

대역폭을 다른 주파수로 나눠서 각 주파수를 N개의 노드에게 할당한다.

자신의 차례를 기다리지 않아도 되지만 전송률이 R/N으로 제한되는 단점이 여전히 존재한다.

 

 

2) 랜덤 접속 프로토콜

채널 분할 프로토콜이 충돌 자체를 피하는 방식이었다면 랜덤 접속 프로토콜은 충돌을 허용하되 처리하는 방식에 대해 다룬다.

슬롯 알로하, 알로하, CSMA 등의 프로토콜이 있다.

 

◾ CSMA (Carrier Sense Multiple Access)

 

노드가 프레임을 전파하기 전에 링크가 사용중인지 확인(carrier sensing)하고 사용중이라면 프레임을 전파하지 않고 잠시 대기한다.

하지만 링크에서 이동되는 것은 전자기파이므로 아무리 빨라도 전파하는 즉시 모든 노드에 도달할 수 없다.

한 노드에서 프레임을 전파했지만 자신에게 브로드캐스팅이 도달하기 전에 carrier sensing이 통과되면 마찬가지로 프레임을 전파하고 CSMA는 충돌 검출을 수행하지 않기 때문에 충돌이 일어나게 된다.

 

◾ CSMA/CD (Collision Detection)

CSMA에서 충돌 검출을 추가로 수행한다.

충돌이 발생하면 브로드캐스팅을 중단하고 랜덤한 시간만큼 대기한 후에 다시 재전송한다.

대기시간의 범위는 짧을수록 좋지만 너무 짧으면 재충돌 가능성이 높고 너무 길면 지연시간이 길어진다.

그래서 이진 지수적 백오프 알고리즘을 이용해서 최초 충돌시 시간을 짧게 설정하고 충돌이 반복해서 발생 시 시간을 점차 늘려나간다.

 

공공구역에서 다수의 사람들이 와이파이 접속 시 연결이 매우 불안정하고 느린것은 노드가 많을수록 충돌이 빈번하게 일어나기 때문이다. 

 

유선 연결(이더넷)은 각 노드의 신호의 세기가 비슷하기 때문에 충돌 감지가 쉬운편이지만 무선 연결은 나의 신호가 월등히 세기 때문에 어떤 노드의 신호 세기가 약하다면 노이즈로 판단하지 않을 수 있으므로 충돌 검출이 어려울 수 있다.

 

 

3) 순번 프로토콜

◾ 폴링 프로토콜

연결된 노드들의 상위에 마스터 노드를 추가해서 각 노드들에게 전송할 프레임이 있는지 매번 물어본다.

채널 분할 프로토콜의 장점인 충돌 회피와 랜덤 접속 프로토콜의 장점인 전송하고 싶을 때 전송하는것을 합친 형태이지만 폴링 지연이 발생하고 마스터 노드가 오작동하거나 다운되었을 경우 연결된 전체 노드도 같이 멈춘다는 단점이 존재한다.

 

◾ 토큰 전달 프로토콜

각 노드가 토큰을 순서대로 전달해가며 토큰이 있는 노드만 전송이 가능하게 한다.

토큰이 분실되면 전체 노드가 멈춘다는 단점이 존재한다.

 

위 두가지 프로토콜은 하나만 잘못되어도 모두가 잘못된다는 공통적인 문제가 있다.

 

 

◾ 채널 분할 프로토콜 : 사용자가 많을수록 효율적이다.

◾ 랜덤 접속 프로토콜 : 사용자가 적을수록 효율적이다.

◾ 순번 프로토콜 : 이상적인 경우 가장 효율적이지만 하나만 잘못되어도 모두가 잘못된다.

 

현재 이더넷과 와이파이는 랜덤 접속 프로토콜을 사용한다.

채널 분할 프로토콜은 LTE 등의 이동 통신에서 사용한다.

 

 

6.4 스위치 근거리 네트워크

 

1) 링크 계층 주소체계와 ARP

◾ MAC 주소

링크 계층 주소는 호스트나 라우터가 아닌 어댑터(네트워크 인터페이스)가 가지게된다.

호스트 이름이나 IP 주소와 다르게 MAC 주소는 변하지 않는다.

프레임의 출발지 및 목적지 주소에는 MAC 주소가 기입된다.

 

◾ ARP (Adress Resolution Protocol)

호스트에서 데이터가 TCP/IP 패킷에 담길 때 목적지에 해당하는 IP 주소는 DNS에 의해 변환되었다. 이 패킷이 프레임에 담길 때에는 목적지가 MAC 주소로 적혀야 하는데 IP 주소를 MAC 주소로 변환해주는 프로토콜이 ARP 이다.

 

호스트의 포워딩 테이블에 의해 next hop의 IP 주소를 알아내면 데이터 영역에 ARP 쿼리, 목적지 주소에 FF-FF-FF-FF-FF-FF를 담은 프레임을 브로드캐스팅 해서 next hop의 MAC 주소를 알아내서 ARP 테이블에 추가한다.

ARP 테이블에 추가되면 위의 과정을 생략하고 바로 테이블의 목적지를 가져온다.

 

포워딩 테이블 → ARP 테이블 순으로 참조해서 링크마다 프레임의 목적지를 정한다.

매 링크마다 프레임이 전달될 때, IP 패킷의 TTL을 감소시키고 출발지와 목적지의 MAC 주소가 변화하며 최종 목적지까지 프레임이 전달된다.

 

 

 

2) 이더넷

 

이더넷 프레임 구조

이더넷은 CSMA/CD를 MAC 프로토콜로 사용하고 있다.

CSMA/CD는 충돌 여부를 검출하기는 하지만 충돌에 대한 피드백을 해당 노드에게 제공하지 않는다. 그 이유는 이더넷은 유선 연결이기 때문에 충돌 검출을 쉽게 할 수 있고 이더넷 내부에서 충돌이 일어나지 않는다면 프레임은 잘 전달됐다는 의미와 동일하기 때문에 피드백을 제공하지 않는다.

 

프레임이 너무 짧은 경우에는 실제 충돌이 일어나더라도 충돌 검출보다 프레임의 마지막 비트 전송이 더 일찍 끝나는 경우가 발생하기 때문에 프레임의 최소 사이즈(64바이트, 18+46)가 정해져있다.

이더넷 프레임의 데이터 필드 최소값은 46 바이트이고 IP 데이터그램이 이보다 작으면 임의의 패딩값을 넣어서 46바이트를 채운다.

 

Length(Type)필드는 데이터 필드에 담긴게 IP 패킷인지 ARP 쿼리인지 등을 구분한다.

 

 

3) 링크 계층 스위치

이더넷 연결에 의한 분류

요즘은 이더넷 연결을 bus형이 아닌 스위치에 연결하는 star형을 사용한다.

하나의 링크를 공유해서 사용하는 bus형은 carrier sense시 링크가 사용중이라면 대기해야 하지만 star형은 각자의 독립된 링크를 가지기 때문에 carrier sense시 항상 조용하기 때문에 원하는 때에 프레임을 항상 전송할 수 있다.

 

스위치는 호스트들과 라우터 사이에 존재해서 서로를 연결시키고 프레임을 전달한다. 하지만 호스트들과 라우터는 스위치의 존재를 모르기 때문에 스위치를 목적지로 삼지 않는다.

그럼에도 불구하고 프레임이 올바르게 전달되는 이유는 스위치에도 테이블이 존재하기 때문이다.

그리고 이 테이블은 자가학습을 통해 테이블이 작성된다.

 

스위치 테이블은 스위치의 해당 링크(인터페이스)로 프레임이 들어올 때 작성된다.

어느 호스트가 어떤 링크를 통해 프레임을 전달했는지 알기 때문에 테이블에 추가한다.

만약 목적지가 테이블에 존재하지 않는다면 자기 자신을 제외한 모든 링크에 프레임을 브로드캐스팅(flood) 한다.

스위치 테이블을 통해 목적지를 찾아가는 것을 스위칭이라고 한다.

 

 

스위치의 포트 개수는 한정적이기 때문에 규모를 키우려면 스위치에 스위치를 연결해서 멀티 스위치를 구성한다.

 

 

◾ 스위치 대 라우터

라우터는 네트워크 계층의 장비이고 스위치는 링크 계층의 장비이다.

또한 라우터는 하나의 새로운 서브넷을 형성시키지만 스위치는 그렇지 않다.

라우터는 라우팅 알고리즘에 의해 포워딩 테이블을 만들지만 스위치는 자가 학습을 통해 스위치 테이블을 만든다.

 

 

6.7 총정리: 웹페이지 요청에 대한 처리

 

1) 시작하기: DHCP, UDP, IP 그리고 이더넷

랩탑이 부팅 시 IP 주소를 할당받기 위해서 DHCP 메시지를 UDP/IP 패킷에 담고 브로드캐스팅해서 DHCP 서버로부터 IP 주소를 할당받는다.

 

DHCP 패킷의 source는 0.0.0.0, dest는 255.255.255.255로 브로드캐스팅 된다.

탐색-제안-요청-확인 순으로 작업이 이루어진다.

 

그 과정에서 스위치를 거친다면 자가 학습으로 스위치 테이블이 작성된다.

작업이 완료되면 자신과 게이트웨이 라우터의 IP 주소와 DNS 서버의 IP 주소를 알게되어서 자기 자신의 포워딩 테이블을 만들게된다.

 

 

2) 여전히 시작하기: DNS와 ARP

웹브라우저에 구글 도메인 주소를 입력 시 DNS 쿼리를 통해 목적지 주소의 IP를 받아와야 하는데 아직 ARP 테이블이 작성되지 않았기 때문에 게이트웨이 라우터의 물리 주소를 모르므로 ARP 요청 쿼리를 먼저 내보낸다.

 

ARP 쿼리 프레임의 source는 랩탑의 MAC주소, dest는 FF-FF-FF-FF-FF-FF로 브로드캐스팅 된다.

요청-응답순으로 작업이 이루어진다.

 

게이트웨이 라우터에서 ARP 응답 쿼리가 도착하여 ARP 테이블이 만들어지면 게이트웨이 라우터로 DNS 쿼리를 내보낸다.

 

 

3) 여전히 시작하기: DNS 서버로의 인트라-도메인 라우팅

게이트웨이 라우터는 포워딩 테이블(OSPF, BGP 등)을 참조해서 DNS 쿼리를 DNS 서버로 전달할 출력 링크를 결정한다.

DNS 서버는 DNS 레코드를 찾아서 응답 메시지를 랩탑에게 보내고 랩탑은 수신받은 메시지에서 구글의 IP 주소를 추출한다.

 

 

4) 웹 클라이언트-서버 상호작용: TCP와 HTTP

랩탑은 목적지 주소로 HTTP 요청을 보내기 전에 TCP 연결(3-way handshake)이 이루어져야 하기 때문에 TCP SYN 세그먼트를 보낸다. TCP SYNACK 피드백을 받아서 연결이 이루어졌다면 이제부터 HTTP 요청-응답 메시지를 주고받는다.

 

https://slideplayer.com/slide/7473201/

'이론 > 네트워크' 카테고리의 다른 글

무선 이동 네트워크  (0) 2022.11.25
네트워크 계층: 제어 평면  (0) 2022.11.20
네트워크 계층 : 데이터 평면  (0) 2022.11.19
트랜스포트 계층 (2)  (0) 2022.11.18
트랜스포트 계층 (1)  (0) 2022.11.18

+ Recent posts