Python 寫程式的「底線」:7 種使用技巧

by 好豪
Published: Updated:

你在寫程式命名變數的時候,是 snake_case 還是 camelCase 派別呢?Python 有一項跟其他程式語言不太一樣的特色,底線(_)並不只有 snake_case 這種命名變數的功能,而還有很多特殊的功用。

舉例而言,只要你稍微讀過 Python 程式碼,一定有看過 def __init__(self),這個函式前後各兩個底線可是有特別意義的,多一個、少一個底線都不行!在 Python 裡,這種有趣的底線用法還真不少。

因此,這則筆記我將分享 7 種 Python 裡單底線(_)與雙底線(__)的意義還有實用技巧,幫助你好好看懂底線寫法、成為「有底線」的 Python 工程師。



更好讀的數字常數

你有沒有遇過在程式碼裡寫數字的字面常數(Literals)時,數字大到你無法一眼看出是幾位數的狀況呢?在 Python 3.6 以上的版本(PEP 515),你可以用底線解決這個煩惱!

數字的字面常數(Numeric Literals)加上底線後將不會影響該常數的意義,你可以運用底線提升程式內常數的可讀性。筆者最愛用的就是把底線當成千位符號使用:

 
# 這樣寫一個常數
# 你能一眼看出來它是百萬、千萬、還是億嗎?
>>> num = 30000000.0
>>> num
30000000.0

# 常數加上底線,意思不會變
# 既然不能加上逗點、就把底線當成千位符號使用
# 一看就知道是三千萬、可讀性大提升!
>>> num = 30_000_000.0
>>> num
30000000.0

除了千位符號,工作中用到二進位十六進位的時候,底線也能幫助你為數字常數加上不同的資料表示法(例如 Nibble 或是 Word):

# 二進位 Bit 資料用半位元組(Nibble)表示
>>> bin_data = 0b_1000_1110_0110_1111
>>> bin_data
36463

# 用字組(Word)表示記憶體位置
>>> addr = 0x_ABCD_F90C
>>> addr
2882402572

直譯器的最近運算結果

Python 的直譯器(interpreter)會把你最近一個運算過的數值,儲存在名為 _(單底線)的變數裡。並且,回傳給直譯器的值才會存在 _,如果你把運算後的數值指定給某個變數就不會儲存在 _ 了,聽起來可能有點抽象,請直接看範例:

>>> (1+2+3)*100
600
>>> _
600
>>> 594_0009_527
5940009527
>>> _
5940009527

# 注意,如果數值已指定給某變數,就不會儲存在單底線了
>>> tmp = [i*2 for i in range(5)]
>>> _
5940009527
>>> tmp
[0, 2, 4, 6, 8]
>>> _
[0, 2, 4, 6, 8]

# 我自己覺得打開 Python 當計算機按的時候
# 單底線還不錯用啦 (˚∀˚) 
>>> 350+128
478
>>> _ * 3
1434
>>> _ - 1200
234
>>> _ ** 3
12812904

存放想忽略的數值

_(單底線)本身可以是變數名稱,拿來儲存資料。不過,要是你真的寫 _ = [3, 5, 7],其他人應該會對你的變數命名感到頭痛。_ 當然不適合拿來存放有意義的資料,相反地,如果是非必要使用、可忽略的資料,就適合用 _ 刻意讓閱讀程式碼的人知道這個存下來的資訊不重要。筆者實際上常用的是以下兩個情境:

迴圈

# 迴圈內有用到 i
>>> for i in range(3):
...     print(f"第 {i} 行")
...
第 0 行
第 1 行
第 2 行

# 每個迴圈內容都一樣,表示 i 存了什麼根本不重要!
>>> for i in range(3):
...     print(f"你好 ʕ •ᴥ•ʔ")
...
你好 ʕ •ᴥ•ʔ
你好 ʕ •ᴥ•ʔ
你好 ʕ •ᴥ•ʔ

