'c++'에 해당되는 글 7건

  1. 2011.05.14 기본 타입 간 (무분별한) 변환은 자제합시다 (3)
  2. 2011.02.27 Visual Studio는 C/C++을 싫어해 (?) (3)
  3. 2011.02.19 Bitwise Switch (2)
  4. 2010.12.05 프로그래밍 언어 만들기 上
  5. 2010.04.26 std::unique_ptr
  6. 2010.04.26 RVO와 NRVO
  7. 2009.11.07 C/C++의 몇 가지 키워드들 (1)

기본 타입 간 (무분별한) 변환은 자제합시다

개발 2011.05.14 01:59

 요새 하는 일 중 상당 부분이 대개 남이 짜놓은 코드에서 발생하는 버그들을 잡는 것인데, 이 버그들을 보다보면 여러 가지 생각을 하게 된다. 전생에 내가 뭘 잘못했길래 ... 같은 부류의. 내가 폭력적인 싸이코패스였다면 좀 달라졌을까? (...)


 이 중 상당수는 아주 기초적인 부분들을 신경쓰지 않아서 발생하는 버그다. 코드를 작성하기 시작할 때 최소한의 원칙을 세우고 거기에 대해 약간만 신경을 써주면 애초에 발생하지 않았을 버그들이지만, 그 시점에 치르는 약간의 비용이 아까워서/혹은 아예 그런 생각 자체를 못해서 차후에 1주~1달 가까이를 무의미하게 소모하게 된 것이다. 특히나 해당 코드를 작성한 사람이 아니라 대개 다른 사람이 삽질을 해야 한다는 점에서 질이 아주 안 좋다고 볼 수 있다. -_-


 아래에서 드는 몇 가지 사례는 프로그래밍에 있어서 기본 중 기본이라고 할 수 있는 기본 형 변환조차 제대로 다루지 못하여 발생한 버그 혹은 문제들이다.



  • signed/unsigned 정수형 간의 경계 없는 사용

 C/C++은 signed/unsigned 형을 구분하여 사용하며, 표준 라이브러리에서도 size_t, ptrdiff_t 등의 형태로 이 둘 모두를 사용하기 때문에 언어를 사용하는 입장에서 "signed 형만 골라 쓴다" 식의 고유한 정책을 취하기는 쉽지 않다. 게다가 이에 관련된 형 변환 정책은 직관적이지 못하고[각주:1], 자칫 잘못하면 off-by-one 에러와 함께 엮여 무한 루프를 만들어 내는 지옥도를 보기 십상이라 이는 언어 설계의 미스로 자주 지적된다.


 signed/unsigned 정수형 간의 비교 연산으로 인한 버그는 좀 식상한 이야기이니 다른 케이스를 이야기해보자. unsigned 정수형은 추상대수학에서 말하는 기본적인 환 ring 의 일종으로 덧셈, 곱셈이 수학적으로 잘 정의 well-define 된다. 이러한 수학적인 우아함 때문에 현존하는 거의 모든 아키텍쳐에서는 연산 과정에 오버플로가 일어나도 해당 결과에 UINT_MAX로 나머지 연산을 한 값이 나오도록 정의하며, C 표준도 그러하다.


 다만 signed 정수형에서 발생하는 overflow의 경우 C 언어 표준에 따르면 그 처리 방법은 미정의이다. 허나 2의 보수 체계를 택하는 대개의 아키텍쳐에서 signed 정수형과 unsigned 정수형의 차이란 비트값을 정수로 변환할 때 사용하는 다항식의 최상단 계수가 음수냐 양수냐 밖에 없으므로 회로를 간단하게 만들기 위해 이 둘 사이의 덧셈과 곱셈은 호환되게 만드는 경우가 많다. 그런 관계로 컴퓨터 구조를 어설프게 알고 있는 경우 이 둘 사이를 마음대로 오가도 문제 없다고 생각하는 경우가 있다.


 헌데 나눗셈과 나머지 연산이 나오면 이야기가 좀 다르다. signed와 unsigned에 적용되는 연산이 완전히 다른 것이다. 여기에 동일한 크기의 정수형 사이의 signedness 캐스팅에는 signed->unsigned 캐스팅이 이루어진다는 기본적인 캐스팅 원칙이 함께 적용되면 프로그래머 입장에서 어떤 연산이 적용될지 한 눈에 파악하기가 어려워진다.



 일례를 들어보겠다. 어떤 코드에서는 시점을 나타내기 위한 타입으로 아래와 같이 unsigned int를 사용한다고 치자.


typedef unsigned int TimeType;

 일반적으로 시점은 음의 값을 가질 이유가 없으며, timeGetTime 등을 사용하여 시간 값을 가져오는 경우 표현 가능한 기간에 현실적인 수준의 제약이 있는 점 등을 감안하면 이는 어느 정도 합리적인 선택이다. 그런데 여기에서 모종의 이유로 인해 시차를 표현하기 위한 용도로도 저 타입을 함께 사용하기로 했다. 게임으로 치자면 쿨다운이라거나 마법의 시전 시간 등도 포함될 것이다. 일반적인 경우라면 이 역시 대개 양의 값을 가질테니 큰 문제는 없을터다.


const TimeType Cooldown::getAppliedCooldownTime()
{
	return baseCooldown_ - cooldownBonus_/divisionFactor_;
}

 원래는 쿨다운 보너스는 양수 값만 가지고, 나누기 계수는 그 때 그 때 다른 값을 가진다고 치자. 그런데 어느 날 이걸 페널티로도 써보겠다는 멋진 생각 아래 기획에서 보너스 값에 음수 값을 넣기로 하였다고 치자. 코드에 대해 잘 모르는 경우라면 충분히 있을 법 한 일이다. 여기에 나누기 계수로 2를 넣는다면 저 함수의 반환 값은 대략 21억 가량이 나올 것이다. 대충 짠 코드 하나 덕분에 스킬 하나의 쿨다운 시간이 몇 개월이 되어 버린 멋진 결과다.


 unsigned와 signed를 섞어 쓰는 코드의 악랄함은 unsigned 정수형의 수학적인 우아함과 더불어 이에 완전히 호환되는 2의 보수 모델 덕분에 어지간하면 큰 문제 없이 잘 돌아 간다는 것에 있다. 그래서 C4018 같은 경고가 나와도 그냥 무시하는 코드도 많고, 경고조차 안 나오는 연산 코드에 대해서는 그 위험성조차 인지하지 못하는 사람들이 대부분이다. 이런 걸 당연하게 여기기 시작하면 이런 문제가 발생해도 잡아내기가 무척 힘들다.



  • 부정확한 부동 소수점을 정수형으로 캐스팅함으로 생기는 오차

 정수와 부동 소수점 사이를 오가는 코드 역시 주의 대상인데, 이런 코드에는 세 가지 문제점이 있다.



 하나는 정밀도 문제다. 부동 소수점은 정확한 수가 아닌 근사값이다. 특히나 십진법을 사용하는 입장에서 자주 사용하게 되는 수 대부분은 부동 소수점으로 정확한 값을 표현할 수 없다. 이런 근본적인 문제와 더불어 부동소수점을 정수형으로 캐스팅하는 연산이 단순한 절삭 연산이라는 점 때문에 0.00001 가량의 작은 오차가 1로 확 벌어지는 경우가 발생할 수 있다. 이를테면,


int value = 1000 * 1.7 * 1.14;

 정상적인 계산이라면 위의 값은 당연히 1938이 나와야 한다. 그런데 내 컴퓨터에서 돌리면 1937이 나온다. 사실 이건 양반이다. 심지어는 비트 레벨에서 완전히 동일한 double 두 값을 뺐는데도 0이 안 나오는 경우도 있을 수 있다. 대부분의 컴파일러에는 이런 비일관성을 줄이는 옵션도 있지만, 이는 프로그램의 속도에 악영향을 준다.



 더 큰 문제는 다른 컴퓨터/플랫폼에서도 항상 똑같은 결과가 나온다는 보장이 없다는 것이다. 이는 부동소수점 연산에는 여러 가지 방식이 있기 때문인데, 이로 인해 부동소수점 연산은 비결정적인 non-deterministic 특성을 띄게 된다. lockstep 류의 네트워크 동기화를 사용하거나 로직이 재연 가능해야 하는 경우에는 이렇게 작은 차이도 치명적이라 floating point determinism을 확보하기 위해 벼라별 삽질을 다 하는 마당에, 이런 작은 오차가 정수형 연산까지 번져 버리면 곤란하다.



  • 타입 간 표현 가능한 수치 범위의 차이로 인한 오버플로 문제

 또 하나의 문제는 타입 간 캐스팅으로 인해 생기는 오버플로다. 정수형끼리의 캐스팅을 다루는 경우는 대개 해당 타입들의 범위를 잘 알고 있다. 그렇기에 오버플로가 일어날 가능성이 아무래도 더 명백하게 드러나서 대부분 크게 문제가 되지는 않는다. 보통 이 경우는 연산으로 인한 오버플로가 더 문제다.


 그런데 부동소수점 실수형의 범위는 대부분 아주 큰 값, 심지어는 무한대로도 생각하는 경향이 있어서 오버플로가 발생할 가능성에 대해서는 아무래도 둔감하다. 실제로 발생하는 경우도 극히 드믈다. 그런데 유의해야 하는 경우가 있으니 바로 실수가 정수형 변수로 캐스팅되는 경우다. 예를 들어 소수점 이하 2자리를 정수형으로 반환하려는 의도의 아래 코드를 보자. (양수만 입력된다고 가정하자.)


