본문 바로가기
C++

[C++ Core Guidelines] P.7 런타임 에러는 빨리 잡자

by 코드쉼터 2024. 4. 13.

P.7: Catch run-time errors early

 

이유

런타임 에러를 빨리 잡지 않으면 나중에 나비효과로 "신비한" 충돌이 발생할 수 있고 충돌의 실제 원인을 파악하기 어렵게 만듭니다.

숙련된 C++ 개발자들은 객체의 생성자에 (이른 시점에) 유효한 값으로 객체를 초기화 하고 있는지 검사하는 것을 선호하기도 합니다.

이것이 항상 바람직한 방법인지, 주의해야 할 점은 없는지 알아봅시다.

 

 

예시 (나쁜 예)

void increment1(int* p, int n)    // 나쁨 : 에러를 발생시키기 쉽습니다
{
    for (int i = 0; i < n; ++i)
    	++p[i];
}

void use1(int m)
{
    const int n = 10;
    int a[n] = {};
    // ...
    increment1(a, m);   // 오타가 났나요? 아마 m <= n 임을 가정하고 이렇게 작성했을수도 있지만
                        // 실수로 m 이 20 이면 어떤 문제가 발생할까요?
    // ...
}

increment1() 함수는 배열의 모든 요소를 1씩 증가시키는 목적을 가지고 있습니다만, 실수로 실제 배열의 길이보다 더 큰 n 값을 받더라도 방어할 수 있는 방법이 없습니다. 더 심각한 문제는 배열 크기를 넘어서는 접근을 시도할때까지 프로그램이 마치 정상적으로 수행되는 것 처럼 계속 동작한다는 것입니다.

 

 

예시 (좋은 예)

void increment2(span<int> p)
{
    for (int& x : p) ++x;
}

void use2(int m)
{
    const int n = 10;
    int a[n] = {};
    // ...
    increment2({a, m});   // 어쩌면 오타거나 m <= n 임을 가정하고 작성한 것입니다
    
    increment2(a);        // 그냥 전체 범위에서 수행하고 싶으면 이렇게 전달해도 됩니다
    // ...
}

이제 m <= n 은 increment2() 함수 호출 시점에 (정확히는 임시개체 대입시) 확인할 수 있습니다.

에러도 일찍 잡으면서, 코드를 더욱 단순화 화면서 범위 실수도 방지하는 일석이조 코드입니다.

 

 

예시 (나쁜 예)

// Date 생성자에는 날짜가 올바로 전달되는지 검사하는 코드가 있다고 가정합니다

Date read_date(istream& is);           // istream 로 데이터를 읽습니다

Date extract_date(const string& s);    // 문자열로부터 날짜를 추출합니다

void user1(const string& date)
{
    auto d = extract_date(date);    // (Date 생성자 호출 2)
    // ...
}

void user2()
{
    Date d = read_date(cin);        // (Date 생성자 호출 1)
    // ...
    user1(d.to_string());
    // ...
}

날짜가 올바른지 Date 생성자를 통해 2번 검증되지만, 애초에 동일한 값이었으므로 반복적으로 확인하여 성능을 낭비할 이유가 없습니다.

게다가 구조화된 데이터를 굳이 문자열로 바꿔서 전달하는 것은 꽤 비효율적인 방식입니다.

 

 

예시 (특수한 경우)

class Jet {    // Physics says: e * e < x * x + y * y + z * z
    float x;
    float y;
    float z;
    float e;
public:
    Jet(float x, float y, float z, float e)
        :x(x), y(y), z(z), e(e)
    {
        // 여기서 값이 물리적으로 의미가 있는지 확인해야 합니까?
    }

    float m() const
    {
        // 아니면 여기서 확인하는게 좋을까요?
        return sqrt(x * x + y * y + z * z - e * e);
    }

    ???
}

과도한 검사는 비용이 많이 들 수 있습니다. 어차피 생성할 당시 값들에는 문제가 없다고 하더라도 실제 계산식으로 활용될 때 부동 소수점 오차 때문에 코드의 유효성을 보장받지 못하는 경우도 있습니다.

모든 물리적 계산식에서 값의 정밀도를 잃지 않는지 매번 개체를 생성할 때마다 미리 검사해 보는 등의 행위는 불필요합니다.

마찬가지로 인터페이스를 만들 때 점근적 알고리즘에 대한 유효성 검사를 추가하지 마세요. (예: 평균 복잡도가 O(1)인 인터페이스에 O(n) 시점까지 확인하는 검사를 추가하지 마세요)

 

 

정리

1. 항상 포인터와 배열의 범위를 주의 : 범위를 벗어나지 않도록 미리 검사하되, 반복해서 검사하지 마세요.
2. 형 변환 시 유의 : 정밀도나 메모리 크기가 줄어드는 변환인 경우 막거나, 불가피한 경우 경고 표시하세요.
3. 사용자로부터 입력받은 확인되지 않은 값들을 한번 체크하세요
4. String(문자열) 대신 구조체나 클래스를 사용해서 데이터를 표현하세요