2016/02/16

c/c++ 숫자 연산에 대한 잡상 정리


틈틈이 장난감 프로그램을 만들고 있는데 Crash가 발생해서 들여다 보니 숫자 연산에 대한 문제때문이었다. 소시적에도 겪었던 일들인데 오랫만에 하다 보니 같은 실수를 반복하게 된다. 그래서 몇가지 공돌이들이 숫자 연산을 하다가 자주 실수하는 부분들을 정리해 보기로 했다.

수치해석을 들을 때 기본이 되는 컴퓨터에서의 숫자 연산에 대한 부분은 설명이 없어서 시행착오를 많이 겪었던 기억이 난다. 수치해석이란 학문 자체가 가능한 정확한 수치해를 구하는 것이 목적인데 사소한 숫자 연산 오류 때문에 심각한 수치해 오류가 발생할 수 있다. 제목에 c/c++ 라고 했지만, 사실 프로그램 언어와 관계없이 발생할 수 있는 문제들이기도 하다.

1. 정수형에 대한 나누기 연산

초보적인 상식이지만 실제 프로그램을 하다보면 실수를 하게 되는 부분이다. 형 변환시 정확한 값을 사용하지 않음으로 인해 오류가 증폭된다. 아래의 예와 같이 심지어 round() 함수를 쓰면 만사 땡인 줄 알고 실수하는 경우도 많다.
int m = 15, n = 2;

int i1 = m/n;                // i1 = round(1.0*m/n);
int i2 = round(m/n);         // i2 = round(1.0*m/n);
double x1 = 15/n;            // x1 = 15.0/n;
double x2 = m/n;             // x2 = 1.0*m/n;
2. 숫자 0 으로 나누기

정수형을 사용할 경우 0으로 나누면 프로그램이 바로 죽어 버리기 때문에 오류 위치를 쉽게 확인할 수 있다. 그런데, 실수형인 경우엔 프로그램이 0으로 나누는 부분에서 죽지 않는다. 0으로 어떤 수를 나누면 무한대 인데, 어디선가 무한대를 사용하다가 죽어 버린다. 물론 debugger가 어느 정도 죽는 위치를 알려 주지만 정확한 위치를 찾기 어려운 경우도 많다.
int int_zero = 0;
double real_zero = 0;

int i3 = m/int_zero;         // devide by zero. crash~!!!
double x3 = m/int_zero;      // devide by zero. crash~!!!
double x4 = m/real_zero;     // inf (infinity is OK~!!!) 
3. 엄청 크거나 작은 숫자에 대한 예외 처리

위와 같이 무한대에 가까운 큰 수들은 컴퓨터에서 표현할 수 있는 수치상의 한계를 넘기 때문에 예외처리를 해주어야 하는데 c++ 표준에서는 아래의 극한 값들을 정의하고 있다.
#include <limits>

double INFI = std::numeric_limits<double>::infinity();
double MAXI = std::numeric_limits<double>::max();
double EPSI = std::numeric_limits<double>::epsilon();

bool truth1 = (INFI > MAXI);        // true
bool truth2 = (-INFI < -MAXI);      // true
bool truth3 = (x4 > MAXI);          // true

double zero = 1.0/MAXI;             // positive zero
즉, 무한대는 최대값 보다 더 크며, 최대값과 동일하게 다른 수와 비교하여 예외처리를 할 수는 있으나 데이터 연산에 사용되면 프로그램이 갑자기 죽어 버리는 증상이 나타난다. 때로는 안죽고 이상한 숫자를 간직하고 있는 경우도 있다. 최대값에 가까운 큰 수로 어떤 수를 나누면 엄청 작은 수가 발생하는데, 이 놈들 역시 예외 처리를 해 주어야 한다.

4. 실수형에 대한 숫자 비교

프로그램이 죽어 버리면 상대적으로 문제를 쉽게 해결할 수 있는데, if문에서 실수형을 비교하다가 생기는 논리 오류는 찾기가 무척 어렵다. 미리 NearEqual()이나 NearZero() 같은 함수를 만들어 놓고 비교하는 것이 좋은 습관이 될 것이다. 또한, 가능한 상황일 경우, 아예 정수형으로 변환해서 값을 비교하는 것도 좋은 해결책이 될 수 있다.
int n1 = 3, n2 = 15/5;
double d1 = 3, d2 = 15/5.0;

bool b1 = (n1 == n2);             // always true~!!!
bool b2 = (d1 == d2);             // sometimes false.
bool b3 = (fabs(d1-d2) <= EPSI);  // something better.

int n3 = n2 - n1;
double d3 = d2 - d1;

bool b4 = (n3 == 0);              // always true~!!!
bool b5 = (d3 == 0);              // sometimes false.
bool b6 = (fabs(d3) <= EPSI);     // something better.
위에서 절대 오차나 절대값 자체가 매우 작은 수인 epsilon보다 작으면 숫자가 같거나 0이라고 간주하는 부분은 상황에 맞게 사용해야 한다. 가령, log 함수를 사용해야 할만큼 작은 숫자들을 가지고 논다면 상대오차도 비교해야 할 것이다.

5. 0으로 나누기 방지

의도치 않게 종종 나누기에 사용되는 변수 값이 0이 되어 버리는 경우가 발생할 수 있다. 이를 방지 하기 위해서 아래와 같이 protection을 걸어 두는 게 좋은 습관이 된다.
int m = 10, n = 0;
if(n) x2 = m/n;
else std::cerr << "Fatal Error: divide by zero.\n";