const int32_t getFractional2(const float positiveInput)
{
    return (positiveInput - static_cast<int32_t>(positiveInput)) * 100.0f;
}

 아주 간단한 코드지만 버그를 가지고 있다. positiveInput이 만약 int32_t의 범위를 넘어선다면 이 코드의 결과는 미정의다. 해당 캐스팅이 어떤 결과를 가져올지는 플랫폼마다 다르겠지만, 내가 사용하는 플랫폼에서는 0x80000000가 나온다. 이 문제를 해결하려면 내림을 위해 int 캐스팅을 사용할 게 아니라 아래와 같이 floor 함수를 사용해야 한다. 이 경우 괄호 안의 표현식은 0.0 ~ 1.0 사이의 값을 가지게 되므로 오버플로가 일어나지 않는다.


const int32_t getFractional2(const float positiveInput)
{
    return (positiveInput - std::floor(positiveInput)) * 100.0f;
}

 사실 이 코드는 정밀도 한계 때문에 입력이 천만만 넘어가도 0만 반환하므로 별 의미 없는 문제일지도 모른다. 하지만 함수의 스펙대로 0~100 사이의 값이 반환되는 것과 예측 불가의 미정의된 값이 반환되는 것은 완전히 다른 이야기다. 입력의 범위를 확신할 수 있는 상황이 아니라면 가급적 오버플로가 일어나지 않게 방어적으로 코드를 짜는게 맞다. 산술 연산으로 인해 생기는 오버플로는 잡아내기 힘들지만, 캐스팅으로 인해 생기는 오버플로는 비교적 잡아내기 쉬우니 그리 어려운 일도 아니다. 단지 귀찮을 뿐.



  • 정수 - 실수 간 캐스팅 오버헤드

 이건 버그는 아니지만 한번 짚고 넘어갈 만한 부분이다. 정수형/실수형을 택할 수 있는 상황에서 속도 문제 때문에 정수형을 고집하는 사람들이 의외로 많다. 정수형 계산이 실수형 계산에 비해 빠른 것은 사실이다. 하지만 여러 분야에 있어 부동 소수점 연산 속도는 매우 중요하기 때문에 구조적으로 많은 최적화가 이루어졌고, 대부분의 프로세서는 부동 소수점 연산만을 위해 물리적/논리적으로 많은 자원을 할당해둔다. 이런 배려 덕분에 나눗셈 연산 정도를 제외하면 정수 연산과 부동소수점 연산의 속도 차이는 생각만큼 크지 않다.


 이를 위해 희생한 부분 중 하나가 있으니 바로 정수/실수 사이의 캐스팅에서 발생하는 오버헤드다. x86을 포함한 상당수의 아키텍쳐에서는 간결한 구조를 위해 FPU를 독립적으로 다루며, 레지스터 역시 정수형과 부동소수점형이 따로 분리되어 관리된다. 즉, 정수형과 실수형 사이의 캐스팅이 이루어진다는 것은 레지스터에 있는 정수형 레지스터에 저장된 값을 메모리로 빼놨다가 다시 실수형 레지스터로 옮겨가는 과정을 수반한다는 이야기다. 캐스팅 연산 자체의 비용은 그리 크지 않더라도 이런 메모리 연산 과정이 수반되기 때문에 가급적 피하는게 좋다.


 이런 구조를 모른다면 최적화라는 명분 하에 멀쩡한 float형 변수들을 전부 int로 바꾸고 여기에다 실수 상수들을 곱하고 나누는 삽질을 하는 것도 충분히 가능한 이야기이고 또 자주 일어나는 일이다. 어설픈 프로그래머보단 컴파일러가 미세 최적화는 훨씬 잘하니 아예 손을 안 대는게 낫다.



 이론은 이러한데, 아래의 코드를 가지고 실제로도 그런가를 확인해보았다.


const int ArraySize = 10000000;
const int IterationCount = 20;

int    cast[ArraySize];
float pureFloat[ArraySize];
int    pureInt[ArraySize];

#pragma optimize("", off)
void integerCast()
{
    for (int j = 0; j < IterationCount; j++) {
        for (int i = 0; i < ArraySize; i++) {
            cast[i] *= 1.1f;
        }
    }
}

void pureInteger()
{
    for (int j = 0; j < IterationCount; j++) {
        for (int i = 0; i < ArraySize; i++) {
            pureInt[i] *= 11;
        }
    }
}

void pureFloatingPoint()
{
    for (int j = 0; j < IterationCount; j++) {
        for (int i = 0; i < ArraySize; i++) {
            pureFloat[i] *= 1.1f;
        }
    }
}
#pragma optimize("", on)

 컴퓨터마다 그 결과가 다르겠지만, 내 경우는 순수 실수 사이의 연산이 정수와 실수를 섞은 경우보다 1.7 ~ 1.8배 가량 빨랐고, 순수 실수와 순수 정수 연산 사이에서도 큰 수준의 차이는 없었다. 루프에 들어가는 오버헤드나 pragma 때문에 놓친 공격적인 최적화의 기회까지 감안하면 실제론 이보다 더 크게 차이난다고 봐도 될 것이다. 물론 저렇게 무식하게 곱연산만 하는 경우는 없겠지만, 적어도 성능 때문에 정수형을 쓸 필요는 없다는 근거 정도는 되리라 생각한다. 부동소수점을 써선 안 되는 경우는 정밀도 문제가 엮여 있거나 연산의 일관성이 필요한 경우지, 적어도 높은 성능이 필요한 경우는 아니다.



 마지막으로 이런 문제점들이 모이고 모여 총체적 난국을 만들어내는 코드 하나를 살펴보도록 하자. 위에 나온 예시와 같이 시간을 표현하기 위해 unsigned 정수형을 사용하는 코드다.


...

    TimeType baseCastingTime_;
    TimeType castingTimePerLevel_;
    float castingTimeFactor_;

...

const TimeType Casting::getCastingTime(const int32_t skillLevel)
{
    return baseCastingTime_ * castingTimeFactor_ +
           castingTimePerLevel_ * skillLevel;
}

...

TimeType castingTime = casting.getCastingTime(skillLevel);

...

 baseCastingTime_이 300, castingTimePerLevel_이 -50, castingTimeFactor_가 1.0f, skillLevel이 3이라고 치자. 그럼 어떤 값이 나올까? signedness를 떠나 상식적으로 생각해보면 당연히 150이 나와야 한다. 그리고 실제로 돌려보니 150이 나왔다. 문제 해결! ... 이면 여기에 이렇게 글을 썼을리가 없다. -_-


 x86, 윈도우즈 플랫폼에서 위의 코드를 수행하면 확실히 150이 나온다. 그런데 x64로 컴파일해서 돌리면 0이 나온다! 어셈도 안 쓰고 플랫폼 의존적인 함수도 없이 순수 C++ 표현식만 썼음에도 플랫폼마다 다른 결과가 나오는 대단한 코드를 만들어 버린 것이다. -_-



 이런 결과가 나오는 까닭은 이렇다. (1) castingTimeFactor와 baseCastingTime을 곱하면 암묵적으로 float 캐스팅이 이루어진다. (2) 그리고 castingTimePerLevel과 skillLevel을 곱하면 역시 암묵적으로 TimeType으로 캐스팅된다. (3) 최종적으로 이 둘을 더한 결과는 float이고, 함수는 이를 다시 TimeType으로 캐스팅하여 반환한다.


 문제는 2번과 3번이다. 2번에서는 약 42억 가량의 unsigned 값이 들어가고, 3번에서 둘을 더한 float 값 역시도 42억 가량의 값이 들어가게 된다. 이 때 부동소수점 정밀도 문제로 인해 아래 9비트 만큼의 정보가 잘려 나가면서 정확히 2의 32승을 가리키는 값이 되어버리고, 이 값이 unsigned 정수로 캐스팅되면 0이 된다. 음수가 unsigned 정수형에 들어갈 때 내부적인 표현이 부동소수점 정밀도 문제와 겹치면서 문제가 발생한 것이다.



 그렇다면 의문점이 남는다. 왜 32비트에서는 제대로 된 값이 나왔나? 생성된 어셈 코드를 보면 32비트에서는 계산된 FPU 레지스터의 결과값(3)을 바로 정수형으로 치환해서 메모리에 저장하는 코드가 생성된다. 여기에서 한 가지 알아둬야 할 점은 x86 아키텍쳐의 FPU 내부에서 이루어지는 연산은 기본적으로 80비트 부동소수점을 다룬다는 점이고, 이 레지스터에 저장되는 값은 32비트 정수를 손실 없이 저장할 수 있다. 여기에 80비트->32비트 부동소수점 변환 과정이 생략되고 바로 정수형으로 캐스팅되므로 저렇게 올바른 값이 나오는 것이다. 실제로 float 형으로 한 번 지역 변수에 저장했다가 반환하면 64비트처럼 0이 반환된다.


 그런데 64비트는 MMX 명령어를 이용하여 부동소수점 연산을 수행하는 코드를 만들어 냈다. 이게 내부적으로 어떤 과정을 거쳐 수행되는지는 모르겠으나, 해당 명령어들이 수행되는 과정들을 관찰해보면 32비트와는 다르게 별다른 변환 과정 없이 32비트 부동소수점만을 이용하여 계산이 이루어지는 것을 볼 수 있다. 어떻게 보자면 이 쪽이 좀 더 일관성 있는 동작을 한다고 볼 수도 있다.



 결론은 간단하다.


  • 기본형 간의 불필요한 캐스팅이 일어나는 상황은 최대한 줄이도록 한다.
  • 연산은 가급적 같은 타입의 변수끼리 하도록 한다. 즉, 암묵적인 캐스팅을 자제한다.
  • 타입 간의 경계에서 일어나는 캐스팅은 정밀도나 범위등을 고려하여 주의 깊게 수행하도록 한다.

 위의 사례를 보면 알 수 있겠지만 unsigned형과 더불어 정수와 실수형을 마구 섞어쓰면 아주 사소해보이는 표현식 수행을 이해하는 일도 아주 피곤한 일이 되어버린다. 생각해보면 해결해야 할 문제의 본질과 전혀 상관 없는 저런 지식들을 프로그래머가 일일히 알아야 할 이유는 전혀 없다. 하지만 C/C++가 애초부터 잘못 설계된 언어인 관계로 어쩔 수 없다. 가급적이면 저런 상황 자체를 안 만드는 게 차선책이다.

  1. 심플하긴 하다. 크기가 같으면 signed를 unsigned로 캐스팅하며 다르면 더 큰 signed 형으로 캐스팅한다. 다만 언제는 signed로, 언제는 unsigned로 캐스팅하는 비일관성이 문제다. [본문으로]

