如何正確遵守 Python 程式碼規範

前言

無規矩不成方圓,程式碼亦是如此,本篇文章將會介紹一些自己做項目時遵守的較為常用的 Python 程式碼規範。

命名

大小寫

  • 模組名寫法: module_name

  • 包名寫法: package_name

  • 類名: ClassName

  • 方法名: method_name

  • 異常名: ExceptionName

  • 函數名: function_name

  • 全局常量名: GLOBAL_CONSTANT_NAME

  • 全局變數名: global_var_name

  • 實例名: instance_var_name

  • 函數參數名: function_parameter_name

  • 局部變數名: local_var_name

命名約定

  1. 函數名,變數名和文件名應該是描述性的,盡量避免縮寫,除了計數器和迭代器、作為 try/except 中異常聲明的 e 以及作為 with 語句中文件句柄的 f.
  2. 用單下劃線(_)開頭表示變數或函數是 protected 的,不應該被外部訪問(除了子類).

注釋

函數和方法

一個函數必須要有文檔字元串, 除非它滿足以下條件:

  1. 外部不可見
  2. 非常短小
  3. 簡單明了

文檔字元串應該包含函數做什麼,以及輸入和輸出的詳細描述。通常,不應該描述「怎麼做」,除非是一些複雜的演算法。文檔字元串應該提供足夠的資訊,當別人編寫程式碼調用該函數時,他不需要看一行程式碼,只要看文檔字元串就可以了。

文檔字元串有多種風格,比如 Google 風格和 Numpy 風格,這裡比較推薦 Numpy 風格的文檔字元串,基本寫法如下述程式碼所示:

def download_song(song_info: SongInfo, save_dir: str, quality=SongQuality.STANDARD) -> str:
    """ download online music to local

    Parameters
    ----------
    song_info: SongInfo
        song information

    save_dir: str
        directory to save the downloaded audio file

    quality: SongQuality
        song sound quality

    Returns
    -------
    song_path: str
        save path of audio file, empty string when the download fails

    Raises
    ------
    AudioQualityError:
        thrown when the sound quality is illegal
    """
    pass

塊注釋和行注釋

最需要寫注釋的是程式碼中那些技巧性的部分,對於複雜的操作,應該在其操作開始前寫上若干行注釋。對於不是一目了然的程式碼, 應在其行尾添加註釋。為了提高可讀性, 注釋應該至少離開程式碼2個空格。

# We use a weighted dictionary search to find out where i is in
# the array.  We extrapolate position based on the largest num
# in the array and the array size and then do binary search to
# get the exact number.
if i & (i-1) == 0:        # True if i is 0 or a power of 2.

另一方面,絕不要描述程式碼。假設閱讀程式碼的人比你更懂 Python,他只是不知道你的程式碼要做什麼.

# BAD COMMENT: increase i
i += 1

縮進

用 4 個空格來縮進程式碼,絕對不要用 tab, 也不要 tab 和空格混用。對於行連接的情況,你應該垂直對齊換行的元素, 或者使用 4 空格的懸掛式縮進(這時第一行不應該有參數)。

哎呦不錯哦:

# 垂直對齊參數
foo = long_function_name(var_one, var_two,
                         var_three, var_four)

# 字典內垂直對齊
foo = {
    "long_dictionary_key": value1 +
                           value2,
    ...
}

# 4 個空格的懸掛縮進,左括弧後面沒有參數
foo = long_function_name(
    var_one,
    var_two,
    var_three,
    var_four
)

# 字典內 4 個空格的懸掛縮進
foo = {
    "long_dictionary_key":
        long_dictionary_value,
    ...
}

那種事情不要啊:

# 左括弧後不能攜帶參數
foo = long_function_name(var_one, var_two,
    var_three, var_four
)


# 禁止 2 個空格的懸掛式縮進
foo = long_function_name(
  var_one,
  var_two,
  var_three,
  var_four
)

# 字典內需要使用懸掛縮進
foo = {
    "long_dictionary_key":
    long_dictionary_value,
    ...
}

編寫程式碼時不能縮進過多的層級,一般不要超過 4 層,不然會造成閱讀困難。Python 中的縮進一般來自於 if-elseforwhile 等語句塊,一種減少縮進的方法是寫 if 就不寫 else,比如:

def update_by_ids(self, entities: List[Entity]) -> bool:
    """ update multi records

    Parameters
    ----------
    entities: List[Entity]
        entity instances

    Returns
    -------
    success: bool
        whether the update is successful
    """
    # 不滿足條件直接返回默認值
    if not entities:
        return True

    # 假設這是一堆很複雜的業務程式碼
    db = self.get_database()
    db.transaction()

    # 創建 sql 語句
    id_ = self.fields[0]
    values = ','.join([f'{i} = :{i}' for i in self.fields[1:]])
    sql = f"UPDATE {self.table} SET {values} WHERE {id_} = :{id_}"
    self.query.prepare(sql)

    return db.commit()

還可以使用 continue 來減少 forwhile 中不必要的縮進:

bboxes = []
for contour in contours:
    if cv.contourArea(contour) < 500:
        continue

    x, y, bw, bh = cv.boundingRect(contour)
    bboxes.append([x, y, bw, bh])