# 所以乾脆用單底線改寫
# 讓其他程式碼閱讀者知道:「第幾個迴圈」是不重要的資訊
>>> for _ in range(3):
...     print(f"你好 ʕ •ᴥ•ʔ")
...
你好 ʕ •ᴥ•ʔ
你好 ʕ •ᴥ•ʔ
你好 ʕ •ᴥ•ʔ

解包

解包(Unpacking)是把多個元素分別儲存在各個變數的技巧,在某些時候,並不是所有要解包的內容都是必要的資料,那些用不到的資料就別耗腦力想變數命名了,全都丟進 _ 吧。

# 範例:
# tuple 格式的國小資料 
# (姓名, 性別, 入學年份, 國小名稱, 年級)
# 我們可以用解包技巧(Unpacking)把多個元素分別儲存在各個變數
>>> name, gender, year, school, gradelevel = ('豪豪', '男', 2001, '西松國小', 3)
>>> name
'豪豪'
>>> gradelevel
3

# 如果我們操作資料只需要用到「姓名」與「年級」兩項資訊
# 中間的其他資料我們可以用單底線來暫時存放,也就是,刻意地忽略
>>> name, _, _, _, gradelevel = ('曼玉', '女', 2010, '健康國小', 6)
>>> name
'曼玉'
>>> gradelevel
6

# 更酷的是,單底線再加上 Extended Iterable Unpacking (PEP 3132) 技巧
# 程式碼會再更簡短
>>> name, *_, gradelevel = ('朝偉', '男', 1998, '敦化國小', 5)
>>> name
'朝偉'
>>> gradelevel
5

順帶一提筆者好豪喜歡的一項資料科學小技巧,我們有時候會需要隨機抽樣一小部分資料來探索,比起尋找抽樣專用的函式,我會偷懶地直接用超常見的 train_test_split() 函式加上單底線技巧,來進行資料隨機抽樣:

>>> import numpy as np
>>> from sklearn.model_selection import train_test_split
>>> X, y = np.arange(10).reshape((5, 2)), range(5)
>>> X
array([[0, 1],
       [2, 3],
       [4, 5],
       [6, 7],
       [8, 9]])

# 隨機抽樣出三分之二的資料
>>> X_sample, _, y_sample, _ = train_test_split(
...     X, y, test_size=0.33, random_state=42)
...
>>> X_sample
array([[4, 5],
       [0, 1],
       [6, 7]])
>>> y_sample
[2, 0, 3]
# 至於 _ 內究竟存了什麼
# 就不管了 ¯\_(ツ)_/¯