'개발' 카테고리의 다른 글

NDC 간략한 참관기 #2  (1) 2011.06.12
NDC 간략한 참관기 #1  (0) 2011.06.04
기본 타입 간 (무분별한) 변환은 자제합시다  (3) 2011.05.14
Visual Studio는 C/C++을 싫어해 (?)  (3) 2011.02.27
Bitwise Switch  (2) 2011.02.19
std::unique_ptr  (0) 2010.04.26
Trackback 0 : Comments 3

Visual Studio는 C/C++을 싫어해 (?)

개발 2011.02.27 12:58
 이번에 새로운 프로젝트를 세팅하면서 해보려고 했던 것 중 하나가 비쥬얼 스튜디오의 텍스트 파일의 기본 인코딩 방식을 CP949에서 UTF-8로 바꾸는 것이었다. (여기에서 뭔가 이상함을 느끼는 사람들도 있을텐데, 일단 계속 읽어주시길 바란다.)

 내가 개인적으로 작성하는 코드 대부분은 가급적 영어로만 작성하려고 하기 때문에 유니코드를 고집해야 할 절체절명의 이유는 없다. 하지만 앞으로도 한글을 전혀 적지 않는다는 보장은 없는 법이며, 가끔 가다 ASCII 영역 이외의 문자 및 인코딩 방식을 사용해서 컴파일에 문제가 발생하는 오픈 소스 프로젝트 덕분에 귀찮음을 겪은 적이 있었다. 그래서 발생하지 않을만한 경우까지 고려하는 성급한 일반화 Premature generalization를 하는 안 좋은 버릇이 발동한 것이다.

 사실 비쥬얼 스튜디오에서 저장하려는 파일의 인코딩을 바꾸는 것은 그리 어려운 일이 아니다. 버젼마다 방법이 약간 다르긴 한데, 2010에서는 File - Advanced File Options...을 택하면 현재 편집 중인 텍스트 파일의 인코딩 및 개행 방식을 바꿀 수 있다. 다만 이게 파일 한 개 단위로 적용이 되기 때문에 프로젝트 전체에 대해 일괄적인 변경을 가한다거나 하는 작업은 불가능하다. 내가 바라는 것은 솔루션/프로젝트 단위로 적용할 수 있는 파일 정책인 것이다.

 그리하여 열심히 웹을 뒤지다 보니 놀라운 문구를 하나 발견할 수 있었다.


Visual Studio saves files in UTF-8 encoding by default, which is a Unicode-based format and should be handled appropriately by most computers.

VS2010: Unicode file saving | Microsoft Connect
 
???!!!


 ... 그렇다면 그간 CP949로 저장되어 온 수 많은 나의 .cpp, .h 파일들은 대체 어떻게 된 것인가? 하지만 내 실수가 있을 수도 있기에 아래의 단계를 밟아 다시 한 번 검증해 보기로 하였다.

  1. 새로 프로젝트를 만든다.
  2. 한글을 포함하는 소스 파일을 만든다.
  3. 저장한다.
  4. 인코딩 방식을 확인한다.
 아니나 다를까 역시나 CP949로 저장이 되어 있다. 하지만 이 것만 가지고 무려 Microsoft 공식 답변의 권위를 무시하기는 어려웠기에 조금 더 정확한 정보를 얻기 위해 다른 정보를 찾아보았고, 똑같은 사이트에서 무언가 수상한 문구를 하나 찾을 수 있었다.


We did switch to using UTF-8 w/BOM from VS 2008 onwards,
with a few exceptions for Native Tools.

Source FIle Encoding. | Microsoft Connect


 즉, 2008에서 만든 네이티브 프로젝트에선 사용자 컴퓨터의 로케일에 맞는 지역 인코딩을 기본으로 택한다는 이야기이다.[각주:1] 그렇다면 애초에 유니코드가 기본인 CLI 계열의 프로젝트는 이미 UTF-8로 잘만 저장을 해왔다는 것이다... 이 충격적인 진실을 확인하기 위해 기존에 만든 C# 소스 파일들을 뒤져보니 역시나 UTF-8 with BOM으로 저장되어 있었다. orz

 요약하자면,
  • VS 2008까지는 일부 네이티브 프로젝트의 텍스트 파일은 UTF-8이 아닌 시스템 로케일에 따른 인코딩 방식으로 저장된다.
  • VS 2010에서는 모든 텍스트 파일의 기본 인코딩 방식은 특별히 명시하지 않는 한 UTF-8 with BOM이다.
  • 그런데 네이티브 프로젝트들은 여전히 시스템 로케일에 따라 인코딩 방식이 선택된다. orz
  • 따라서 우리는 MS가 이런 간단한 문제조차 신경쓰지 않을 정도로 네이티브 개발자들을 홀대한다는 결론을 내려볼 수 있다. (??)

 

  1. 물론 위의 답변에서 VS 2010은 자동으로 UTF-8을 택한다고 했으니 이론적으로 VS2010에서는 네이티브 프로젝트라고 해도 UTF-8로 저장되는게 맞다. [본문으로]

'개발' 카테고리의 다른 글

NDC 간략한 참관기 #1  (0) 2011.06.04
기본 타입 간 (무분별한) 변환은 자제합시다  (3) 2011.05.14
Visual Studio는 C/C++을 싫어해 (?)  (3) 2011.02.27
Bitwise Switch  (2) 2011.02.19
std::unique_ptr  (0) 2010.04.26
RVO와 NRVO  (0) 2010.04.26
Trackback 0 : Comments 3

Bitwise Switch

개발 2011.02.19 01:56

 코딩하다 비트셋을 쓰면 아래 같이 각 플래그들을 검사해서 해당되는 경우 관련 작업을 수행하는 식의 코드를 줄줄이 작성하는 경우가 가끔 있다.


if (bitset & FLAG_A) {
	/* A 플래그와 관련된 작업들 */
}

 ...

if (bitset & FLAG_Z) {
	/* Z 플래그와 관련된 작업들 */
}


 오늘 위와 같은 코드를 보다가 아래처럼 비트셋에 적용되는 switch 문이 있으면 좋겠다는 생각이 들어서 검색해보았는데 못 찾아서 (그 이유는 아래에서 밝혀진다 -_-) 직접 만들어 보기로 하였다.


bitwise_switch(bitset) {
	case FLAG_A: /* A 플래그와 관련된 작업들 */

	...

	case FLAG_Z: /* Z 플래그와 관련된 작업들 */
}


 사실 구현 자체는 그리 어려운게 아니지만 비트 연산을 이용한 몇 가지 트릭이 필요했다. 일단 코드는 아래와 같다.

 
// Calculate LSB index in O(1) using de bruijn sequences.
// For more information, read "Using de Bruijn sequences to index a 1 in a computer word"
inline const int GetLSBIndex(const uint32_t LSB)
{
	static const uint32_t DeBruijnConstant32 = 0x077CB531U;
	static const int32_t HashTable[32] =
	{
		 0,  1, 28,  2, 29, 14, 24,  3,
		30, 22, 20, 15, 25, 17,  4,  8,
		31, 27, 13, 23, 21, 19, 16,  7,
		26, 12, 18,  6, 11,  5, 10,  9
	};

	return HashTable[static_cast(LSB * DeBruijnConstant32) >> 27];
}

inline const uint32_t ExtractLSB(uint32_t& bitset)
{
	uint32_t LSB = bitset & (-bitset);
	bitset = bitset ^ LSB;
	return LSB;
}

