2020年7月12日 星期日

Pythonic 實踐:實用的 python 慣用法整理

沒有留言:
 


近來匆忙於大小事務,還是要抽時間看點輕鬆的 code 呀,整理一個 pythonic 的小品文章分享給大家!



什麼是 Pythonic 的寫法?


Pythonic Code 的意思是:具有 Python 特色的 Python 程式碼,白話一點的說法就是「這 Code 很 Python」。

Pythonic 的用法明顯和其它語言的寫法不同,有許多約定俗成的慣用法(idioms),也就是說,一個好的 python 開發者在 trace 一份新的 python 程式碼時,只要看到這些 pythonic 的程式碼樣式,就知道這份 python code 是由熟練 python 的人所撰寫。

那麼,要如何寫出道地的 python 程式碼呢?比較官方的說法是參考 Python style guide: PEP 8 (Python Enhancement Proposal) ,一般稱作 PEP8,這個文件是一份撰寫 python 的重要規範,另外,也可以藉由安裝靜態原始碼分析器 Pylint 來自動用 PEP8 或其他自訂的規則分析校正自己所撰寫的程式碼,非常方便。

然而,和學習外文一樣,與其看一些文法書,不如實際練習一些片語和例句,藉由不斷的使用才能更容易道地的使用一個語言,所以以下列舉一些非常常見又道地的 python 用法,那就廢話不多說,開始學習吧!


Outline


  • 什麼是 Pythonic 的寫法?
  • 一、Pythonic 用法:List 篇
    • 切割 List 的操作
    • List comprehension
  • 二、Pythonic 用法:Loop 篇
    • in, enumerate, zip, items
    • 不推薦 for ... else 用法
  • 三、Pythonic 用法:賦值和條件判斷
    • 兩值交換、連鎖比較、檢查空值、條件判斷
  • 四、Pythonic 用法:文件處理與字串操作
    • 用上下文管理器 with 做文件處理
    • strinng 串接 join
    • 注意 Python 的 string formatting 方式
  • 五、Pythonic 用法:Function 篇
    • 使用多重回傳值與下底線 _ 忽略回傳
    • 小心可變變數 (mutable variable) 作為參數 default 值造成的錯誤
  • 結語


 

一、Pythonic 用法:List 篇


List 是 python 中最常見的資料結構,想看起來 Pythonic 就要寫好 List,關於 List 的 pythonic 比較常見有兩個面向:

  • 切割 List 的操作
  • List Comprehension

 

A. 切割 List 的操作

 

基本的 List 操作(切割序列),幾乎每種用法在 python 中都非常常見。但在較為古典的語言中 (如 C、java) 可能比較少看到這些操作方法,會覺得這些省略 index、冒號或負 index 有點奇怪,在這裡做一個簡單的歸納:


my_list = [x for x in range(10)]
# my_list[start:end:stride]     # 切割序列的格式
print(my_list)
print(my_list[0:4])             # 和其他語言相同慣例,索引的 start 包含,end 不包含。
print(my_list[4:len(my_list)])
print(my_list[:4])              # 索引是頭尾可以省略
print(my_list[4:])    
print(my_list[-4:])             # 索引可以為負,本例表示最後4項。

# [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# [0, 1, 2, 3]
# [4, 5, 6, 7, 8, 9]
# [0, 1, 2, 3]
# [4, 5, 6, 7, 8, 9]
# [6, 7, 8, 9]

my_list[20]                     # 沒有該 index 直接 error
my_list[20:21]                  # 沒有該 index 回傳空值
my_list[2:8] = ["b","a"]        # 可以範圍取代
my_list[::-1]                   # 很常見的 reverse 寫法
reversed(my_list)               # 和上式相等,可讀性更高
if my_list == my_list[::-1]:    # reverse 小應用,判斷回文。
    print("Palindrome!")

值得注意的是,切割序列後得到的是全新 List,修改新 List 不會改到原 List。

 

[不建議] 不要為了炫技寫這樣

有時候會看到有人這樣寫:

myList[2:2:2]
myList[-2:2:-2]
myList[2:2:-2]

