Python 2 / 3 Rounding 比較

Falldog Hsieh
GoFreight HQ
Published in
11 min readAug 27, 2019

--

由於 Python2.7 的末日愈來愈近了,因此敝公司最近升級 Python 至 3.6.8 (有一些考量因素,所以沒有升至3.7.x)。用熟悉的 Python 2.7 開發也有一段時間了,當初顧慮的 Performance, 以及 module 支援度都有顯著的改善。現在絕對是個很好的升級時機。

敝公司開發的系統,有一些是跟帳務系統相關的功能,所以金錢相關的 Database 欄位都是用 Decimal 處理,在從 Python2.7 升到 Python3 ,發現 rounding 的機制有些改變,在這邊分享一下我的小小研究心得。(測試以 3.6.8 為主,但是 3.7 以上的版本應該也適用)

前言

Python 處理浮點數有兩個型別可以處理,(1) float (2) Decimal ,float 由於會有精準度的問題,所以通常處理金錢相關的數字,都會交給 Decimal 來處理。在深入研究後,才發覺 Python 2 / 3 在處理 round(float) or round(Decimal) 的方式是不一樣的,原來我們之前在 Python 2 處理 Decimal rounding 時,可能都有一些潛在性的問題沒有注意到。

Builtin round()

先來看一下 Python 2 / 3 的 document 對 round() 的說明

Python 2.7- round(x[, n])
x rounded to n digits, rounding ties away from zero. If n is omitted, it defaults to 0.

Python 3.6 - round(x[, n])
x rounded to n digits, rounding half to even. If n is omitted, it defaults to 0.

round() 在 2.7 的做法應該是接近「四捨五入」,但是 3.6 的做法卻變成 「四捨六入五取偶」(俗稱銀行家捨入法)

再來看 Decimal 的 document 說明

Python 2.7- decimal.DefaultContext
預設 rounding 為 ROUND_HALF_EVEN

Python 3.6 - decimal.DefaultContext
預設 rounding 為 ROUND_HALF_EVEN

Decimal 提供了自己的 quantize() function ,可以傳入不一樣的 rounding 方式,預設為四捨六入五取偶的 ROUND_HALF_EVEN ,而不是四捨五入的 ROUND_HALF_UP ,所以在 Python 2 裡, round()Decimal.quantize() 做 rounding 的行為是不太一致的。

以下有個簡單的測試程式看看 2 跟 3 有什麼不一樣的結果

Python 2.7

round 結果就是預期中的「四捨五入」,注意到的是 round(Decimal(x.x)) 回傳的結果不是 Decimal,而是 float。(由於浮點數精準度問題,如果用其他的小數在測試,可能不如預期。Ex: round(1.15) == 1.0 )

但是 round(Decimal) 的結果,卻也是四捨五入,與 round(float) 一樣!? (後面再說明為何不同)

>>> from decimal import getcontext, Decimal
>>> getcontext()
Context(prec=28, rounding=ROUND_HALF_EVEN, Emin=-999999999, Emax=999999999, capitals=1, flags=[], traps=[DivisionByZero, InvalidOperation, Overflow])
>>> round(1.5)
2.0
>>> round(2.5)
3.0
>>> round(3.5)
4.0
>>> round(4.5)
5.0
>>> round(Decimal('1.5'))
2.0
>>> round(Decimal('2.5'))
3.0
>>> round(Decimal('3.5'))
4.0
>>> round(Decimal('4.5'))
5.0

Python 3.6

結果符合「四捨六入五取偶」。注意到的是 round(Decimal(x.x), n) 回傳值,跟 Python 2 不一樣,會依第二個參數來決定。有帶位數的話回傳的不是浮點數,而是 Decimal。沒帶位數回傳的會是整數(不會被 Context setting 給影響,總是用 ROUND_HALF_EVEN 處理)。

>>> from decimal import getcontext, Decimal
>>> getcontext()
Context(prec=28, rounding=ROUND_HALF_EVEN, Emin=-999999, Emax=999999, capitals=1, clamp=0, flags=[], traps=[InvalidOperation, DivisionByZero, Overflow])
>>> round(1.5, 0)
2.0
>>> round(2.5, 0)
2.0
>>> round(3.5, 0)
4.0
>>> round(4.5, 0)
4.0
>>> round(Decimal('1.5'), 0)
Decimal('2')
>>> round(Decimal('2.5'), 0)
Decimal('2')
>>> round(Decimal('3.5'), 0)
Decimal('4')
>>> round(Decimal('4.5'), 0)
Decimal('4')
>>> round(Decimal('1.5'))
2
>>> round(Decimal('2.5'))
2
>>> round(Decimal('3.5'))
4
>>> round(Decimal('4.5'))
4

Python2.7 vs Python3.6

上面主要測試分兩個部分

  • round(float)
  • round(Decimal)