#define bitwise_switch(bitset) \
	for (uint32_t __bitset__ = bitset; __bitset__ != 0;) \
		switch (GetLSBIndex(ExtractLSB(__bitset__))) \


 동작은 아주 간단하다. 주어진 비트셋에서 Least Significant Bit를 추출한 뒤 추출한 LSB의 위치를 계산, 이 값을 switch-case 문으로 넘기는 것을 반복하는 것이다.
 
 위 코드에서 함수 ExtractLSB는 주어진 비트셋에서 LSB를 뽑아내는 비트 연산 기법인 bitset & (-bitset)을 구현한 코드로 쉽게 이해할 수 있다. 함수 GetLSBIndex는 주어진 LSB 값을 받아 오프셋을 계산하는 함수인데, 이를 위해 De Bruijn 수열을 이용, Perfect hash function을 만들어 사용한다. 알고리즘의 원리가 궁금하신 분들은 이 논문을 참조하시면 되겠다. 비트 값을 굳이 이렇게 오프셋 값으로 바꿔서 사용하는 이유는 switch-case 분기문에 주어지는 인자가 연속된 정수 값인 경우 컴파일러가 이를 점프 테이블을 이용하는 간접 분기문으로 바꾸는 최적화를 해주기 때문이다.


 최적화가 목표였던 건 아니지만 그래도 각각의 비트에 대한 분기 처리가 O(1)에 처리되도록 신경을 썼으니 처리 속도가 궁금해진다. 그렇다면 이 기법을 이용하는 경우 속도가 얼마나 빨라질까? 불필요한 비트 검사가 생략되니 빨라질 수도 있겠지만, 비트 하나를 검사하는데 들어가는 비용이 확연히 커졌으니 느려질 수도 있다. 과연 결과는?


열기



 오늘의 교훈: 최적화 같은거로 고민하지 말자. 최적화로 얻는 시간보다 최적화에 들이는 시간이 더 많을거다. -_-;;

'개발' 카테고리의 다른 글

기본 타입 간 (무분별한) 변환은 자제합시다  (3) 2011.05.14
Visual Studio는 C/C++을 싫어해 (?)  (3) 2011.02.27
Bitwise Switch  (2) 2011.02.19
std::unique_ptr  (0) 2010.04.26
RVO와 NRVO  (0) 2010.04.26
volatile과 메모리 배리어  (2) 2009.11.11
Trackback 0 : Comments 2

프로그래밍 언어 만들기 上

컴퓨터 과학 2010.12.05 14:11
소프트웨어 엔지니어들에게 있어서 로망이 있다고 하면 OS, DB, 컴파일러, 프로그래밍 언어 같이 컴퓨터 과학의 정수를 담은 물건을 직접 만들어 보는 게 하나다. 특히나 프로그래머들이 직접 사용하는 도구인 프로그래밍 언어가 그런 경향이 좀 있다. 좀 쓰다보면 다른 언어와 비교되는 아쉬운 점이 계속 보이는데, 진행 중인 프로젝트에서 사용하는 언어를 갈아 타는 일은 대개 불가능해서 입맛만 다신다.

나는 C++을 주로 사용하는데, 이 언어는 여러 가지 문제점을 가지고 있는 언어다. 그런데 표준화 위원회에 의해 개발되기 때문에 발전 속도도 무척 느리다. [각주:1] 다른 언어들에서는 언어 차원에서 기본적으로 지원되는 기능이 C++에서는 차기 표준은 커녕 검토 목록에도 아직 올라오지 않은 것을 보면 배가 아프다. 이런 문제에 대한 가장 근본적인 해법은 그냥 새로 언어를 만드는 것이다. 그런데 그게 쉬운 일은 아니니 공상부터 하게 된다.

서두가 쓸데 없이 길었다. 제목에 낚시성이 농후한데, 조금 더 정확한 제목은 "프로그래밍 언어 상상해보기" 정도일 것이다. 쓰는 언어의 근본이 C++이라 학계의 최신의 연구 결과를 반영하는 것까진 감히 상상하지 못하고, 그저 C++을 내 취향대로 다시 reengineer한다면 이런 모양이 아닐까 이런 느낌이다.


1. 기본적인 문법

언어의 기본적인 성격과 문법들, 이를테면 절차 지향 언어라는 점 혹은 사용하는 표현식 문법이나 변수 선언, 블록, 상수성 등에 있어서는 C 계열의 언어로 분류될 수 있을 것이다. C/C++이 복잡한 문법을 가지는 언어로 지탄받지만, 이런 부분은 의외로 직관적이고 괜찮은 편이다. 다만 함수 문법은 약간 수정되어야 할 필요가 있다고 생각한다. 이를테면 아래와 같은 코드가 있다고 하면

someclass someobject(someargument);

현재의 C++과 같은 문법으로는 이게 someclass 타입의 실체화인지 아니면 someclass라는 타입을 반환하는 함수의 선언인지 문맥을 파악하기 전까지는 알 수 없다. 다른 사례를 들자면

// ScopedLock은 operator bool 함수를 오버 로딩하고 있으므로
// 필요한 경우 암묵적으로 false로 캐스팅이 된다.

// 컴파일 에러
#define synchronized(lock) \
    if (ScopedLock<decltype(lock)> __scope(lock)) {} else
// 컴파일 됨
#define synchronized(lock) \
    if (ScopedLock<decltype(lock)> __scope = lock) {} else

이건 자바의 synchronized 키워드를 에뮬레이션하기 위해 만들어 본 매크로인데, 안타깝게도 전자는 함수 선언을 if 문에 넣은 것으로 간주하여 컴파일이 되지 않는다. 왜 이렇게 되는지가 쉽게 이해가질 않는데, 어쨌든 직관성이라는 부분에 있어서 이 부분은 빵점이다. 애초에 전역 함수가 없거나 선언과 정의라는 개념을 분리하지 않는다면 간단하게 해결될 문제긴 하지만.


2. 정적 강타입 언어

나는 코딩할 때 실수가 잦은 편이라 정적 강타입 언어를 선호한다. IDE의 도움을 받지 않으면 코드를 살짝만 수정해도 에러가 5~6개씩 뜨는건 기본이다. 그런데 만약 여기에서 타입 안전성 체크가 없었다면 어떤 재앙이 일어났을지 생각해보면 좀 무섭다. 사실 이게 lua를 쓸 때 가장 난감했던 점이기도 하다. 변수나 멤버 이름 하나를 오타 내도 런타임에 nil을 꺼내 쓸 뿐 컴파일 시점에서 아무런 경고가 뜨지 않는다. 이런 걸 디버깅하는 것은 무척 괴로운 일이다. 변수를 명시적으로 선언해서 쓰는 언어에서는 이런 문제가 덜하지만, 타입 정보가 없음으로 인해 프로그래머가 가정할 수 있는 내용이 적다는 것은 여전히 약점이다.

정적 타입 언어의 강점은 또 하나 있다. 타입 정보가 컴파일 시점에 완전히 알려지기 때문에 최종적으로 수행되는 코드의 효율성은 동적 타입 언어의 그 것과 비교가 되질 않는다는 것이다. 아래와 같은 코드가 있다고 치자.

var a = 10, b = 2.0f, c = "haha";
var d = a+b+c;

동적 타입 언어에서는 우선 a, b의 타입을 체크해서 어떻게 타입 캐스팅을 하고 덧셈 연산을 수행할 지 판단한 뒤에야 실제 덧셈 연산이 수행될 수 있다. 위 같은 코드는 그나마 컴파일 시점에 최적화를 해볼 여지가 있지만, 함수 인자 등으로 넘어오는 경우는 얄짤 없다. 하지만 정적 타입 언어는 모든 타입 정보를 컴파일 시점에 가지고 있기 때문에 타입 캐스팅과 수행할 덧셈 연산이 컴파일 시점에 전부 결정된다. 분기문이 완전히 사라지므로 CPU 파이프라인을 훨씬 효율적으로 사용할 수 있다. 당연히 수행 효율은 동적 타입 언어가 범접할 수 없는 수준이다. 물론 요즘 컴파일러/가상머신 기술들이 좋아져서 그 격차가 많이 줄어들긴 했지만, 이건 내가 직접 만든다는 가정하에 공상을 하는 것이므로 내가 구현할 수 없는 내용엔 큰 의미를 두지 않겠다. (...)

물론 강타입 언어의 사용성은 동적 타입 언어에 비해 현저히 떨어진다. C++의 iterator 삽질만 해도 그렇다. 좀 오버스러운 사례지만, std::vector<std::list<std::pair<someclass::key, someclass::value> >::iterator >::iterator 이런 것을 일일히 쳐주다 보면 내가 무슨 일을 하는건지 알 수 없는 경지에 도달하게 된다. 하지만 기본적인 수준의 타입 유추 기능이라도 도입하면 이런 문제는 많이 완화시킬 수 있다. 사실 템플릿을 지원 안 하는 것도 좋은 방법이다. 취미 수준으로 만들기에 C++ 같은 템플릿은 너무 방대하고 복잡하다.


3. 구조적 타이핑 Structural typing

객체 지향 프로그래밍을 한다고 할 때 보통 각각의 객체에는 부여된 책임이 있을 것이고, 이를 수행하기 위해 필요한 연산의 집합이 있을 것이다. 전통적인 OOP 언어에서는 해당되는 연산의 집합을 정의하는 상위 클래스를 만든 뒤 이를 상속받아서 다형성을 이용한다.
class Duck {
public:
    virtual void walk() = 0;
    virtual void swim() = 0;
    virtual void quack() = 0;
};
// 새끼 오리
class BabyDuck : public Duck {
    ...
};
// 나는 오리
class FlyingDuck : public Duck {
    ...
};
Duck* duck = new FlyingDuck;