鬼才看得懂!非常不建議這樣寫,[1]避免同時使用 start、end 和 stride,真的要用可以分開步驟撰寫,並且除了常見的 [::-1],[2]盡量使用正數的 stride

 

 

B. List comprehension


List comprehension 的基礎語法如下:

[ expression for item in list (if conditional) ]
[ expression_A if condition else expression_B for item in list ]

List comprehension 是我自己覺得最 pythonic 的操作,優點非常多,靈活度高、非常精簡且效率高,這種寫法顯而易見的有以下優點:

  • 提供非常便捷的方式產生目標 List,很多簡單迴圈加上判斷式產生 List 的寫法,都可以利用這種寫法改寫,讓程式碼更簡潔。
  • 由於 List comprehension 底層是由 C 語言實作,效率比起 python 的 For 迴圈提升很多,速度的提升在實際開發上是感覺得出來的。


當然,這種寫法也有一些缺點:

  • 其他大多數的語言沒有支援這種寫法,所以跨語言的開發者會有一點學習曲線。
  • 很容易讓你寫出很複雜的單行運算式,用這種寫法當判斷式太長時可讀性會大幅降低,這也是 python 語法有時候難以閱讀的元兇。


下面列舉一些非常常見的 List comprehension 用法與注意事項。


a. 0~99 偶數取平方

生成以下 0~99 偶數取平方的數列,下面是一個很正常的 C-like 寫法:

myList = []
for i in range(100):
    if i%2==0:
        myList.append(i*i)
print(myList)

# [0, 4, 16, 36, 64, 100, 144, 196, 256, 324, 400, 484, 576, 676, 784, 900, 1024, 1156, 1296, 1444, 1600, 1764, 1936, 2116, 2304, 2500, 2704, 2916, 3136, 3364, 3600, 3844, 4096, 4356, 4624, 4900, 5184, 5476, 5776, 6084, 6400, 6724, 7056, 7396, 7744, 8100, 8464, 8836, 9216, 9604]

使用 List comprehension 的話就非常簡潔:

my_list = [x*x for x in range(100) if x%2==0]
print(my_list)

另外還有 lambda 的寫法:

my_list = list(map(lambda x: x*x, filter(lambda x : x%2==0, range(100))))
print(my_list)

我個人是不太推薦這種用法,雖然 lambda 的寫法近年來有較多推廣,可讀性相比傳統語法而言還是比較差!List Comprehensions 比起 map() 以及 filter() 來得更簡單且有彈性許多。


b. 字串/數值預處理

一些簡短的預處理可以使用 List comprehension 讓表達更簡潔,例如 [1] 英文大小寫轉換或 [2]將存在 NaN 之數字字串 List 轉換成 float 等。

import numpy as np

str_list = ['HELLO', 'World', '!']
lowercase_list = [x.lower() for x in str_list]


raw_data = ["1", np.nan, "4", "6", "  7", "  23", "4", np.nan]
data = [float(x) if not np.isnan(float(x)) else -1 for x in raw_data]

# [1.0, -1, 4.0, 6.0, 7.0, 23.0, 4.0, -1]


c. 檔案處理

常見檔案列舉用 list comprehension 也很適合,例如列舉路徑內的檔案/特定檔案。

import os

cur_path = '.'

my_files_dirs = [x for x in os.listdir(cur_path)]
print(my_files_dirs)

my_files = [x for x in os.listdir(cur_path) if os.path.isfile(os.path.join(cur_path, x))]
print(my_files)

extensions = ['.png', '.jpg', '.gif']
my_img_files = [x for x in os.listdir(cur_path) if any(s in x for s in extensions)] 
print(my_img_files)

# ['a.png', 'b.png', 'c.gif']

 

[進階] Generator 寫法

比較進階且容易被忽略的用法是 產生器表達式 (Generator Expression),基本上寫法和 List comprehension 相似 (中括弧換成小括弧),Generator 可以按需要產⽣ instance,從而節省記憶體。

一個簡單的示例如下,方法一無法執行完畢,而方法二可以持續執行。

my_list = [x*x for x in range(0,999999999) if x%2==0]
for i in my_list:
    print(i)