命名:前單底線(_var

在變數或者函式名稱前面加上單底線、變成 _var 的形式,是用於提示其他使用與閱讀你的程式碼的人,告訴大家這項變數或函式是內部使用,請你「最好不要用」。但這項前單底線的命名終究只是慣例(Convention)、只是微弱地提醒,實際上加上前單底線並不影響變數與函式的功能,所以其他人硬是要用這些變數與函式也不會真的受到阻礙。

假設我們有個類別(Class)儲存員工資訊,內部的 _title 變數、還有 _print_employee_info() 函式,是我們不建議其他程式設計師拿來用的部分:

class Employee:
    """Class that contains basic information about an employee."""
    def __init__(self, name: str, job: str, salary: int = 0) -> None:
        self.name = name
        self.job = job
        self.salary = salary
        self._title = f"{self.job} - {self.name}"

    def get_name(self):
        return self.name

    def _print_employee_info(self):
        print(f"月薪 ${self.salary} 的 {self._title}")
        return None
>>> hao = Employee("好豪", "資料科學家", 22_000)

# 一般命名方法的變數可以取用
>>> hao.name
'好豪'

# 前單底線命名的變數代表「內部使用」或「不建議使用」
# 但其實還是可以取得
>>> hao._title
'資料科學家 - 好豪'

# 同樣地,一般命名方法的函式當然可以用
>>> hao.get_name()
'好豪'

# 前單底線命名的函式代表「內部使用」或「不建議使用」
# 但你還是可以呼叫得到 (˚∀˚) 
>>> hao._print_employee_info()
月薪 $22000 的 資料科學家 - 好豪

需要提醒的是,在 import 模組的時候,如果你的寫法是全部內容都 import 的 from my_module import *,前單底線命名的變數跟函式不會被 import!

# 範例模組:my_module.py

def function_a():
    print('一般命名的函式')

def _function_b():
    print('前單底線命名的函式')
>>> from my_module import *
>>> function_a()
一般命名的函式
>>> _function_b()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name '_function_b' is not defined

# 如果你刻意要使用前單底線命名的內容
# 需要明確寫出你要 import 它 
>>> from my_module import _function_b
>>> _function_b()
前單底線命名的函式

補充:當你不只是需要用前單底線「提醒」、還需要強制保護資料不被使用者更動的時候…

命名:後單底線(var_

要是你想用的變數或函式命名,已經在 Python 內建出現過了,你可不能跟 Python 搶名字!以下範例就真的「霸佔」某個 Python 內建功能的命名,看看發生什麼事:

# 相信大家都知道,dict 是 Python 內建類別
>>> dict
<class 'dict'>
>>> dict()
{}
>>> d = dict()
>>> d['a'] = 123
>>> d
{'a': 123}

# 如果你真的把一個「字典」功能的變數用 'dict' 命名
>>> dict = {'apple': '蘋果', 'banana': '香蕉', 'grava': '拔辣'}
>>> dict
{'apple': '蘋果', 'banana': '香蕉', 'grava': '拔辣'}

# 你會發現你不能再正常使用 Python 的 dict()!
# 因為 dict 被你「覆蓋」掉了
>>> d = dict()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'dict' object is not callable

為了避免變數命名與 Python 內建衝突,程式設計慣例是為會與 Python 內建重複的命名後方加上單底線

# 你如果堅持要用 'dict' 來命名
# 通融一下加上底線改成 'dict_'
# 就能跟 Python 和平共處了!
>>> dict_ = {'apple': '蘋果', 'banana': '香蕉', 'grava': '拔辣'}
>>> dict_
{'apple': '蘋果', 'banana': '香蕉', 'grava': '拔辣'}

>>> d = dict()
>>> d
{}

與 Python 內建的命名衝突如果出現在函式的參數,Python 甚至會在你命名的當下就回報錯誤,最常見的就是不能用類別的 class 關鍵字來命名函式參數。

# 函式參數用 'class' 命名會直接出錯
>>> def my_function(class): print(class)
  File "<stdin>", line 1
    def my_function(class): print(class)
                    ^
SyntaxError: invalid syntax
# 真要用的話就加個底線吧
>>> def my_function(class_): print(class_)
>>> my_function(class_="hello world")
hello world

這就是為什麼有些 Python 套件裡的函式會用 class_ 當作參數命名,例如使用 BeautifulSoup 來尋找網頁元素中的某個 class 時,雖然你要找的是 CSS 的 class 屬性,但是在函式中參數必須寫 class_

# BeautifulSoup 的 find_all() 函式節錄範例
>>> soup.find_all("a", class_="sister")
[<a class="sister" href="http://example.com/elsie" id="link1">Elsie</a>,
 <a class="sister" href="http://example.com/lacie" id="link2">Lacie</a>,
 <a class="sister" href="http://example.com/tillie" id="link3">Tillie</a>]

命名:前雙底線(__var

前面跟命名有關的兩種單底線用法 _varvar_,都是命名慣例為主,接下來談到的雙底線 __var__var__ 則會與 Python 的運作邏輯直接相關。

前雙底線(__var)的功能,是避免你所創造類別(Class)內的變數或函式,被子類別所覆寫(Override)。

我們自創一個簡單的類別來看 Python 會對前雙底線命名的變數與函式做什麼:

class MyClass:
    def __init__(self):
        self.bar = 0
        self.foo = 1
        self._foo = 2
        self.foo_ = 3
        self.__foo = 4

前或後單底線跟前面學過的一樣,只是命名慣例,用法沒什麼意外之處。唯有前雙底線的變數,不能用普通的方式取得:

>>> my_class = MyClass()
>>> my_class.bar
0
>>> my_class.foo
1
>>> my_class._foo
2
>>> my_class.foo_
3

>>> my_class.__foo
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'MyClass' object has no attribute '__foo'

>>> dir(my_class)
['_MyClass__foo', '__class__', ... (中間省略) ... , '__weakref__', '_foo', 'bar', 'foo', 'foo_']

dir() 函式觀察該變數內所有屬性後,前雙底線命名的 __foo 根本不存在!再仔細看,還會看到多了一個有趣的 _MyClass__foo,把它取出來會發現,資料內容跟我們設定的 __foo 一樣!

>>> my_class._MyClass__foo
4

Python 直譯器主動修改你的前雙底線變數的這項行為,稱為 名字修飾(Name Mungling),它的作用是保護類別內的變數,不讓它在子類別被覆蓋掉。

以下範例我們接著創建子類別試試看,就能知道名字修飾的好處了:

# 注意:
# 1. 此類別繼承自 MyClass
# 2. 此類別內「沒有」宣告類別變數 bar

class MyChildClass(MyClass):
    def __init__(self):
        super().__init__()
        self.foo = 11111
        self._foo = 22222
        self.foo_ = 33333
        self.__foo = 44444
>>> my_child_class = MyChildClass()
# 從 MyClass 繼承來的
>>> my_child_class.bar  
0

# 以下三個變數都被子類別覆蓋了
>>> my_child_class.foo
11111
>>> my_child_class._foo
22222
>>> my_child_class.foo_
33333

# __foo 依然不能直接取得
>>> my_child_class.__foo
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'MyChildClass' object has no attribute '__foo'

# 需要利用我們剛剛學的名字修飾來取得 __foo
>>> my_child_class._MyChildClass__foo
44444

# 使用正確的名字修飾
# 就可以取得父類別(MyClass)的 __foo 變數資料
# 最重要的是:父類別的這個前雙底線命名變數 *沒有* 被子類別覆蓋!
>>> my_child_class._MyClass__foo
4

透過前雙底線的名字修飾機制,父類別跟子類別的變數即使命名相同,都透過「被 Python 直譯器擅自改名字」的方式通通保留下來了,不會互相覆蓋


上方範例是用前雙底線命名變數,在類別內用前雙底線命名函式,同樣也適用名字修飾。此外,名字修飾是發生在函式宣告的當下,亦即,函式主體(Function Body)內,你可以用還沒名字修飾的前雙底線命名來取用你的變數或函式。以下範例一併介紹上述兩個要點:

class MyHahaClass:
    def __laugh(self):
        print("哈哈 ^_^")

    def call_laugh_method(self):
        return self.__laugh()    # 特別注意函式主體的此處還沒有發生名字修飾
>>> my_haha_class = MyHahaClass()

# 同樣地,函式命名如果是前雙底線,不能直接用該命名呼叫
>>> my_haha_class.__laugh()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'MyHahaClass' object has no attribute '__laugh'

# 用經過名字修飾的函式命名就可以了
>>> my_haha_class._MyHahaClass__laugh()
哈哈 ^_^

# 或是,在函式主體內的另個函式可以用 self.__laugh() 來呼叫它
>>> my_haha_class.call_laugh_method()
哈哈 ^_^

對於名字修飾,筆者好豪在《Python 神乎其技》書中還看到一段特別有趣且令我驚奇的程式碼,能幫助我們更理解名字修飾機制運作,以下我直接引用 書中 的範例:

_MangledGlobal__mangled = 23

class MangledGlobal:
    def test(self):
        return __mangled

仔細觀察,MangledGlobal 根本沒創建任何類別變數(例如 self.__mangled = 123),但 test() 這個函式經過名字修飾後還是能正常執行:

>>> MangledGlobal().test()
23

看懂這個範例之後你有沒有更加理解,名字修飾乍聽之下複雜,但其實行為很單純:在宣告類別的時候,Python 直譯器看到前雙底線的變數或函式就幫你(字面上地)修改命名。筆者自己是這樣想像:

  • test() 函式裡,我的肉眼看到的是 return __mangled
  • Python 直譯器的眼睛看到的則是 return _MangledGlobal__mangled

以下再稍微修改剛剛的範例,我們來看看會發生什麼事:

_MangledGlobal__mangled = 23

class MangledGlobal:
    def __init__(self):
        self.__mangled = 9527

    def test(self):
        return __mangled

    def test2(self):
        return self.__mangled
>>> MangledGlobal().test()
23

>>> MangledGlobal().test2()
9527

看到這裡,你是更理解前雙底線與名字修飾,還是更一頭霧水了呢?



命名:前後雙底線(__var__

前跟後都有雙底線命名的函式,被稱為 Magic Method“Dunder” Method,在 Python 會用特殊的邏輯運作。

(註記:如果前跟後都有雙底線,Python 直譯器就不會使用上個小節提到的名字修飾了)

例如類別內總是會見到的 def __init__(self) 代表的是建構子(Constructor),每次你創建一個實例(Instance),__init__() 函式自動會第一個被執行。

再以 __lt__() 舉例,lt 是 Less Than 的縮寫,你的類別只要有實作這個函式,你就可以用小於符號(<)來呼叫該函式了。

class MyLessClass:
    def __lt__(self, another):
        print('hello world')
>>> my_less_class1 = MyLessClass()
>>> my_less_class2 = MyLessClass()
>>> my_less_class1.__lt__(my_less_class2)
hello world

>>> my_less_class1 < my_less_class2
hello world
# 目前這個「小於」只是被實作
# 邏輯上根本沒做到比大小 

明明類別宣告裡完全沒寫到 < 這個符號,只要實作 __lt__() 就能透過 < 符號運作了,前後雙底線是不是如 Magic Method 這個名稱、像魔法一樣神奇呢?

不過,正是因為前後雙底線命名的函式用法高度取決於 Python 內部的邏輯或是你使用的框架(Framework),如果你對這些 Magic Method 運作邏輯還不太熟悉,最好不要輕易使用前後雙底線的命名方式比較好。

結語

這則筆記總共分享了 7 種 Python 底線的運用方式,其中四種是跟變數還有函式命名有關:

  • 前單底線(_var):告知該變數或函式是類別內部使用的命名慣例
  • 後單底線(var_):避免跟 Python 內建功能重複命名
  • 前雙底線(__var):透過名字修飾機制防止子類別覆蓋變數或函式
  • 前後雙底線(__var__):有特殊運作邏輯的 Magic Method

另外三種則是寫 Python 程式好用的小技巧:

  • 常數加上底線 30_000_000 可以更好讀
  • 在直譯器中用 _ 查看最近一項運算結果
  • 迴圈或是解包時,想忽略的數值,直接用 _ 來命名

相信看完這篇文章後,以後你看到 Python 程式碼的各種底線將不再茫然!


參考資料:


還想知道更多 Python 相關技巧嗎?推薦你閱讀好豪的其他 Python 分享文章,學會更多實用程式設計方法:

也感謝你閱讀這篇筆記,歡迎追蹤 好豪的粉絲專頁Threads 帳號,我會持續分享 Python 技巧、資料科學等知識;也歡迎點選下方按鈕將本文加入書籤、或者分享給更多正在學 Python 的朋友。

推薦閱讀