반면 덕 타이핑을 지원하는 언어에서 조금 다른 방식의 다형성이 지원된다. 이는 "오리처럼 걷고, 오리처럼 수영하고, 오리처럼 꽥꽥거리는 새를 본다면 이를 오리라고 한다."라는 설명에서 유래한 용어다. 동일한 메타포를 이용하여 상속 기반 다형성을 표현하자면 "오리란 오리처럼 걷고 오리처럼 수영하고 오리처럼 꽥꽥거리는 새이다" 정도가 될 수 있다.

이 개념을 지원하는 언어에서는 상속과 같이 코드에 선언된 타입 정보와는 무관하게 런타임에 객체가 지원하는 연산 집합을 판단, 사용하려는 문맥에 의미적으로 호환되는지를 확인한다. 따라서 타입 호환 여부를 런타임 환경에서 직접 검증해야 하는 대부분의 동적 타입 언어에서는 덕타이핑을 어렵지 않게 지원할 수 있다. 하지만 정적 타입 언어에서는 이야기가 다르다. 컴파일되고 나면 대부분 심볼 및 타입 정보가 날아가 버리며, 설령 타입 관련 메타 데이터들이 남아 있다고 해도 이를 실시간으로 찾아서 디스패칭해주는 오버헤드를 감수하는 것은 있을 수 없는 일이다. 게다가 타입 안전성을 런타임에 체크하려면 굳이 정적 타입 언어를 쓸 이유가 없다.

구조적 타이핑은 그 사이의 중간점을 취한다. 상속과 같은 타입 구조가 아닌 지원하는 연산에 따라 타입을 판별하는 것은 덕 타이핑과 똑같다. 하지만 타입 간의 호환성은 컴파일 시점에 검증함으로써 타입 안전성과 더불어 수행 효율을 증대시키는 방법이다.
interface Duck {
public:
    void walk();
    void swim();
    void quack();
};
// 새끼 오리
class BabyDuck {
    ...
};
// 나는 오리
class FlyingDuck {
    ...
};
Duck duck = new FlyingDuck;

구조적 타이핑을 지원하면 얻을 수 있는 이점으로는 여러 가지가 있겠지만 가장 큰 것으로는 각각의 객체들이 타입 정의에 종속되지 않기 때문에 프로그램 내부의 결합도가 크게 낮아진다는 강점이 있다. 요구하는 인터페이스와 구현 사이의 관계가 칼 같이 분리되기 때문이다. 다형성을 활용하기 위해 상속 구조를 고민할 필요가 없어지고, 그저 인터페이스에서 요구하는 연산을 구현해주기만 하면 된다. 그리고 타입에 대해 필요한 가정이 객체가 지원하는 연산 집합으로만 한정되므로 C++의 템플릿을 이용하는 것처럼 일반화 프로그래밍 Generic programming이 가능해지는 것도 한 가지 장점이다.

이렇게 좋은 개념이 있음에도 불구하고 그간의 주류 OOP 언어에서 지원하지 않은 이유는 이를 효율적으로 구현하는 데에 있어 난점이 있었기 때문이다. 상속 기반 다형성을 구현하기 위해서 보통 가상 함수 테이블을 사용하는데, 이는 상속이라는 개념을 통해 해당 클래스가 어떤 인터페이스에서만 사용될지를 예측할 수 있어서 가능한 것이다. 하지만 구조적 타이핑 언어에서는 비슷한 방법을 쓰려면 프로그램 전체에 대한 분석을 수행하던지 모든 클래스와 모든 인터페이스에 대해 필요한 디스패치 테이블을 만들어줘야 한다. 이런 이유로 구조적 타이핑을 지원하는 언어가 흔치 않았는데 비교적 최신 언어인 haskell이나 구글의 go 등에서는 타입의 메타 데이터를 이용, 실제로 사용되는 클래스-인터페이스 조합에 대해서만 런타임에 딱 한번 디스패치 테이블을 만들어 사용하고 컴파일 시점에는 타입 안전성만을 체크하는 방법을 통해 효율적인 구현을 해냈다.


4. 가비지 컬렉션 Garbage collection

C++ 프로그래머들에게 Java에서 가장 부러운 것을 들라고 하면 아마도 첫 번째로는 방대한 표준 라이브러리가 나올 것이고, 두 번째로는 가비지 컬렉션이 나올 거라고 생각한다. 적어도 난 그렇다. 그래도 전자는 boost라는 괴물이 있기 때문에 많은 부분 상쇄될 여지가 있지만, 후자는 그 구현의 난이도 때문에 당분간은 가망이 보이질 않는다. [각주:2]

다행스럽게도 하위 호환성 신경 쓸 필요 없는 완전히 새로운 언어를 만드는 경우의 가비지 컬렉션 지원은 "비교적" 간단하다. 그런데 멀티 쓰레드 환경에 들어가면 그 비교적이라는 단어의 의미가 좀 무색해지긴 한다. orz

가비지 컬렉션의 가장 분명한 강점은 당연히 메모리 관리가 수월해진다는 점에 있다. 하지만 그 외에 얻을 수 있는 것으로는 추가적으로 메모리 압축 memory compaction을 지원함으로써 얻는 성능적인 이점이다. 메모리 압축이 이루어지면서 단편화 문제도 원천적으로 사라지고, 캐쉬 지역성을 조금 더 잘 활용할 수 있다는 점 등으로 인해 가비지 컬렉션으로 인한 성능적인 약점이 어느 정도 상쇄된다. 또한 메모리를 어떻게 할당해도 최종적으로는 공간을 최적으로 활용한다는 보장이 있기 때문에 새로운 메모리를 할당하는 속도가 일반적인 메모리 할당자와는 비교도 안 될 정도로 빨라진다는 점도 있다. [각주:3]

허나 가비지 컬렉션이 아무리 빨라져도 수동으로 해제하는 것에 비해서는 빠르기가 어렵다. 이게 단순히 프로그램 전반의 throughput이 떨어지는 정도라면 큰 문제가 아니겠지만, 그 부하가 한 순간에 집중됨으로써 반응 속도가 느려지는 것이 문제다. 이를 해결하기 위해 사용되는 다양한 알고리즘들이 있지만 모든 경우에 대해 완벽하게 적용 가능한 일반적인 알고리즘은 없다. 또한 가비지 컬렉션을 사용하는 경우 프로그래머가 객체의 수명을 직접 컨트롤할 수 없다는 것 역시 큰 문제다. 따라서 가비지 컬렉션에 사용되는 정책을 필요에 따라 적절하게 조절할 수 있는 기능과 더불어 소유권이 명백한 객체에 대해서는 사용자가 수동으로 관리할 수 있도록 하는 방법을 공존시킬 방안을 모색해봐야 한다.


어쩐지 너무 길어지고 있다. 다음에 꼐속.
  1. C++0x가 2012년에 공표된다는 이야기를 보면 마이너 패치였던 C++03을 제하면 첫 표준 이후로 다음 표준까지의 기간이 14년이나 걸리는 셈이다. [본문으로]
  2. 소멸자가 호출되는 시점이 결정적(deterministic)이라는 C++의 특성과 연관된 부분들에서 많은 문제점들이 발생한다. 포인터를 값으로 직접 다룰 수 있는 언어의 특성도 한 몫한다. [본문으로]
  3. 오라클의 JVM 구현에서는 메모리 할당이 10사이클 내외에 이루어진다. [본문으로]
Trackback 1 : Comment 0

std::unique_ptr