my_gen = (x*x for x in range(0,999999999) if x%2==0)
for i in my_gen:
    print(i)
    next(my_gen)

** 一般 Generator 宣考方法是在一般 Function 中把 return 改成 yield

 

[補記] 其他的 comprehension

關於 List comprehension,最早只有 List comprehension 的寫法,而目前常見的版本也具備 dictionary comprehension 和 set comprehension 的功能。

{ key:value for item in list if conditional }
{ expression for item in list if conditional }

 

 

 

二、Pythonic 用法:Loop 篇


在 Python 中,大抵各種資料型態都有自己最常見的 iteration 方法,這裏整理 4 種最為常見的狀況:in、enumerate、zip 和 items。


A. 用 in 走訪元素


Bad:

這個不好的 pattern 最常見於從 C/C++ 和 Java 過來的開發者。

# for (i=0; i < mylist_length; i++) {
#    printf(mylist[i]);
# }

mylist = [0, 1, 2, 3, 5, 7]
for i in range(len(mylist)):
    print(mylist[i])

Good:

python 非常常用 in 來拿元素,取代繁瑣的 index 操作。

mylist = [0, 1, 2, 3, 5, 7]
for item in mylist:
    print(item)

 

B. 用 enumerate 走訪元素和 index


Bad:

在走訪 List 要 index 又要 element 時,用直覺的寫法會比較累贅。

my_list = ["apple", "banana", "cat"]
for i in range(len(my_list)):
    print(i, my_list[i])

Good:

用建議的 enumerate() 程式更加簡潔,非常常見的用法。

my_list = ["apple", "banana", "cat"]
for i, item in enumerate(my_list):
    print(i, item)

 

C. 用 zip 同時走訪多個 List、做 Dict 轉換


Bad:

同時走訪 2 個 List,用 index 一般不建議。

for i in range(len(names)):
    print(names[i], ages[i])

Good:

用 zip 就可以一次走訪

names = ["John", "Tom", "Tina"]
ages = [17, 23, 20]
for x, y in zip(names, ages):
    print(x, y)

 

Bad:

兩個 List 轉 dict,用 for 很繁瑣。

names = ["John", "Tom", "Tina"]
ages = [17, 23, 20]
mydict = {}
for ind, val in enumerate(names):
  mydict[val] = ages[ind]

Good:

實際上很常用 zip() 處理,zip() 是返回一個 iterator,所以可以用這個 iterator 轉型成想要的資料型態:

mydict = dict(zip(names, ages))

 

D. 用 items 走訪 map


Bad:

python 裡面 dict 走訪方式很多,在需要 key, value 或 key/value 的場合要分別用適合的 iterator:

for key in my_dict:
    print(key, my_dict[key])

Good:

for k, v in my_dict.items():
    print(k, v)

for key in my_dict:
    print(key)

for val in my_dict.values():
    print(val)

 

[不建議] for 和 else 一起用?


python 的 for ... else 寫法一般比較少見,意思是如果中途從 break 跳出,則不執行 else。

例如找平方數直覺的寫法:

import math

found_flag = False
for n in range(101,115,1):
    root = math.sqrt(n)  
    if root == int(root):  
        print("Find it!") 
        found_flag = True
        break  

if not found_flag:
    print("Not found...")

而 for ... else 寫法可以少掉 flag 的使用,雖然看起來簡潔,但很少語言有這個語法,不太建議使用,因為容易令人困惑。

for n in range(101,115,1):  
    root = math.sqrt(n)  
    if root == int(root):  
        print("Find it!") 
        break  
else:  
    print("Not found...")    

 

 

三、Pythonic 用法:賦值和條件判斷


A. 兩值交換


很常見,只要一行,比起其他語言簡潔很多。

Bad:

c = a
a = b
b = c

Good:

a, b = b, a

 

B. 連鎖比較


Bad:

if 0 < a and a < 100:
	print("1 ~ 100 case")

Good:

if 0 < a < 100:
	print("1 ~ 100 case")

 

C. 檢查空值、條件判斷


Bad:

if len(mylist) == 0:  
	print("empty list")
if flag == True:
	print("flag true!")

Good:

if not mylist: 
	print("empty list")
