Python 的 round() 與 decimal 模組
Tags: python
Published: 2022-02-08 06:03:43

[`round()`](https://docs.python.org/3/library/functions.html#round) 是 Python 內建的[捨入法](https://en.wikipedia.org/wiki/Rounding)函式, 它的規格如下：

```python
round(數值, 位數)
```

初次使用可能會不大習慣, 因為它採用的並不是四捨五入, 而是依照指定的位數, 往前或是往後取最接近的數, 例如：

```python
>>> round(2.2251, 2)
2.23
```

因為取到 2 位小數, 所以最接近 2.2251 的數值是 2.23。

## `round()` 的擇『偶』規則

你可能會想說這不就是四捨五入嗎？請看下一個範例：

```python
>>> round(4.5, 0)
4.0
```

取小數 0 位, 等於取到個位數, 只保留整數, 如果是四捨五入, 就應該進位成 5.0 才對, 請再看下個範例：

```python
>>> round(3.5, 0)
4.0
```

剛剛 5 不進位, 但是這個 5 卻進位？前面有提過 `round()` 是取最接近的數值, 可是當往前與往後的數值等距時, 會取**偶數**, 因此剛剛的範例都是取偶數的 4, 而不會取奇數的 5 或是 3。

這種捨入法稱為『[**偶數捨入法 (round half to even)**](https://en.wikipedia.org/wiki/Rounding#Round_half_to_even)』或是『**銀行家捨入法 (banker's round)**』, 也是 [IEEE 754](https://en.wikipedia.org/wiki/IEEE_754#Roundings_to_nearest) 標準裡的捨入法方式。它的用意是要解決多筆數字以四捨五入後加總平均會偏高的問題, 讓遇到中間值時捨位與進位的機率相等, 而非一律進位。這也是[科學研究取有效數字時的偏好方式](https://en.wikipedia.org/wiki/Significant_figures#Rounding_to_significant_figures)。

## 指定負的位數

`round()` 的第 2 個參數可以是負數, 0 是個位數、-1 是十位數、-2 是百位數、...以此類推, 例如：

```python
>>> round(4351.3455, 0)
4351.0
>>> round(4351.3455, -1)
4350.0
>>> round(4351.3455, -2)
4400.0
>>> round(4351.3455, -3)
4000.0
```

## `round()` 是看整個數值而非只看下一位數

有些教材會把 `round()` 解釋為『四捨六入, 遇到 5 看前一位是奇數才進位』, 不過這很可能導致誤解, 請看一開始我們舉的例子：

```python
>>> round(2.2251, 2)
2.23
```

取到小數點第 2 位, 而小數第 3 位是 5, 前一位是 2, 如果只看小數第 3 位來決定是否進位, 依據『**擇偶**』規則, 前一位數是偶數, 應該不進位, 但實際的結果卻是進位成 2.23。這是因為 `round()` 並不是看小數第 3 位, 而是以 2.2251 來看, 2.23 要比 2.22 接近 2.2251, 因此結果為 2.23。

## 浮點數潛在的誤差

浮點數因為是以 2 進位的有限位元數來表示, 許多數值儲存的是近似值, 如果只顯示幾位小數分辨不出來, 但若是顯示多位小數, 就可以看出差別, 例如：

```python
>>> "{:1.5f}".format(2.2250)
'2.22500'
>>> "{:1.20f}".format(2.2250)
'2.22500000000000008882'
```

你可以看到 2.2250 實際上儲存的值比 2.2250 大一點點, 如果用 `round()` 取到小數第 2 位就會發現怪怪的：

```python
>>> round(2.2250, 2)
2.23
```

2.2250 明明與 2.23 及 2.22 等距, 依據『擇偶』規則, 應該選偶數的 2.22, 但結果是 2.23, 這就是因為實際的值比 2.2250 大, 離 2.23 比較近的關係。再來看一個例子：

```python
>>> round(2.2350, 2)
2.23
```

依據『擇偶』規則, 應該選偶數的 2.24, 但結果卻是 2.23, 一樣把多位小數印出來見真章：

```python
>>> "{:1.20f}".format(2.2350)
'2.23499999999999987566'
```

原來實際儲存的值比 2.2350 小一點點, 所以 2.23 比較接近, 而不是 2.24。

瞭解這一點, 對於某些神奇的運算結果就不會驚訝了, 如果對於浮點數有興趣, 可以參考 Python 官網上的[這一篇文章](https://docs.python.org/3/tutorial/floatingpoint.html#tut-fp-issues)。

## 使用 decimal 模組的 Decimal 類別

如果對於浮點數的誤差很介意, 那麼可以試試看使用 [`decimal` 模組](https://docs.python.org/3/library/decimal.html#)內的 [`Decimal 類別`](https://docs.python.org/3/library/decimal.html#decimal-objects), 這是專以 10 進位觀點設計的數值類別, 先來看一個範例：

```python
>>> import decimal
>>> round(decimal.Decimal('2.2350'), 2)
Decimal('2.24')
```

這裡使用字串建立 `Decimal` 物件, 套用 `round()` 會看到 2.2350 依照擇偶規則進位成 2.24, 而不是之前範例的 2.23。

請注意 `Decimal` 物件也可以透過浮點數建立, 但是會原封不動保留誤差, 因此底下的範例仍會變成 2.23：

```python
>>> round(decimal.Decimal(2.2350), 2)
Decimal('2.23')
```

### 變更捨入法

`decimal` 模組提供有 [`Context` 類別](https://docs.python.org/3/library/decimal.html#context-objects)的物件, 可透過 [`getcontext()` 函式](https://docs.python.org/3/library/decimal.html#decimal.getcontext)取得, 控制捨進位方式等等, 例如：

```python
>>> c = decimal.getcontext()
>>> c.rounding = decimal.ROUND_UP
```

這樣會將捨進位方式改成無條件進位：

```python
>>> round(decimal.Decimal(2.2350), 2)
Decimal('2.24')
>>> round(decimal.Decimal(2.2310), 2)
Decimal('2.24')
```

現在即使是 2.2310 也會進位成 2.24。

有關 `Decimal` 的各種捨進位方式, 可參考[官方文件](https://docs.python.org/3/library/decimal.html#rounding-modes)。

## 客製類別的捨入法

實際上 `round()` 倚賴的是個別類別的 `__round__()` 方法來協助捨進位, 因此您也可以為自訂類別設計專屬的捨進位方式, 例如：

```python
>>> import math
>>> class MyFloat:
...     def __init__(self, f):
...             self.f = f
...     def __round__(self, d=0):
...             f = self.f * pow(10, d)
...             f = float(math.ceil(f))
...             return f * pow(10, -d)
...
>>>
```

在這個類別中, 就任性的採用無條件進位, 我們可以測試看看：

```python
>>> round(MyFloat(2.13), 1)
2.2
>>> round(MyFloat(2.13), 0)
3.0
```

## 小結

本文說明 `round()` 內建函式的用法, 主要希望能提醒大家, 即使是這樣看似簡單的功能, 如果不注意細節, 都有可能會讓程式產生意料之外的結果, 務必要謹慎小心。