Python 想必大家都已經很熟悉了,甚至關于它有用或者無用的論點大家可能也已經看膩了。但是無論如何,它作為一個將加入高考科目的語言還是有它獨到之處的,今天我們就再展開聊聊 Python。
《流暢的 Python》一書中提到,如果一門語言很少隱式轉換類型,說明它是強類型語言,例如 Java、C++ 和 Python 就是強類型語言。
同時如果一門語言經常隱式轉換類型,說明它是弱類型語言,PHP、JavaScript 和 Perl 是弱類型語言。
當然上面這種簡單的示例對比,并不能確切的說 Python 是一門強類型語言,因為 Java 同樣支持 integer 和 string 相加操作,且 Java 是強類型語言。因此《流暢的 Python》一書中還有關于靜態類型和動態類型的定義:在編譯時檢查類型的語言是靜態類型語言,在運行時檢查類型的語言是動態類型語言。靜態語言需要聲明類型(有些現代語言使用類型推導避免部分類型聲明)。
綜上所述,關于 Python 是動態強類型語言是比較顯而易見沒什么爭議的。
Python 在 PEP 484(Python Enhancement Proposals,Python 增強建議書)[https://www.python.org/dev/peps/pep-0484/]中提出了 Type Hints(類型注解)。進一步強化了 Python 是一門強類型語言的特性,它在 Python3.5 中第一次被引入。使用 Type Hints 可以讓我們編寫出帶有類型的 Python 代碼,看起來更加符合強類型語言風格。
這里定義了兩個 greeting 函數:
name?=?"world" def?greeting(name): ????return?"Hello?"?+?name greeting(name)
name:?str?=?"world" def?greeting(name:?str)?->?str: ????return?"Hello?"?+?name greeting(name)
以 PyCharm 為例,在編寫代碼的過程中 IDE 會根據函數的類型標注,對傳遞給函數的參數進行類型檢查。如果發現實參類型與函數的形參類型標注不符就會有如下提示:
上面通過一個 greeting 函數展示了 Type Hints 的用法,接下來我們就 Python 常見數據結構的 Type Hints 寫法進行更加深入的學習。
默認參數
Python 函數支持默認參數,以下是默認參數的 Type Hints 寫法,只需要將類型寫到變量和默認參數之間即可。
def?greeting(name:?str?=?"world")?->?str: ????return?"Hello?"?+?name greeting()
自定義類型
對于自定義類型,Type Hints 同樣能夠很好的支持。它的寫法跟 Python 內置類型并無區別。
class?Student(object): ????def?__init__(self,?name,?age): ????????self.name?=?name ????????self.age?=?age def?student_to_string(s:?Student)?->?str: ????return?f"student?name:?{s.name},?age:?{s.age}." student_to_string(Student("Tim",?18))
當類型標注為自定義類型時,IDE 也能夠對類型進行檢查。
容器類型
當我們要給內置容器類型添加類型標注時,由于類型注解運算符 [] 在 Python 中代表切片操作,因此會引發語法錯誤。所以不能直接使用內置容器類型當作注解,需要從 typing 模塊中導入對應的容器類型注解(通常為內置類型的首字母大寫形式)。
from?typing?import?List,?Tuple,?Dict l:?List[int]?=?[1,?2,?3] t:?Tuple[str,?...]?=?("a",?"b") d:?Dict[str,?int]?=?{ ????"a":?1, ????"b":?2, }
不過 PEP 585[https://www.python.org/dev/peps/pep-0585/]的出現解決了這個問題,我們可以直接使用 Python 的內置類型,而不會出現語法錯誤。
l:?list[int]?=?[1,?2,?3] t:?tuple[str,?...]?=?("a",?"b") d:?dict[str,?int]?=?{ ????"a":?1, ????"b":?2, }
類型別名
有些復雜的嵌套類型寫起來很長,如果出現重復,就會很痛苦,代碼也會不夠整潔。
config:?list[tuple[str,?int],?dict[str,?str]]?=?[ ????("127.0.0.1",?8080), ????{ ????????"MYSQL_DB":?"db", ????????"MYSQL_USER":?"user", ????????"MYSQL_PASS":?"pass", ????????"MYSQL_HOST":?"127.0.0.1", ????????"MYSQL_PORT":?"3306", ????}, ] def?start_server(config:?list[tuple[str,?int],?dict[str,?str]])?->?None: ????... start_server(config)
此時可以通過給類型起別名的方式來解決,類似變量命名。
Config?=?list[tuple[str,?int],?dict[str,?str]] config:?Config?=?[ ????("127.0.0.1",?8080), ????{ ????????"MYSQL_DB":?"db", ????????"MYSQL_USER":?"user", ????????"MYSQL_PASS":?"pass", ????????"MYSQL_HOST":?"127.0.0.1", ????????"MYSQL_PORT":?"3306", ????}, ] def?start_server(config:?Config)?->?None: ????... start_server(config)
這樣代碼看起來就舒服多了。
可變參數
Python 函數一個非常靈活的地方就是支持可變參數,Type Hints 同樣支持可變參數的類型標注。
def?foo(*args:?str,?**kwargs:?int)?->?None: ????... foo("a",?"b",?1,?x=2,?y="c")
IDE 仍能夠檢查出來。
泛型
使用動態語言少不了泛型的支持,Type Hints 針對泛型也提供了多種解決方案。
TypeVar
使用 TypeVar 可以接收任意類型。
from?typing?import?TypeVar T?=?TypeVar("T") def?foo(*args:?T,?**kwargs:?T)?->?None: ????... foo("a",?"b",?1,?x=2,?y="c")
Union
如果不想使用泛型,只想使用幾種指定的類型,那么可以使用 Union 來做。比如定義 concat 函數只想接收 str 或 bytes 類型。
from?typing?import?Union T?=?Union[str,?bytes] def?concat(s1:?T,?s2:?T)?->?T: ????return?s1?+?s2 concat("hello",?"world") concat(b"hello",?b"world") concat("hello",?b"world") concat(b"hello",?"world")
IDE 的檢查提示如下圖:
TypeVar 和 Union 區別
TypeVar 不只可以接收泛型,它也可以像 Union 一樣使用,只需要在實例化時將想要指定的類型范圍當作參數依次傳進來來即可。跟 Union 不同的是,使用 TypeVar 聲明的函數,多參數類型必須相同,而 Union 不做限制。
from?typing?import?TypeVar T?=?TypeVar("T",?str,?bytes) def?concat(s1:?T,?s2:?T)?->?T: ????return?s1?+?s2 concat("hello",?"world") concat(b"hello",?b"world") concat("hello",?b"world")
以下是使用 TypeVar 做限定類型時的 IDE 提示:
Optional
Type Hints 提供了 Optional 來作為 Union[X, None] 的簡寫形式,表示被標注的參數要么為 X 類型,要么為 None,Optional[X] 等價于 Union[X, None]。
from?typing?import?Optional,?Union #?None?=>?type(None) def?foo(arg:?Union[int,?None]?=?None)?->?None: ????... def?foo(arg:?Optional[int]?=?None)?->?None: ????...
Any
Any 是一種特殊的類型,可以代表所有類型。未指定返回值與參數類型的函數,都隱式地默認使用 Any,所以以下兩個 greeting 函數寫法等價:
from?typing?import?Any def?greeting(name): ????return?"Hello?"?+?name def?greeting(name:?Any)?->?Any: ????return?"Hello?"?+?name
當我們既想使用 Type Hints 來實現靜態類型的寫法,也不想失去動態語言特有的靈活性時,即可使用 Any。
Any 類型值賦給更精確的類型時,不執行類型檢查,如下代碼 IDE 并不會有錯誤提示:
from?typing?import?Any a:?Any?=?None a?=?[]??#?動態語言特性 a?=?2 s:?str?=?'' s?=?a??#?Any?類型值賦給更精確的類型
可調用對象(函數、類等)
Python 中的任何可調用類型都可以使用 Callable 進行標注。如下代碼標注中 Callable[[int], str],[int] 表示可調用類型的參數列表,str 表示返回值。
from?typing?import?Callable def?int_to_str(i:?int)?->?str: ????return?str(i) def?f(fn:?Callable[[int],?str],?i:?int)?->?str: ????return?fn(i) f(int_to_str,?2)
自引用
當我們需要定義樹型結構時,往往需要自引用。當執行到 init 方法時 Tree 類型還沒有生成,所以不能像使用 str 這種內置類型一樣直接進行標注,需要采用字符串形式“Tree”來對未生成的對象進行引用。
class?Tree(object): ????def?__init__(self,?left:?"Tree"?=?None,?right:?"Tree"?=?None): ????????self.left?=?left ????????self.right?=?right tree1?=?Tree(Tree(),?Tree())
IDE 同樣能夠對自引用類型進行檢查。
此形式不僅能夠用于自引用,前置引用同樣適用。
鴨子類型
Python 一個顯著的特點是其對鴨子類型的大量應用,Type Hints 提供了 Protocol 來對鴨子類型進行支持。定義類時只需要繼承 Protocol 就可以聲明一個接口類型,當遇到接口類型的注解時,只要接收到的對象實現了接口類型的所有方法,即可通過類型注解的檢查,IDE 便不會報錯。這里的 Stream 無需顯式繼承 Interface 類,只需要實現了 close 方法即可。
from?typing?import?Protocol class?Interface(Protocol): ????def?close(self)?->?None: ????????... #?class?Stream(Interface): class?Stream: ????def?close(self)?->?None: ????????... def?close_resource(r:?Interface)?->?None: ????r.close() f?=?open("a.txt") close_resource(f) s:?Stream?=?Stream() close_resource(s)
由于內置的 open 函數返回的文件對象和 Stream 對象都實現了 close 方法,所以能夠通過 Type Hints 的檢查,而字符串“s”并沒有實現 close 方法,所以 IDE 會提示類型錯誤。
實際上 Type Hints 不只有一種寫法,Python 為了兼容不同人的喜好和老代碼的遷移還實現了另外兩種寫法。
使用注釋編寫
來看一個 tornado 框架的例子(tornado/web.py)。適用于在已有的項目上做修改,代碼已經寫好了,后期需要增加類型標注。
使用單獨文件編寫(.pyi)
可以在源代碼相同的目錄下新建一個與 .py 同名的 .pyi 文件,IDE 同樣能夠自動做類型檢查。這么做的優點是可以對原來的代碼不做任何改動,完全解耦。缺點是相當于要同時維護兩份代碼。
基本上,日常編碼中常用的 Type Hints 寫法都已經介紹給大家了,下面就讓我們一起來看看如何在實際編碼中中應用 Type Hints。
dataclass——數據類
dataclass 是一個裝飾器,它可以對類進行裝飾,用于給類添加魔法方法,例如 init() 和 repr() 等,它在 PEP 557[https://www.python.org/dev/peps/pep-0557/]中被定義。
from?dataclasses?import?dataclass,?field @dataclass class?User(object): ????id:?int ????name:?str ????friends:?list[int]?=?field(default_factory=list) data?=?{ ????"id":?123, ????"name":?"Tim", } user?=?User(**data) print(user.id,?user.name,?user.friends) #?>?123?Tim?[]
以上使用 dataclass 編寫的代碼同如下代碼等價:
class?User(object): ????def?__init__(self,?id:?int,?name:?str,?friends=None): ????????self.id?=?id ????????self.name?=?name ????????self.friends?=?friends?or?[] data?=?{ ????"id":?123, ????"name":?"Tim", } user?=?User(**data) print(user.id,?user.name,?user.friends) #?>?123?Tim?[]
注意:dataclass 并不會對字段類型進行檢查。
可以發現,使用 dataclass 來編寫類可以減少很多重復的樣板代碼,語法上也更加清晰。
Pydantic
Pydantic 是一個基于 Python Type Hints 的第三方庫,它提供了數據驗證、序列化和文檔的功能,是一個非常值得學習借鑒的庫。以下是一段使用 Pydantic 的示例代碼:
from?datetime?import?datetime from?typing?import?Optional from?pydantic?import?BaseModel class?User(BaseModel): ????id:?int ????name?=?'John?Doe' ????signup_ts:?Optional[datetime]?=?None ????friends:?list[int]?=?[] external_data?=?{ ????'id':?'123', ????'signup_ts':?'2021-09-02?17:00', ????'friends':?[1,?2,?'3'], } user?=?User(**external_data) print(user.id) #?>?123 print(repr(user.signup_ts)) #?>?datetime.datetime(2021,?9,?2,?17,?0) print(user.friends) #?>?[1,?2,?3] print(user.dict()) """ { ????'id':?123, ????'signup_ts':?datetime.datetime(2021,?9,?2,?17,?0), ????'friends':?[1,?2,?3], ????'name':?'John?Doe', } """
注意:Pydantic 會對字段類型進行強制檢查。
Pydantic 寫法上跟 dataclass 非常類似,但它做了更多的額外工作,還提供了如 .dict() 這樣非常方便的方法。
再來看一個 Pydantic 進行數據驗證的示例,當 User 類接收到的參數不符合預期時,會拋出 ValidationError 異常,異常對象提供了 .json() 方法方便查看異常原因。
from?pydantic?import?ValidationError try: ????User(signup_ts='broken',?friends=[1,?2,?'not?number']) except?ValidationError?as?e: ????print(e.json()) """ [ ??{ ????"loc":?[ ??????"id" ????], ????"msg":?"field?required", ????"type":?"value_error.missing" ??}, ??{ ????"loc":?[ ??????"signup_ts" ????], ????"msg":?"invalid?datetime?format", ????"type":?"value_error.datetime" ??}, ??{ ????"loc":?[ ??????"friends", ??????2 ????], ????"msg":?"value?is?not?a?valid?integer", ????"type":?"type_error.integer" ??} ] """
所有報錯信息都保存在一個 list 中,每個字段的報錯又保存在嵌套的 dict 中,其中 loc 標識了異常字段和報錯位置,msg 為報錯提示信息,type 則為報錯類型,這樣整個報錯原因一目了然。
MySQLHandler
MySQLHandler[https://github.com/jianghushinian/python-scripts/blob/main/scripts/mysql_handler_type_hints.py]是我對 pymysql 庫的封裝,使其支持使用 with 語法調用 execute 方法,并且將查詢結果從 tuple 替換成 object,同樣也是對 Type Hints 的應用。
class?MySQLHandler(object): ????"""MySQL?handler""" ????def?__init__(self): ????????self.conn?=?pymysql.connect( ????????????host=DB_HOST, ????????????port=DB_PORT, ????????????user=DB_USER, ????????????password=DB_PASS, ????????????database=DB_NAME, ????????????charset=DB_CHARSET, ????????????client_flag=CLIENT.MULTI_STATEMENTS,??#?execute?multi?sql?statements ????????) ????????self.cursor?=?self.conn.cursor() ????def?__del__(self): ????????self.cursor.close() ????????self.conn.close() ????@contextmanager ????def?execute(self): ????????try: ????????????yield?self.cursor.execute ????????????self.conn.commit() ????????except?Exception?as?e: ????????????self.conn.rollback() ????????????logging.exception(e) ????@contextmanager ????def?executemany(self): ????????try: ????????????yield?self.cursor.executemany ????????????self.conn.commit() ????????except?Exception?as?e: ????????????self.conn.rollback() ????????????logging.exception(e) ????def?_tuple_to_object(self,?data:?List[tuple])?->?List[FetchObject]: ????????obj_list?=?[] ????????attrs?=?[desc[0]?for?desc?in?self.cursor.description] ????????for?i?in?data: ????????????obj?=?FetchObject() ????????????for?attr,?value?in?zip(attrs,?i): ????????????????setattr(obj,?attr,?value) ????????????obj_list.append(obj) ????????return?obj_list ????def?fetchone(self)?->?Optional[FetchObject]: ????????result?=?self.cursor.fetchone() ????????return?self._tuple_to_object([result])[0]?if?result?else?None ????def?fetchmany(self,?size:?Optional[int]?=?None)?->?Optional[List[FetchObject]]: ????????result?=?self.cursor.fetchmany(size) ????????return?self._tuple_to_object(result)?if?result?else?None ????def?fetchall(self)?->?Optional[List[FetchObject]]: ????????result?=?self.cursor.fetchall() ????????return?self._tuple_to_object(result)?if?result?else?None
運行期類型檢查
Type Hints 之所以叫 Hints 而不是 Check,就是因為它只是一個類型的提示而非真正的檢查。上面演示的 Type Hints 用法,實際上都是 IDE 在幫我們完成類型檢查的功能,但實際上,IDE 的類型檢查并不能決定代碼執行期間是否報錯,僅能在靜態期做到語法檢查提示的功能。
要想實現在代碼執行階段強制對類型進行檢查,則需要我們通過自己編寫代碼或引入第三方庫的形式(如上面介紹的 Pydantic)。下面我通過一個 type_check 函數實現了運行期動態檢查類型,來供你參考:
from?inspect?import?getfullargspec from?functools?import?wraps from?typing?import?get_type_hints def?type_check(fn): ????@wraps(fn) ????def?wrapper(*args,?**kwargs): ????????fn_args?=?getfullargspec(fn)[0] ????????kwargs.update(dict(zip(fn_args,?args))) ????????hints?=?get_type_hints(fn) ????????hints.pop("return",?None) ????????for?name,?type_?in?hints.items(): ????????????if?not?isinstance(kwargs[name],?type_): ????????????????raise?TypeError(f"expected?{type_.__name__},?got?{type(kwargs[name]).__name__}?instead") ????????return?fn(**kwargs) ????return?wrapper #?name:?str?=?"world" name:?int?=?2 @type_check def?greeting(name:?str)?->?str: ????return?str(name) print(greeting(name)) #?>?TypeError:?expected?str,?got?int?instead
只要給 greeting 函數打上 type_check 裝飾器,即可實現運行期類型檢查。
附錄
如果你想繼續深入學習使用 Python Type Hints,以下是一些我推薦的開源項目供你參考:
Pydantic [https://github.com/samuelcolvin/pydantic]
FastAPI [https://github.com/tiangolo/fastapi]
Tornado [https://github.com/tornadoweb/tornado]
Flask [https://github.com/pallets/flask]
Chia-pool [https://github.com/Chia-Network/pool-reference]
MySQLHandler [https://github.com/jianghushinian/python-scripts/blob/main/scripts/mysql_handler_type_hints.py]
|