if flag:
	print("flag true!")

 

[注意] 布林值為 False 的情況

布林值為 False 的狀況通常有以下幾種:

  • False
  • None
  • 空的字串 ""
  • 數字 0
  • 空的容器 [] () {} set()

一般來說很常直接 if item:if not item: 這樣的方式直接使用,是很 pythonic 的寫法。

 

D. C 語言中的 a? b:c 的 python 替代品


如果寫過 C/C++ 一定很想寫這種寫法

bool is_adult;
is_adult = (age>18) ? 1:0;

python 裡面可以這樣寫達到同樣的效果(雖然我覺得可讀性差了一點):

is_adult = True if age >= 18 else False

 

 

四、Pythonic 用法:文件處理與字串操作


A. 用上下文管理器 with 做文件處理


當用 python 處理文件時,一般不必 try-except 手動 open/close 檔案,而是通過用 with 上下文管理 (context manager) 來自動 open/close 檔案:

Bad:

一般 Java/C 的開檔風格。

myfile = open('data.txt') 
try: 
    for line in myfile: 
        print(line)
finally:
    myfile.close()

Good:

使用上下文管理器 with 是 pythonic 的標準做法,非常常見。

with open('data.txt') as myfile:
    for line in myfile:
        print(line)

 

B. 字串連接用 join


Bad:

一個簡單的慣例,把 list 裡的字串做串接。

names = ['Tom', 'John', 'Tina']
names_str = names[0]
for name in names[1:]:
    names_str += ', ' + name 
print(names_str)

Good:

names_str = ", ".join(names)
print(names_str)

 

C. 注意 Python 的 string formatting 方式


由於歷史因素,python 的 string formatting 有多種方式,雖然舊版的 % 方式沒有停止支援,但還是建議使用新版的 str.format() 相關用法:

name, method = 'Tom', 'A: string concate'
print("Hi " + name + ", Using Method " + method + " is not a good idea...")

name, method = 'Tom', 'B: old python2 formatting'
print('Hi %s, Using Method %s is not a good idea, either.' % (name, method))

name, method = 'Tom', 'C: python3 formatting'
print('Hi {name}, Using Method {method} is a good idea!'.format(name=name, method=method))

name, method = 'Tom', 'D: python3 f formatting'
print(f'Hi {name}, Using Method {method} is a good idea, too.')

# Hi Tom, Using Method A: string concate is not a good idea...
# Hi Tom, Using Method B: old python2 formatting is not a good idea, either.
# Hi Tom, Using Method C: python3 formatting is a good idea!
# Hi Tom, Using Method D: python3 f formatting is a good idea, too.

 

 

五、Pythonic 用法:Function 篇


A. 使用多重回傳值與下底線 _ 忽略回傳


Bad:

在其他語言中,一個簡單的函式回傳可能是這樣:

void test(int* a, float* b, float* c) {
    *a = 3;
    *b = 5.5;
    *ans = *a + *b;
}
...
int alpha;
int beta;
int ans;
foo(&alpha, &beta, &ans);

Good:

而 python 的 Function 中有兩個好用的特色:

  • 一次可以回傳多個值
  • 用 _ 省略忽略的回傳值
def foo():
	  a, b = 3, 5.5
    return a, b, a+b 

alpha, beta, ans = foo()
_, _, ans = foo()

交互變數 "_" 會保留最近一次的輸出,當想省略 function 回傳變數時,通常也用這個變數來接住省略的變數。

 

B. 小心可變變數 (mutable variable) 作為參數 default 值造成的錯誤


Bad:

在下面的函式範例中,datetime 不會每次更新且 list 狀態也不會每次清空。這個不符合預期的行為,是可變變數 (mutable variable) 保留前次呼叫的狀態所造成。

from datetime import datetime
import time

def get_datetime_log(mydate=datetime.now(), my_log_list = []):
    my_log_list.append("current log {}".format(datetime.strftime(mydate, "%Y/%m/%d %H:%M:%S")))
    return my_log_list

print(get_datetime_log())
time.sleep(1)
print(get_datetime_log())
time.sleep(1)
print(get_datetime_log())