可以注意到在 Python 2 / 3 裡,上下兩個部分的結果都是一樣的,都跟 round 預設的行為一樣,不過又有點些微的不同。

先針對 Python 2 / 3 的round(Decimal(x.x), n) 回傳的內容不一樣來探討一樣。覺得有點好奇,所以就找了一下 Python source code,才知道 Python 2 / 3 的做法大不同。

  • Python 2 round() 會把參數轉型成 C 的 double 後,再進行四捨五入。
  • Python 3 不會做強制轉型的動作,而是轉 call 各個 PyObject 的 __round__() ,讓各自型別自行處理 round 的行為。

所以再回來看 Python 2 / 3 的行為,一切就合理了。

Python 2 因為會強制轉型成 double 再做 round 。所以,round(float)round(Decimal(x.x)) 的結果一樣,都是四捨五入。

Python 3 因為會呼叫每個 PyObject 自己的 __round__() 所以,會符合各自型別的行為。 round(float) -> 依照 Document 所寫的 rounding half to even。 round(Decimal(x.x)) -> 依照 Decimal DefaultContext 所定義的 ROUND_HALF_EVEN,所以結果一樣,都是四捨六入五取偶

Decimal Context

Decimal 的 DefaultContext 的 rounding 為 ROUND_HALF_EVEN ,想要改變這個預設 Context 是可以做的,一個作法是改變 Global context , 第二個作法則是用 Context Manager 產生 個 local context。

global context

>>> from decimal import Decimal, getcontext, ROUND_CEILING>>> round(Decimal('1.25'), 1)
1.2
>>> c = getcontext()
>>> c.rounding = ROUND_CEILING
>>> round(Decimal('1.25'), 1)
1.3

local context

>>> from decimal import Decimal, localcontext, ROUND_CEILING, ROUND_FLOOR
>>> with localcontext() as ctx:
... ctx.rounding = ROUND_CEILING
... x = Decimal('1.25')
... print(x.quantize(Decimal('0.1')))
... print(round(x, 1))
... print('{:.1f}'.format(x))
...
1.3
1.3
1.3
>>>
>>> with localcontext() as ctx:
... ctx.rounding = ROUND_FLOOR
... x = Decimal('1.25')
... print(x.quantize(Decimal('0.1')))
... print(round(x, 1))
... print('{:.1f}'.format(x))
...
1.2
1.2
1.2

Builtin round() - CPython source code

Python 2.7 - bltinmodule.c

看裡面的邏輯,會先在 PyArg_ParseTupleAndKeywords() 強制轉型成 double 存入 x ,最後再統一由 _Py_double_round() 處理四捨五入。

static PyObject *
builtin_round(PyObject *self, PyObject *args, PyObject *kwds)
{
double x;
PyObject *o_ndigits = NULL;
Py_ssize_t ndigits;
static char *kwlist[] = {"number", "ndigits", 0};
// number (Py: float or Decimal) ----> x (C: double)
if (!PyArg_ParseTupleAndKeywords(args, kwds, "d|O:round",
kwlist, &x, &o_ndigits))
return NULL;
... if (ndigits > NDIGITS_MAX)
/* return x */
return PyFloat_FromDouble(x);
else if (ndigits < NDIGITS_MIN)
/* return 0.0, but with sign of x */
return PyFloat_FromDouble(0.0*x);
else
/* finite x, and ndigits is not unreasonably large */
/* _Py_double_round is defined in floatobject.c */
return _Py_double_round(x, (int)ndigits);
}

floatobject.c

PyObject *
_Py_double_round(double x, int ndigits) {
...
_Py_SET_53BIT_PRECISION_START;
rounded = _Py_dg_strtod(mybuf, NULL);
_Py_SET_53BIT_PRECISION_END;
}

Python 3.6 - bltinmodule.c

看裡面的邏輯,會先找 number__round__ ,找到的話就透過 PyObject_CallFunctionObjArgs() 處理,沒有的話就丟 Exception。

    round = _PyObject_LookupSpecial(number, &PyId___round__);
if (round == NULL) {
if (!PyErr_Occurred())
PyErr_Format(
PyExc_TypeError,
"type %.100s doesn't define __round__ method",
Py_TYPE(number)->tp_name);
return NULL;
}
if (ndigits == NULL || ndigits == Py_None)
result = PyObject_CallFunctionObjArgs(round, NULL);
else
result = PyObject_CallFunctionObjArgs(round, ndigits, NULL);

Decimal __round__() : _pydecimal.py
float __round__() : floatobject.c

Summary

在每次版本升級總是會遇到一些不預期的問題出現,唯有注意這些枝微末節的處理,才能讓版本順利升級上去。這次 Python 的升級帶來的 Decimal 的行為改變,在對於金錢的處理上更是需要特別的小心,沒注意到的話,客戶的財務報表計算就會出現問題。希望這個深入實驗的分享可以讓大家更了解 Python Decimal 的機制。

--

--