double d1 = 15/3.0, d2 = 5;
double t = 0, v = 20, d = d2 - d1;
if(fabs(d) > EPSI) t = v/d;
else std::cerr << "Fatal Error: divide by zero.\n";
이는 pointer 사용시 nullptr가 아닌지 확인해서 작업을 수행할 때 protection을 거는 것과 유사하다.
int n = 3, *p = n;
if(p) *p += 5;
else std::cerr << "Fatal Error: null pointer operation.\n"; 
6. NaN(Not-A-Number) 값에 대한 예외 처리

NaN 값은 0을 실수형 0으로 나누거나 수학 함수 값에 처리할 수 없는 범위의 입력값을 주었을 때 발생한다.
double d1 = 5*(3-3)/0.0;        // d1 = nan
double d2 = 10*log(-1);         // d2 = nan
NaN 값이 발생되어도 프로그램은 문제없이 동작하는 것처럼 보인다. 하지만 if 문에서 모르고 사용하면 엉뚱한 결과가 나올 수 있다.
bool b1 = (d2 <  0);            // false
bool b2 = (d2 >= 0);            // false
NaN이 발생하리라 예상되는 부분에 예외 처리를 해주는 것이 최선이다. 0으로 나누는 부분을 앞서 다룬 바와 같이 잘 처리하고 있다면 NaN이 발생할 수 있는 경우는 log 함수와 같이 특별한 수학 함수를 사용하는 경우가 아니면 발생할 일이 별로 없기는 하다.
if(std::isnan(d2)) std::cerr << "Warning Error: NaN value operation.";
7. 연산자 우선 순위

이 부분도 내가 자주 실수했던 부분이다. 연산자 우선 순위가 애매하면 괄호치는 습관으로 해결할 수 있다. 이런 종류의 논리 오류도 찾기가 무척 어렵다.
int size = 100, step = 10;

if(step && !size%step) ...;       // if(step && !(size%step)) ...;
8. 음수에 대한 나머지(remainder) 연산자(%)

간혹 음수까지 고려해서 나머지 값을 사용해야 할 경우가 생기는데, 항상 조심해야 하는 부분이다.
int a1 = -5%7, a2 = -5%(-7);      // a1 = a2 = -5
참고로, Python에서는 심지어 a1 = 2가 되더라. 리눅스에서 python으로 수식값을 빨리 확인해 보는 경우가 많은데, 프로그램 언어마다 상식적인 연산 결과가 다르게 나올 수도 있다는...

9. 짝수와 홀수

그래픽 프로그램을 만들고 있다면 짝수를 좀더 짝사랑하게 된다. pixel이 어긋나는 문제를 쉽게 해결할 수 있으니까.
if(n%2) ...
10. 유효숫자, 그리고 소수

과학이나 공학적인 계산에서 유효숫자는 매우 중요하다. 갯수를 헤아리거나 size를 다룰 경우 정확한 값을 사용해야 한다. 미분값을 구하는 경우에는 아주 작은 수치오류도 크게 증폭될 수 있다.
int n = 1000000 + 1;            // n = 1000001
double d1 = 1e+6 + 1;           // d1 = 1e+6
double d2 = MAXI - 1e+300;      // d2 = MAXI = 1.79769e+308 (64-bit system)
한편으로, 숫자의 범위를 다루거나 할 때 백만분의 1이라는 숫자는 무시해도 된다. 위에서 최대값에 아주 큰 수를 뺐지만 최대값의 유효숫자에  영향을 줄 정도로 큰 수는 아니므로 d2값은 MAXI가 된다. 유효 숫자를 제대로 고려하는 것은 간단한 문제는 아니다. 이런 문제는 수치해석에서도 다루는 문제들이기도 하다.

유효숫자 자리 수가 엄청 중요한 수가 소수(prime number)이다. 소수 자체가 유효숫자 덩어리니까. 전에 TV에서 봤더니 외국의 어느 인증회사에서는 금고에 몇 메가 바이트에 달하는 소수를 보관하고 있닥카더라.

11. 난수 구하기

가끔 특정 범위의 난수를 사용해야 할 때가 있다. 아래의 함수를 for loop에서 사용한다면 난수가 아니라 상수를 사용하게 된다.
// return random number in the range of (-max, max)
int Rand(int max)
{
    if(!max) return 0;

    srand(time(0));
    return rand() % (2*max) - max;
}
프로그램 실행시마다 새로운 난수가 발생되도록 현재 시간으로 seed를 주었는데 의도와는 달리 컴퓨터가 워낙 빨라서 for loop 내에서 현재시간이 계속 유지되기 때문이다. seed 주는 부분을 빼면 그나마 난수가 발생되는데 이른바 pseudo-random number이다. 즉, 프로그램을 새로 시작할 때마다 동일한 난수가 발생된다.

약간의 trick을 쓰면 의도대로 프로그램을 새로 시작해도 새로운 난수가 발생하도록 할 수 있을 것이다. 그리고, 위의 함수에 한가지 문제가 더 있는데 max 값이 너무 작으면 균일한 난수가 발생되지 않는다(non-uniform distribution). c++11에서는 새로운 <random> 헤더가 있던데 새로 배워야 할만큼 필요하진 않아서 패스...

댓글 없음:

댓글 쓰기