# ['current log 2020/07/12 13:41:16']
# ['current log 2020/07/12 13:41:16', 'current log 2020/07/12 13:41:16']
# ['current log 2020/07/12 13:41:16', 'current log 2020/07/12 13:41:16', 'current log 2020/07/12 13:41:16']

Good:

一個好的解法是把可變變數的參數位置用 None 作為預設值,進到函式再進行初始化。

from datetime import datetime

def get_datetime_log(mydate=None, my_log_list=None):
    mydate = datetime.now() if mydate is None else mydate
    if my_log_list is None:
        my_log_list = []
    my_log_list.append("current log {}".format(datetime.strftime(mydate, "%Y/%m/%d %H:%M:%S")))
    return my_log_list 

print(get_datetime_log())
time.sleep(1)
print(get_datetime_log())
time.sleep(1)
print(get_datetime_log())

# ['current log 2020/07/12 14:00:38']
# ['current log 2020/07/12 14:00:39']
# ['current log 2020/07/12 14:00:40']

 

[補記] python 中的可修改/不可修改物件

關於可修改/不可修改物件,一個直觀的想法如下:

  • immutable object 在函數參數傳遞是類似於 C/C++ 中的 call by value
  • mutable object 在函數參數傳遞是類似於 C/C++ 中的 call by reference
a = 1
print(id(a))
a = 100
print(id(a))

b = [1,2,3]
print(id(b))
b[0] = 100
print(id(b))

# 4552516864
# 4552520032
# 4647260744
# 4647260744

 

在 python 中的不可修改的物件 (immutable objects) :

  • Numeric types: int, float, complex
  • string
  • tuple
  • frozen set

在 python 中的可修改的物件 (mutable) :

  • list
  • dict
  • set
  • byte array

 

 

結語


看了這麼多精簡的慣用法,可能一時間沒辦法在實戰中馬上吸收應對,但其實學習 Pythonic 和文章寫作沒什麼區別:總之用心寫,在保證程式碼可讀性的前提下,程式碼盡量短,換行是逗號,空一行是句號,空兩行是分段... 多看好的實作 (如各大開源專案),多臨摹學習,就會越寫越好。

pythonic 當然還有很多面向,其他比較進階的主題像是:

  • Python 程式碼標準架構 (Python Program Structure)
  • decorator
  • OO (super, metaclass, …)
  • 重新定義對象屬性的特性 ( property, getattr, ... )
  • lambda 表達式
  • closures
  • Test case
  • ……


有機會再寫寫這些進階的主題跟大家分享:)

 

 

[彩蛋] Python 之禪


在 python terminal 打上 import this 會出現一段精美有詩意的宣言:

Beautiful is better than ugly. 

Explicit is better than implicit. 

Simple is better than complex. 

Complex is better than complicated. 

Flat is better than nested. 

Sparse is better than dense. 

Readability counts. 

Special cases aren't special enough to break the rules. 

Although practicality beats purity. 

Errors should never pass silently. 

Unless explicitly silenced. 

In the face of ambiguity, refuse the temptation to guess. 

There should be one-- and preferably only one --obvious way to do it. 

Although that way may not be obvious at first unless you're Dutch. 

Now is better than never. 

Although never is often better than right now. 

If the implementation is hard to explain, it's a bad idea. 

If the implementation is easy to explain, it may be a good idea. 

Namespaces are one honking great idea -- let's do more of those!

 

 

 



References


[Book] Effective Python: 59 Specific Ways to Write Better Python

PEP 8 -- Style Guide for Python Code
https://www.python.org/dev/peps/pep-0008/

The Hitchhiker's Guide to Python - Code Style
https://docs.python-guide.org/writing/style/

Tim's Blog - 讓你的 Python 代碼更加 pythonic
https://wuzhiwei.net/be_pythonic/

機遇 - Python中的一些奇淫技巧
https://deepindeed.cn/2018/12/14/Python-Tips/

Sebastiano Panichella - On the Usage of Pythonic Idioms https://www.slideshare.net/sebastianopanichella/on-the-usage-of-pythonic-idioms

 

 

 

 

沒有留言:

張貼留言

技術提供:Blogger.