개발 2010.04.26 22:32

 C++ 차기 표준인 C++0x에서는 다양한 언어적 차원의 기능과 라이브러리들이 추가되었는데, 라이브러리 중 unique_ptr라는 상당히 흥미로운 물건이 추가되었다. 이 클래스의 역할은 기존의 auto_ptr 클래스를 대체하는 것인데, 이를 이해하려면 auto_ptr에 대해 어느 정도 이해가 필요하다.

 
 auto_ptr는 C++ 코딩의 원칙 중 하나인 RAII[각주:1]를 구현하기 위해 표준 라이브러리에 존재하는 클래스로, auto_ptr 객체가 정의된 스코프를 벗어나면 해당 auto_ptr에 저장된 객체의 해제를 보장하는 역할을 한다. 간단히 말해 스마트 포인터인 셈이다. 안 그래도 복잡한 언어인 C++에서 이러한 스마트 포인터의 존재는 코드의 복잡도를 낮추는데 무척 중요한 역할을 한다. 자원 할당 및 해제까지는 조심스럽게 코딩함으로써 어찌 할 수 있겠으나, 예외 안전성의 영역까지 가면 자원을 사람이 일일히 관리하기가 거의 불가능한 수준이 되기 때문이다. [각주:2]

 헌데 auto_ptr 클래스의 동작을 잘 보면 상당히 특이하다. 일반적으로 복사 생성자와 대입 연산자는 동일한 객체를 두 개 만든다는 의미에 맞게 동작하는데, 이 경우는 포인터의 소유권을 이전하는 방식으로 동작한다. 이를테면 auto_ptr 객체 a, b가 있을 때 a에 b를 대입하면 a가 가리키고 있던 객체는 해제되고 b에 있는 객체를 가리킨다. 그리고 b에는 널 포인터가 들어간다. 결과적으로 한 포인터를 가리키는 auto_ptr 객체는 동시에 하나만 존재하게 되어 이중 해제가 되지 않음을 보장하는 것이다. (물론 잘못 쓰면 충분히 가능한 일이지만)


 문제는 STL 컨테이너를 사용하는 경우 컨테이너에 들어가는 객체들에 요구되는 조건 중 하나가 복사 생성자와 대입 연산자가 동일한 객체를 두 개 만든다는 의미를 가져야 하는 것이다. 헌데 auto_ptr는 이러한 조건을 지키지 않기 때문에 STL 컨테이너에 사용하는 경우 내부에서 이루어지는 대입, 복사 과정에서 가리키는 포인터가 마구마구 해제되어 버리는 불상사가 발생한다. 그렇기 때문에 표준에서 auto_ptr를 STL 컨테이너를 구체화시키기 위한 타입으로 사용하는 것을 아예 금지하고 있으며[각주:3]여기를 참조하자.'>, boost에서는 아예 복사라는 개념 자체를 제거한 scoped_ptr을 제공한다.

 C++에서 STL 컨테이너에 담을 수 없는 클래스라면 아무리 좋은 물건이라도 반쪽 클래스일 수 밖에 없다. 이러한 것을 극복하기 위해 소유권을 이전하는 형식이 아닌, 소유권을 나누는 방식의 shared_ptr이 제안된다. 이는 레퍼런스 카운팅 방식으로 동작하는 포인터로 사실상 모든 경우에 대해 포인터를 대체할 수 있으며 대부분의 경우에 대해 멀티 쓰레드 환경과 예외에 대해 안전하고 가리키는 포인터가 없어지는 경우는 해당 자원이 자동으로 해제되기 때문에 무척 유용하다. 순환 참조에 대해서만 조심하면 자바나 C#의 가비지 콜렉터가 부럽지 않을 정도다.

 그러나 모든 편리함은 그 댓가를 필요로 하는 법이다. shared_ptr은 자체적으로 레퍼런스 카운팅을 제공하지 않는 클래스에 대해서도 동작해야 하기 때문에 각 개체마다 레퍼런스 카운트 값을 저장할 공간을 추가로 가져야 한다. 이 값은 shared_ptr마다 하나가 아니라 가리키는 객체마다 하나씩 필요하므로 새로운 포인터를 가리킬 때마다 추가로 힙 공간을 할당해야 한다는 의미이다. 게다가 shared_ptr의 복사 및 소멸마다 레퍼런스 카운트 값을 조절해야 하는데 이는 쓰레드 안전해야 하므로 비교적 수행 속도가 느린 atomic operation을 이용해야 하며, 이는 전부 힙 공간을 조작하는 행위이므로 캐쉬 지역성에 악영향을 끼칠 우려가 있다. 여기에 추가로 객체 접근을 위해 포인터를 역참조할 때 이 값이 shared_ptr에 저장된다면 shared_ptr의 크기가 2배로 불어나며 레퍼런스 카운트를 저장해두는 곳에 저장한다면 간접 참조를 해야 하므로 메모리 연산이 두 번 일어나는 셈이다.

 추가 - 다만, shared_ptr과 대상 객체를 한 번에 만드는 make_shared 함수를 사용하면 메모리 할당을 한 번으로 줄이고, 레퍼런스 카운트 값을 객체에 인접시켜 저장하는 구현이 가능하다. 하지만 현재 vc 같은 경우는 아주 멍청하게 구현이 되어 있으므로 이건 그냥 shared_ptr을 쓰는 것과 차이가 없다. 제대로 구현을 하기 위해서는 type erasure를 이용한 다소 까다로운 테크닉이 들어가야 하기 때문.

 물론 대부분의 경우 shared_ptr의 유용성은 이러한 오버헤드를 충분히 감수하고도 남을만 하지만, 해당 객체의 수명이 아주 명확한 경우까지 shared_ptr을 쓰는 것은 낭비라고 볼 수 있다. 다시 말해 auto_ptr와 shared_ptr 사이의 절충안이 있으면 좋겠다는 의미다. 이를 위해 C++ 표준 위원회에서는 unique_ptr이라는 클래스를 새롭게 도입한다.


 unique_ptr은 기본적으로 auto_ptr와 유사하게 소유권의 이전에 기반한 동작을 한다. 그러나 일반 복사 생성, 대입 연산이 아닌 C++0x에서 새롭게 추가된 R-value reference를 이용한다는 것이 틀리다. 자세히 들어가자면 무척 긴 내용이라 시시콜콜 설명하진 않겠으나 R-value reference를 이용한 복사 생성와 대입 연산은 의미적으로 값의 복사가 아닌 값의 이동을 뜻한다. [각주:4] 때 마침 STL 컨테이너에서도 효율성을 위해 내부적인 복사 및 대입 동작은 전부 R-value reference를 이용하도록 바뀌었는데, 일반 복사 및 대입 연산자를 막아 버리는 대신 R-value reference를 이용한 복사 및 대입 연산자만 정의한다면 STL에서도 사용할 수 있는 auto_ptr가 생긴다는 것이 unique_ptr의 의미이다.


 unique_ptr와 auto_ptr의 차이점은 대충 아래와 같이 정리할 수 있다.

  • 일반 복사, 대입 연산을 막은 대신 R-value reference를 이용함으로써 그 의미가 뚜렷해진다.
  • 또한 STL 컨테이너에서도 사용할 수 있다.
  • 해제자를 따로 지정할 수 있다. (배열의 경우는 템플릿 특수화를 통해 전용 해제자를 이미 정의해놓은 상태이다)

 간단히 말해 auto_ptr가 할 수 있는 것은 unique_ptr도 전부 할 수 있으며, 거기에 다양한 기능이 추가된데다 raw pointer에 비해 추가적인 오버헤드도 없는 스마트 포인터라 할 수 있다. 앞에서 말한 auto_ptr를 대체하기 위한 클래스라는 것은 바로 이런 의미이다. 다만 의미적인 뚜렷함과 안전한 사용을 위해 대부분의 암시적인 변환이 막혀 있고, 일반적인 대입 및 복사가 막혀 있으므로 unique_ptr 끼리의 대입에는 std::move 함수를 명시적으로 사용해야 하는 등 코딩량이 다소 늘어난다는 불편함이 있으나, 안전한 코딩을 위해서라면 이 정도는 충분히 감수할 만 하다.


 unique_ptr의 경우 개인 프로젝트인 C--에서도 상당히 유용하게 쓰고 있는데, 이를 잘만 쓰면 아무런 오버헤드 없이 유효 범위가 확실한 객체에 대해 delete를 전부 제거할 수 있으며, 나머지도 shared_ptr을 이용하여 제거할 수 있다. 유일하게 예외가 있다면 union을 쓰는 경우인데 (union의 멤버는 생성자를 가질 수 없다) 이 경우는 사용자가 따로 래퍼 클래스를 만들어야 한다.

 개인적으로는 boost의 intrusive_ptr[각주:5]의 인터페이스를 좀 더 다듬어서 오버헤드가 적은 shared_ptr의 모양새로 표준에 도입해보는 것도 좋지 않을까 생각하는데, 표준화 위원회는 이 쪽에 관심이 없거나 이 정도면 충분하다고 생각하는 것 같다. 내가 만들어 볼까 생각도 해봤으나 모든 코너 케이스를 고려하면서 안전한 스마트 포인터 클래스를 만드는 것은 그리 만만한 일이 아닐 것 같다.

  1. Resource aquisition is initlaization, 자원의 획득은 객체의 생성과, 해제는 객체의 소멸과 일치시키라는 의미로, 컴파일러가 자동으로 관리해주는 객체의 생명 주기와 자원의 생명 주기를 일치시켜 컴파일러로 하여금 자원을 자동적으로 관리하도록 하는 정책을 의미한다. [본문으로]
  2. 여담이지만 C++에서 delete란 만악의 근원으로, 가능하면 컴파일러가 알아서 삽입하도록 코드를 짜는게 좋다. C++에서는 그 방법으로 제안되는 것이 스마트 포인터이다. [본문으로]
  3. '관련 [본문으로]
  4. R-value reference가 도입된 목적이 어차피 곧 무효화될 임시 객체에 대해서는 값을 복사하지 말고 이동시켜서 복사에 따른 오버헤드를 줄이자는 것이기 때문이다. [본문으로]
  5. shared_ptr과 비슷하게 레퍼런스 카운트를 하지만 이 녀석은 개체 자신이 레퍼런스 카운트 값을 유지해야 한다는 제약이 있다. 물론 퍼포먼스 측면에서는 그만큼 이득이 있다. [본문으로]

'개발' 카테고리의 다른 글

Visual Studio는 C/C++을 싫어해 (?)  (3) 2011.02.27
Bitwise Switch  (2) 2011.02.19
std::unique_ptr  (0) 2010.04.26
RVO와 NRVO  (0) 2010.04.26
volatile과 메모리 배리어  (2) 2009.11.11
C/C++의 몇 가지 키워드들  (1) 2009.11.07
Trackback 1 : Comment 0

RVO와 NRVO