換行

頂級定義之間空兩行,比如函數或者類定義。 方法定義,類定義與第一個方法之間,都應該空一行。函數或方法中,某些地方要是你覺得合適,就空一行,比如在 if-elseforwhile 的後面空一行,以及在不同的邏輯之間加空行:

def download_cover(url: str, singer: str, album: str) -> str:
    """ download online album cover

    Parameters
    ----------
    url: str
        the url of online album cover

    singer: str
        singer name

    album: str
        album name

    Returns
    -------
    save_path: str
        save path of album cover, empty string when the download fails
    """
    if not url.startswith('http'):
        return ''

    # request data
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
                        'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36'
    }
    response = requests.get(url, headers=headers)
    response.raise_for_status()
    pic_data = response.content

    # save album cover
    return Cover(singer, album).save(pic_data)

導包

導入總應該放在文件頂部, 位於模組注釋和文檔字元串之後, 模組全局變數和常量之前。導入應該按照 標準庫 —> 第三方包 —> 項目程式碼 的順序導入:

# coding:utf-8
import base64
import json
from pathlib import Path
from typing import Union

import requests
from common.database.entity import SongInfo
from common.url import FakeUrl

from .crawler_base import CrawlerBase, MvQuality

函數長度

不對函數長度做硬性限制。但是若一個函數超過來40行,推薦考慮一下是否可以在不損害程式結構的情況下對其進行分解。因為即使現在長函數運行良好,幾個月後可能會有人修改它並添加一些新的行為,這容易產生難以發現的 bug。保持函數的簡練,使其更加容易閱讀和修改。當遇到一些很長的函數時,若發現調試比較困難或是想在其他地方使用函數的一部分功能,不妨考慮將這個場函數進行拆分。

類型注釋

  1. 公共的 API 需要注釋

  2. 對於容易出現類型相關的錯誤的程式碼進行注釋,比如下述程式碼的 image 可能為 numpy 數組,也可能是 Image 類型,究竟是哪種類型需要指明:

    def draw_bboxes(image: np.ndarray, bboxes: Union[np.ndarray, list], labels: List[str]) -> Image:
        pass
    
  3. 可以使用行章節附註釋 # type:

    persons = []  # type: List[Person]
    

參數和返回值類型

當函數需要傳入或者返回多個值,可以考慮將這些相關的值作為數據成員封裝到一個類中。如下述程式碼所示,使用 python3.7 提供的 dataclass 可以十分方便地創建一個實體類,接著可以傳入傳出實體類的實例:

from dataclasses import dataclass

@dataclass
class SongInfo:
    """ Song information """
    file: str = None
    title: str = None
    singer: str = None
    album: str = None
    year: int = None
    genre: str = None
    duration: int = None
    track: int = None
    track_total: int = None
    disc: int = None
    disc_total: int = None
    create_time: int = None
    modified_time: int = None


class SongInfoReader(SongInfoReaderBase):
    """ Song information reader """

    def read(self, file: Union[str, Path]):
        if not isinstance(file, Path):
            file = Path(file)

        tag = TinyTag.get(file)

        file_ = str(file).replace('\\', '/')
        title = tag.title or file.stem
        singer = tag.artist or self.singer
        album = tag.album or self.album
        year = self.__get_year(tag, file)
        genre = tag.genre or self.genre
        duration = int(tag.duration)
        track = self._parseTrack(tag.track or self.track)
        track_total = int(tag.track_total or self.track_total)
        disc = int(tag.disc or self.disc)
        disc_total = int(tag.disc_total or self.disc_total)
        create_time = int(file.stat().st_ctime)
        modified_time = int(file.stat().st_mtime)

        return SongInfo(
            file=file_,
            title=title,
            singer=singer,
            album=album,
            year=year,
            genre=genre,
            duration=duration,
            track=track,
            track_total=track_total,
            disc=disc,
            disc_total=disc_total,
            createTime=create_time,
            modifiedTime=modified_time
        )


class KuWoMusicCrawler:
    """ Kuwo music crawler """

    def get_song_url(self, song_info: SongInfo) -> str:
        if not FakeUrl.isFake(song_info.file):
            return song_info.file

        # send request for play url
        rid = KuWoFakeSongUrl.getId(song_info.file)
        url = f'//www.kuwo.cn/api/v1/www/music/playUrl?mid={rid}&type=convert_url3&br=128kmp3'
        response = requests.get(url, headers=self.headers)
        response.raise_for_status()
        play_url = json.loads(response.text)['data']['url']

        return play_url

千萬不要圖一時方便而把這些欄位放在字典或者列表之中,然後作為函數的參數或者返回值。寫時一時爽,重構火葬場。

寫在最後

如果能遵守上述規範,相信程式碼不會那麼容易散發出難聞的氣味。關於更多程式碼規範,可以參見 Google 開源項目風格指南,本文也參考了指南的部分內容。如果想寫出更加優雅的程式碼,可以閱讀設計模式相關的書籍和部落格,這裡推薦《精通Python設計模式》和教程 Refactoring GURU,以上~~

Tags: