숫자 인쇄하기

아들 녀석에게 프로그래밍을 가르쳐준답시고 “헬로! 컴퓨터프로그래밍” 이라는 책을 들여다 본적이 있습니다. 저자가 어린 아들에게 파이썬 프로그래밍을 설명한다는 형식을 취하고 있는 꽤 재미있는 책입니다. 책의 두 저자 중 한 명은 아들인데 초등학생이라고 합니다. 전체적으로 초등학생 수준에 맞추어져 있을 거라고 기대할 수 있지만, 그 보다는 살짝살짝 높은 기준을 넘나드는 부분들이 있습니다.

이 책의 62쪽을 보면 38.8 이라는 숫자에 관한 내용이 나옵니다.

>>> c = 38.8
>>> c
38.799999999999997
>>> print c
38.8

부동소수점과 반올림오차를 간단하게 다루고 있습니다. 따라 붙는 설명에는 약간 부정확한 부분도 있습니다.

변수 c 에 저장되어 있는 숫자와 38.8 이라는 숫자 사이에는, 저자가 주장하는 0.000000000000003 만큼이 아니라 정확히 0.00000000000000284217094304040074348449707 만큼의 차이가 납니다. 이 값을 어떻게 구할 수 있는지는 뒤에서 살펴보겠습니다.

여기에 더해 저자는 세 번째 줄과 다섯 번째 줄의 차이를 이렇게 설명하고 있습니다.

인터랙티브 모드에서 c를 치면 파이썬은 소수점 모든 자리의 원래 값을 그대로 보여줍니다. (중간생략) print 명령어는 조금 더 똑똑해서 반올림해야 하는 것을 알고는 38.8을 보여줍니다.

조금 뒤에서 살펴보겠지만 이 설명에는 오해의 소지가 있습니다.

마지막으로, 파이썬 2.7에서 실행하면 앞의 예시와는 다른 결과가 나옵니다.

>>> c = 38.8
>>> c
38.8
>>> print c
38.8

책에 나오는 예시는 파이썬 버전 2.6 까지만 성립합니다.(아마 파이썬 3.0도 마찬가지일 겁니다.) 파이썬 2.7 에서는 부동소수점과 관련한 변화가 있었습니다. 저자의 설명을 적용한다면 인터랙티브 모드가 좀 더 똑똑해진 거겠지요. 재미있게도 이 예상은 사실입니다.

repr()

인터랙티브 모드에서는 최근에 실행된 명령의 결과가 None 이 아닐 경우 repr() 값이 인쇄됩니다. 그러니까 38.799999999999997 은 repr(3.8) 의 결과라는 뜻이 됩니다. 파이썬 2.7에서는 float 형의 repr() 구현이 달라졌습니다. 파이썬 2.7 alpha 1 의 Change Log 를 보면 이런 내용이 나옵니다.

  • Issue #7117: repr(x) for a float x returns a result based on the shortest decimal string that’s guaranteed to round back to x under correct rounding (with round-half-to-even rounding mode). Previously it gave a string based on rounding x to 17 decimal digits. repr(x) for a complex number behaves similarly. On platforms where the correctly-rounded strtod and dtoa code is not supported (see below), repr is unchanged.

예전에는 repr(x) 이 17자리로 반올림했는데, 이제는 ‘correct rounding’ 으로 원래의 값으로 돌아올 수 있는 가장 짧은 문자열로 변환한다는 뜻입니다. ‘38.799999999999997’ 는 총 17개의 숫자로 구성되어 있습니다. 18번째 숫자에서 반올림해서 17개의 숫자로 표현하는 것이 예전(2.6) 구현이었던 반면, 새(2.7) 구현에서는 float('38.799999999999997')float('38.8') 이 같다는 것을 알아채고 ‘38.8’ 을 사용한다는 뜻입니다.

>>> float('38.799999999999997') == float('38.8')
True
>>> 38.799999999999997 == 38.8
True

달리 표현해 본다면 부동소수점을 내부에 표현하는 방식 때문에(파이썬의 경우 대부분 IEEE 754 binary64 형식) ‘38.799999999999997’ 라고 길게 늘여 쓴 숫자와 ‘38.8’ 라고 짧게 쓴 숫자 모두 같은 값으로 저장되어 구별할 수 없다는 뜻입니다. 그러니 가역적인 변환(repr() <-> float())이 가능한 최소한의 문자열로 표현하는 것이, 경제적일 뿐만 아니라 부동소수점의 정밀도를 높이지 않는 이상 잃을 것이 없다는 뜻입니다.

그러면 저자가 똑똑하다고 표현했던 print 명령은 어떻게 된 걸까요? 이 경우는 단순히 반올림하는 자릿수가 더 짧은 것뿐입니다. 10진법으로 인쇄된 값과 변수에 저장된 부동소수점간의 차이는 17번째 숫자에서부터 나타나는데 그보다 짧은 자리에서 반올림해버리니 정밀도를 잃어버리게 됩니다. 이는 원래 인쇄된 숫자가 짧은 경우 군더더기가 없어지는 효과가 있지만, 실제로 자릿수가 필요한 경우도 잘라버리게 됩니다.

>>> 0.1234567890123456789
0.12345678901234568
>>> print 0.1234567890123456789
0.123456789012

용도가 있겠지만 똑똑한 것과는 거리가 멉니다. 오히려 똑똑한 녀석은 2.7 버전의 repr() 이라고 보아야 하겠지요.

숫자를 인쇄하는 가장 정확하고 경제적인 방법은?

새로 구현된 repr() 은 숫자를 인쇄하는 가장 정확하고 경제적인 방법을 제공합니다. 정확하다는 것은 정밀도를 최대한 보존하는 것이고, 경제적이라는 것은 필요한 최소한의 문자만 사용한다는 뜻입니다. JSON 이나 XML 등과 같이 텍스트 표현을 사용하는 형식으로 데이터를 저장할 때 꼭 필요한 특성입니다. 여러분이 JSON 형식을 사용하는 RESTful API 로 다른 통계 계산에 사용되어야 하는 수치 데이터를 전달해야 한다면 str() 대신에 repr() 을, 포맷 스트링에서는 %s,%f,%g 대신에 %r 을 사용해야 한다는 뜻입니다. 이런 인식은 파이썬의 코어에도 광범위한 영향을 줬는데 그 흔적은 Change Log 에 이렇게 나타납니다.

  • Issue #7117: On almost all platforms: float-to-string and string-to-float conversions within Python are now correctly rounded. Places these conversions occur include: str for floats and complex numbers; the float and complex constructors; old-style and new-style numeric formatting; serialization and deserialization of floats and complex numbers using marshal, pickle and json; parsing of float and imaginary literals in Python code; Decimal-to-float conversion.

decimal.Decimal

부동소수점은 일정한 길이를 넘지 않는 비순환 유리수만을 표현할 수 있습니다. 이 때문에 메모리에 일단 저장된 float 형은 2진법으로 표현된 비순환 유리수이고, 이를 십진수로 변환할 때는 유한한 길이로 정확한 값을 표현할 수 있습니다. 이런 변환을 제공하는 클래스가 decimal 모듈의 Decimal 입니다.

>>> import decimal
>>> decimal.Decimal(38.8)
Decimal('3.79999999999999982236431605997495353221893310546875')
>>> decimal.Decimal('38.8') - decimal.Decimal(38.8)
Decimal('2.842170943040400743484497070E-15')

컨스트럭터에서 float 를 받아들이는 것은 2.7에서 새로 들어간 기능입니다.