개발 2010.04.26 21:08

 컴파일러가 컴파일 시간에 수행하는 최적화는 참으로 다양하다. 의미 없는 코드를 삭제하는 Dead code elimination 같은 기본적인 테크닉부터 함수 내부 코드만이 아니라 함수 사이의 관계까지 최적화를 하는 Interprocedural optimization까지 많은 종류의 최적화가 수행된다. 특히나 템플릿을 적극 활용하는 스타일의 C++ 코딩에서는 많은 변인들이 컴파일 시간에 정적으로 결정되기 때문에 이러한 최적화의 혜택을 많이 받는다. [각주:1] 이로 인해 C++의 템플릿을 강력한 유연성과 높은 효율 두 마리 토끼를 함께 잡을 수 있는 것이다.

 이러한 최적화로 인해 얻어지는 수행 효율의 증대는 크누스 교수님으로 하여금 어설픈 최적화는 모든 악의 근원이다라는 말까지 남기도록 하였다. 그럼에도 불구하고 쪼잔한 우리 프로그래머들은 컴파일러를 믿지 못하고 문장 단위의 최적화를 시도하는 경우가 많은데, 이 중 가장 대표적인 사례를 들라면 함수의 반환값을 복사 대신 참조로 넘기기 위한 다양한 노력이 있다. 스캇 마이어의 Effective C++에서도 함수의 반환값을 참조로 넘기지 말라고 강조를 하지만 복사 생성으로 인해 성능에서 피를 본 적이 있는 사람이라면 이러한 유혹에 빠질 수 밖에 없는 것이다.

 이를테면 스코프에 대한 개념이 잘 잡혀 있지 않은 초보 프로그래머들이 '참조로 넘기는게 효율이 좋데'라는 말을 듣고 최적화를 하려고 한다. 그런데 그렇게 큰 객체가 아니라 힙에다가 매 번 할당하는 것은 비효율적이고 또 수동 할당 해제 등의 번거로움도 싫다. 그래서 힙에 할당을 하지 않고 그냥 스택에 있는 객체를 이용하려 한다 치면 아래와 같은 실수를 저지를 수 있다.

SomeClass& someFunction()
{
    SomeClass ret;
    // Do something...
    return ret;
}

 당연히 안 될 말이다. SomeClass의 인스턴스 ret는 해당 함수가 끝나는 순간 그 유효 기간이 다 한다. 자동으로 소멸자가 호출되어 관련 정보를 깨끗하게 정리해버리는 것이다. 이 상황에서 객체 자체가 아닌, 객체의 참조를 반환한다면 대재앙이 일어날 수 밖에 없다. 그렇다면 이건 어떨까?

class SomeClass
{
    // ...

    SomeObject buffer_;
}
SomeObject& SomeClass::getSomeObject()
{
    // do Something on buffer_
    return buffer_;
}

 이는 큰 문제 없이 돌아가겠지만, 객체 안의 객체를 반환할 경우에나 사용할 수 있다는, 다시 말해 원본 객체 상태의 일부에 한정되는 객체만 반환 가능하다는 한계점을 가지고 있다. SomeClass에 종속적이지 않은 객체를 만들고 싶은 경우라면 사용할 수 없는 코드인 것이다.

 그렇게나 효율을 강조하는 언어인 C++에서 이런 문제 하나 제대로 해결을 못하고 결국 무작정 복사를 해야 한다니 무언가 이상하다. 컴파일러 개발자나 C++ 표준 위원회 등이 이러한 내용에 대해 고민을 안 해봤을리가 없다. 그리고 이 문제에 대해 Return value optimization(RVO)이라는 해법을 내놓는다. RVO란 함수가 특정 값을 반환할 때 객체를 생성하고 복사, 소멸시키는 삼중 오버헤드를 단순히 객체 생성 한 번으로 끝내도록 만드는 최적화이다. 우선 아래의 코드를 보자.

SomeClass someFunction()
{
    // Do something...

    return SomeClass(someArgument);
}

 이렇게 반환문에 객체 생성문을 명시했다는 것은 실제 반환되는 위치에 곧바로 해당 객체를 생성하여도 의미적으로 볼 때 차이가 없을 것이라는 의미이다. 그렇다면 위의 코드는 아래와 같은 코드로 바뀔 수 있다. (정확하게 아래와 같지는 않다. 단지 이렇게 될 것이라는 것을 말할 뿐이다.)

void someFunction(SomeClass& returnTo)
{
    // Do something...

    returnTo.SomeClass::SomeClass(someArgument); // Construct SomeClass on returnTo
}

 이렇게 반환값 최적화가 일어나면 반환값을 굳이 참조로 넘기지 않아도 효율적인 코드가 생성된다. 헌데 위와 같은 최적화만 가지고는 아무래도 반환하는 객체에 대한 표현력이 생성자에 의해 곧바로 만들어 질 수 있는 익명 객체 수준으로만 한정되기 때문에 아쉬운 점이 있다. 이를 보완하는 최적화 테크닉도 있는데 이는 Named return value optimization(NRVO)으로, 이름을 가진 반환값까지 이러한 최적화의 대상으로 만든다.

Object generate()
{
    Object someObject;

    someObject.doSomething();

    return someObject;
}

 이런 코드는 앞서 언급한 RVO만 가지고는 반환값 최적화의 혜택을 받을 수 없는데, NRVO는 이마저 최적화시켜버린다. someObject가 있다고 치면 굳이 someObject를 생성할 필요 없이 반환값을 저장할 변수를 가져와서 그 곳에다가 someObject 관련 작업을 해버리는 것이 포인트이다.

void generate(Object& returnTo)
{
    returnTo.Object::Object();
    returnTo.doSomething();
}

 물론 실제 코드가 이렇게 간단하게 최적화될 수 있다면 고대 컴파일러인 VC 6.0에서부터 구현이 되었을 것이다. 하지만 실제로 사용하는 코드들은 이보다 복잡한 경로를 거쳐 값들을 반환하기 때문에 훨씬 복잡한 알고리즘을 동원하여 최적화를 하는 것으로 보인다. MSDN에 따르면 NRVO는 2005부터 지원을 하고 있다. [각주:2]

 다만 이 최적화의 문제라면 생성자, 소멸자의 호출 횟수가 달라지면서 같은 코드라도 내는 결과가 최적화 여부에 따라 달라질 수 있다는 점인데, 웹에서 본 바에 따르면 이러한 최적화는 C++ 표준에서 이미 허용 가능 한 범위로 정의된 것으로 보인다. 성능이 좋은 컴파일러를 이용하는 경우라면 적어도 값 반환에 따른 복사의 오버헤드를 걱정할 필요는 없다는 이야기이다. 즉, 대부분의 경우 값을 반환하는 루틴을 최적화하는 것은 어설픈 최적화의 범주에 속한다는 의미.

 기계의 관점에서 쪼잔할 정도로 코드를 최적화하는 것[각주:3]이 과거에는 의미가 있었을지 모르겠으나, 요즘 컴파일러들의 최적화 실력을 보면 어설픈 수준의 최적화는 오히려 성능을 낮추고 코드의 유지 보수를 힘들게 만들 가능성이 높다. 이러한 수준의 최적화는 컴파일러에게 맡기고, 사람은 프로그램의 흐름이나 논리를 면밀하게 검토하여 불필요한 연산을 제거하거나 보다 더 효율적인 알고리즘으로 교체하는 등 기계가 할 수 없는 보다 더 높은 수준에서의 최적화를 해야 할 것이다.

  1. 예를 들어, VS의 STL vector 구현을 보면 값 하나 참조하는 데에만 함수가 몇 번씩 호출되어 콜스택이 몇 중으로 쌓이는데 최적화된 목적 코드에서는 이 코드들이 전부 인라인되고 상수 브랜칭들도 다 삭제되어 해당 객체의 포인터를 곧바로 가리키는 코드로 바뀐다. [본문으로]
  2. 해당 사이트에 NRVO가 적용 안 되는 경우 등이 자세히 설명되고 있으므로 한번 읽어 볼 만 하다. [본문으로]
  3. 이를테면 배열 대신 포인터를 쓴다거나 [본문으로]

'개발' 카테고리의 다른 글

Visual Studio는 C/C++을 싫어해 (?)  (3) 2011.02.27
Bitwise Switch  (2) 2011.02.19
std::unique_ptr  (0) 2010.04.26
RVO와 NRVO  (0) 2010.04.26
volatile과 메모리 배리어  (2) 2009.11.11
C/C++의 몇 가지 키워드들  (1) 2009.11.07
tags : c++, NRVO, RVO, 개발, 최적화
Trackback 0 : Comment 0

C/C++의 몇 가지 키워드들

개발 2009.11.07 23:47

C++에서는 의외로 사람들이 잘 모르는 키워드가 많다. 이를테면 autoregister 같이 존재 의미부터가 희미한 키워드부터[각주:1] mutable 같이 잘만 쓰면 유용할 수도 있는 키워드, volatile 같이 알려지긴 했지만 사람들이 잘못 이해하고 있는 경우가 많은 키워드, export 같이 컴파일러들에게 외면 당한 키워드 등등 알아보면 C++의 세계는 크고 아름답다무궁무진하다. 그래서 세상에서 제일 익히기 어려운 프로그래밍 언어 타이틀을 땄다.

 

 C/C++에서는 변수를 선언할 때 보통 자료형 이름 앞에 해당 변수의 유효 기간과 가시 영역에 영향을 주는 storage class specifier와 변수의 상수성, 일시성을 지정하는 cv-qualifier, 이 두 분류의 속성들이 붙을 수 있다. 그 외에도 class, struct, enum 등의 키워드를 이용, 즉석에서 자료형을 정의하고 사용하는 것도 가능하지만 자료형 선언과 변수 선언은 서로 분리시키는 것이 보통이므로 변수가 가질 수 있는 속성은 실질적으로 위 두 가지가 전부라고 볼 수 있다.

  

 현재 C/C++ storage class specifier에는 auto, register, static, extern 네 종류가 있고, 멤버 변수에 한해서 mutable이 있다. 대부분의 프로그래머들은 static과 extern 키워드가 무슨 역할을 하는지 잘 알고 있으나 auto와 register는 사실상 사장된 키워드들이라 모르는 경우가 많다.

 우선 auto 키워드는 해당 변수의 가시 영역을 변수가 초기화되는 지점의 scope로 한정시키는 역할을 한다. 즉, 지역 변수를 선언하는데 사용되는 키워드이다. 그러나 C++ 컴파일러는 storage class specifier가 지정되지 않은 모든 변수에는 암시적으로 auto 키워드를 붙여 지역 변수로 분류하기 때문에 이를 명시적으로 사용할 이유는 전혀 없다. 그렇기 때문에 C++0x에서는 가능한 경우에 한해 컴파일러가 타입을 자동으로 유추하는데에 사용하는 키워드로 그 목적이 바뀌었다.

 register 키워드는 해당 변수가 굳이 메모리에 기록될 필요가 없을 때 속도 향상을 위해 가급적 레지스터에만 쓰도록 권유하는 키워드이다. 그러나 대부분의 컴파일러들은 충분히 똑똑하기 때문에 이러한 키워드를 쓰지 않아도 레지스터를 최대한 활용하도록 알아서 최적화를 해준다. 그런 이유로 register는 사실상 거의 쓰이지 않는 키워드이고, 상당수의 컴파일러에서는 이 키워드 자체를 그냥 무시한다.

 static 키워드는 익히 알려진 대로 정적 변수를 선언하는데 쓰이나 예외적인 용법이 있다. 전역 변수에 static을 붙일 경우 해당 변수는 속한 번역 단위[각주:2] 밖으로 변수가 노출되지 않도록 보장한다. 허나 C++의 익명 네임스페이스 역시 똑같은 기능을 제공하므로 C++을 사용한다면 이를 굳이 사용할 필요는 없을 것이다.

 mutable 키워드는 상수 객체에서도 변경할 수 있는 멤버 변수를 지정하는데 사용되는 키워드이다. 이 속성이 지정된 변수는 해당 객체가 상수 객체이거나 상수 멤버 함수에서도 수정이 가능해진다. 이 키워드는 대개 객체의 실제 상태와는 직접적인 연관이 없는 변수에 사용한다. (그다지 좋은 예는 아니라 생각하지만) 이를테면 그래프 객체를 만든다 할 때 현재 객체가 가리키고 있는 노드를 mutable 속성을 지닌 내부 변수로 지정한면 iterator가 따로 없더라도 상수 멤버 함수들을 통해 상수 그래프 객체의 순회를 쉽게 구현할 수 있게 된다.

  

 cv-qualifier에는 constvolatile이 있는데, const는 익히 알려진대로 변수에 상수성을 추가하는 키워드이다. 이는 아주 널리 쓰이고 있으므로 별다른 추가적인 설명은 필요 없을 것이다. 그러나 volatile은 많은 사람들이 그 기능에 대해 오해를 하고 있다.

 큰 오해 중 하나가 "volatile은 해당 객체의 최적화를 막는 키워드"라는 것이다. 결과적으로 본다면 맞는 말이지만, 이는 키워드의 본래 목적을 왜곡할 수 있다. 기본적으로 volatile은 프로그램 문맥 외의 요인으로 인해 해당 객체가 비동기적으로 변경될 가능성이 있음을 컴파일러에게 알려주는 키워드이다. 따라서 컴파일러는 이를 참조하여 해당 객체에 대한 접근을 할 때 매 번 레지스터가 아니라 메모리에서 읽고 쓰도록 바이너리를 작성하며, 또한 병렬성 극대화를 위해 명령들의 수행 순서를 바꾼다거나 하는 공격적인 최적화를 하지 않는다.[각주:3] 그러나 프로그램의 흐름을 바꾸지 않는 최적화까지 막는 것은 아니다. 이를테면

volatile int a = 1;
a = a * 4; // Equivalent to a = a << 2;

 이러한 코드가 있을 때 곱하기보다는 쉬프트 연산이 훨씬 저렴하므로 가능한 경우 곱하기를 쉬프트 연산으로 최적화하는데, 이렇게 프로그램의 흐름을 바꾸지 않는 정도에 한해서는 최적화가 이루어질 수도 있다. 물론 이는 컴파일러 의존적이므로 반드시 이렇다고 단언할 수는 없다.

 또 한 가지의 오해 중 하나는 "멀티 쓰레드 프로그래밍에서 동기화 용도로 사용될 수 있다는 것"이다. 물론 바쁜 대기(busy-waiting) 등에서 CPU 레지스터와 실제 메모리 사이에서 생긴 괴리로 인한 문제 정도라면 volatile을 사용하여 해결할 수도 있으나 이 역시 CPU의 명령어 비순차 실행으로 인한 오류 가능성을 고려해보면 좋은 선택은 아니다. 게다가 data race 등의 문제를 volatile로 해결할 수 있는 방법은 없으며, 이는 atomic operation이나 동기화 객체를 사용하여 해결하는 수 밖에 없다. 예외는 있으나[각주:4] 대부분의 경우 멀티 쓰레드 프로그래밍과 volatile은 아무 상관 없다고 생각하는 것이 속 편하다.

 그 외에 캐시를 사용하지 않게 만든다거나 하는 등의 오해도 있지만, 이는 컴퓨터 구조에 대한 기본적인 지식만 있어도 풀릴 오해이다. 캐시를 사용하고 말고는 프로그램 레벨에서 결정되는 것이 아니라 하드웨어 레벨에서 결정되는 문제이며, 응용 프로그램 수준에서 이를 바꾸려면 특별한 인스트럭션을 써야 하지만 이는 volatile 키워드의 목적을 달성하는데 있어서는 아무런 의미도 없는 일이기 때문이다.

  

 export 키워드는 템플릿을 사용했을 때 클래스 선언과 구현을 분리할 수 있도록 도와주는 키워드이다. 이 키워드를 쓴 템플릿 함수는 다른 번역 단위들에 노출이 되어 다른 번역 단위에서도 사용할 수 있게 된다... 는 것이 당시 표준에 export 키워드를 넣은 목적이었다.

 그러나 안타깝게도 이는 현재 C++의 컴파일 방식에 정면으로 배치되기 때문에 컴파일러 입장에서는 구현하기가 무척 어렵다. C++은 각각의 번역 단위를 따로 목적 코드로 컴파일한 뒤 목적 코드끼리 서로 링크하여 최종적인 실행 파일을 생성해낸다. 그런데 템플릿 함수나 클래스는 구체화를 하기 전까지는 목적 코드를 생성할 수 없고, 구체화는 링크 단계가 아니라 컴파일 초기 단계에서 이루어진다. 다시 말해 링크 단계가 아니라 컴파일 단계에서 모든 번역 단위에게 템플릿의 정의를 알려야 하는데 이는 무척 비효율적일 뿐만 아니라 기존 컴파일러의 구조와도 정면으로 배치되기 때문에 메이저 컴파일러들은 전부 export의 구현을 포기했다. 대부분의 컴파일러가 지원하지 않는 기능이기에 사실상 용도 폐기된 셈이다.

  

 이외에도 C++에는 다양한 키워드가 있는데, 이 정도가 사람들이 잘 모르거나 잘못 알고 있는 키워드가 아닌가 싶다. 나 역시 얼마 전까지는 이 중 상당수를 잘못 알고 있었다. 이러한 키워드가 많다는 것은 C++이 무척 어려운 언어라는 것을 반증하는 것이 아닌가 싶은데, 이도 모자라 내년에는 C++ 확장팩 C++0x가 발매될 예정이라고 한다. 과연 따라갈 수 있을까?

 

  1. 이 중 auto는 C++의 다음 표준인 C++0x에서 다른 용도로 사용되는 것으로 결정되었다. 참고로 x는 16진수 A임이 유력하다. [본문으로]
  2. 번역 단위란 #include, #ifdef 등 전처리 과정이 끝난 cpp 파일 하나를 의미한다. [본문으로]
  3. 물론 이는 하드웨어 레벨에서 이루어지는 비순차 실행까지 막지는 못한다. [본문으로]
  4. Java나 C#, 혹은 VC++과 같이 volatile 키워드를 사용하면 메모리 배리어가 보장되는 메모리 모델에서는 부분적으로 사용 가능하다. 그런데 VC++은 메모리 배리어가 보장되는게 맞는지 좀 모호하다. [본문으로]

'개발' 카테고리의 다른 글

Visual Studio는 C/C++을 싫어해 (?)  (3) 2011.02.27
Bitwise Switch  (2) 2011.02.19
std::unique_ptr  (0) 2010.04.26
RVO와 NRVO  (0) 2010.04.26
volatile과 메모리 배리어  (2) 2009.11.11
C/C++의 몇 가지 키워드들  (1) 2009.11.07
tags : c++, 코딩, 키워드
Trackback 0 : Comment 1

티스토리 툴바