本文詳細介紹在Python 3.10新加入的四個和類型系統相關的新特性。
PEP 604: New Type Union Operator
在之前的版本想要聲明類型包含多種時,這麼寫:
fromtypingimportUniondefsquare(number:Union[int,float])->Union[int,float]:returnnumber**2
這麼寫其實比較麻煩,每次都要from typing import Union再使用Union[],現在直接可以使用|來表示:
defsquare(number:int|float)->int|float:returnnumber**2
可以說很方便了。事實上這個新的用法也可以用在isinstance和issubclass裡面。之前確認是不是某些類型中的一種這麼寫:
In:isinstance('s',(int,str))Out:TrueIn:isinstance(1,(int,str))Out:True
只要是int或者str都返回True,現在這麼寫就可以:
In:isinstance('s',int|str)Out:TrueIn:isinstance(1,int|str)Out:TruePEP 613: TypeAlias
有時候我們想要自定義一個類型,那麼就創建一個別名(Alias),之前的版本通常這麼寫:
Board=List[Tuple[str,str]]
但是對於類型檢查器(Type Checker)來說,它無法分辨這是一個類型別名,還是普通的賦值。現在引入TypeAlias就很容易分辨了:
fromtypingimportTypeAliasBoard:TypeAlias=List[Tuple[str,str]]#這是一個類型別名Board=2#這是一個模塊常量PEP 647: User-Defined Type Guards
在當代靜態檢查工具(如typescript、mypy等)裡面會有一個叫做【Type narrowing】功能,如其名字,中文叫做【類型收窄】。就是當某個參數類型本來可以符合多個類型,但是在特定的條件里可以讓類型範圍縮小,直接限定到更小範圍的某個(些)類型上。
在mypy官方裡面有多個例子(延伸閱讀鏈接4),這裡拿一個來舉例:
In:defshow_type(obj:int|str):#參數obj是int或者str...:ifisinstance(obj,int):#實現Typenarrowing,mypy確認obj是int...:return'int'...:return'str'#由於前面限定了int,所以這裡mypy會確認obj是str...:In:show_type(1)Out:'int'In:show_type('1')Out:'str'
更準確的了解對象的類型對於mypy是非常友好的,檢查的結論也會更準確。
類型收窄在一些場景下會有問題,再舉個例子:
In:defis_str(obj:object)->bool:...:returnisinstance(obj,str)...:...:...:defto_list(obj:object)->list[str]:...:ifis_str(obj):...:returnlist(obj)...:return[]...:In:defis_str(obj:object)->bool:...:returnisinstance(obj,str)...:...:...:defto_list(obj:object)->list[str]:...:ifis_str(obj):...:returnlist(obj)...:return[]...:In:to_list('aaa')Out:['a','a','a']In:to_list(111)Out:[]
這段代碼比PEP裡面提到的更簡單,它們都是正確的代碼,類型注釋也有問題。但是運行mypy:
➜mypywrong_to_list.pywrong_to_list.py:7:error:Nooverloadvariantof"list"matchesargumenttype"object"wrong_to_list.py:7:note:Possibleoverloadvariants:wrong_to_list.py:7:note:def[_T]list(self)->List[_T]wrong_to_list.py:7:note:def[_T]list(self,Iterable[_T])->List[_T]Found1errorin1file(checked1sourcefile)
分析一下問題。在2個函數中obj由於不確定對象類型,所以用了object,事實上to_list只會對obj為str類型做處理。本來if is_str(obj)會讓類型收窄,但是由於被拆分成函數,isinstance並沒有在這裡成功收窄。
怎麼解決呢?在新版本提供了用戶自定的Type Guards:
fromtypingimportTypeGuarddefis_str(obj:object)->TypeGuard[str]:returnisinstance(obj,str)
本來返回值的類型是bool,現在我們指定成了TypeGuard[str],讓mypy能理解它的類型。其實換個角度,你可以理解為TypeGuard[str]是一個帶着類型聲明的bool的別名,請仔細理解這句話。
現在就好啦:
➜mypyright_to_list.pySuccess:noissuesfoundin1sourcefilePEP 612 – Parameter Specification Variables
Python的類型系統對於Callable的類型(例如函數)的支持很有限,它只能註明這個Callable的類型但是對於函數調用時的參數是無法傳播的。這個問題主要存在於裝飾器用法上,看一下例子:
fromcollections.abcimportCallablefromtypingimportAny,TypeVarR=TypeVar('R')deflog(func:Callable[...,R])->Callable[...,R]:definner(*args:Any,**kwargs:Any)->R:print('In')returnfunc(*args,**kwargs)returninner@logdefjoin(items:list[str]):return','.join(items)print(join(['1','2']))#正確用法print(join([1,2]))#錯誤用法,mypy應該提示類型錯誤
join接收的參數值應該是字符串列表。但是mypy沒有正確驗證最後這個print(join([1, 2]))。因為在log裝飾器中inner函數中args和kwargs的類型都是Any,這造成調用時選用的參數的類型沒有驗證,說白了怎麼寫都可以。在新的版本中可以使用ParamSpec來解決:
fromtypingimportTypeVar,ParamSpecR=TypeVar('R')P=ParamSpec('P')deflog(func:Callable[P,R])->Callable[P,R]:definner(*args:P.args,**kwargs:P.kwargs)->R:print('In')returnfunc(*args,**kwargs)returninner
通過使用typing.ParamSpec,inner的參數類型直接通過P.args和P.kwargs傳遞進來,這樣就到了驗證的目的。現在再檢查就正確了:
➜mypyright_join.pyright_join.py:22:error:Listitem0hasincompatibletype"int";expected"str"right_join.py:22:error:Listitem1hasincompatibletype"int";expected"str"Found2errorsin1file(checked1sourcefile)
typing.ParamSpec幫助我們方便【引用】位置和關鍵字參數,而這個PEP另外一個新增的typing.Concatenate是提供一種添加、刪除或轉換另一個可調用對象的參數的能力。
我能想到比較常見的添加參數是指【注入】類型的裝飾器。比如:
importloggingfromcollections.abcimportCallablefromtypingimportTypeVar,ParamSpec,Concatenatelogging.basicConfig(level=logging.NOTSET)R=TypeVar('R')P=ParamSpec('P')defwith_logger(func:Callable[Concatenate[logging.Logger,P],R])->Callable[P,R]:definner(*args:P.args,**kwargs:P.kwargs)->R:logger=logging.getLogger(func.__name__)returnfunc(logger,*args,**kwargs)returninner@with_loggerdefjoin(logger:logging.Logger,items:list[str]):logger.info('Info')return','.join(items)print(join(['1','2']))print(join([1,2]))
join函數雖然有2個參數,但是由於第一個參數logger是在with_logger裝飾器中【注入】的,所以在使用時只需要傳遞items參數的值即可。
除了添加,刪除和轉換參數也可用Concatenate。再看一個刪除參數的例子:
fromcollections.abcimportCallablefromtypingimportTypeVar,ParamSpec,ConcatenateR=TypeVar('R')P=ParamSpec('P')defremove_first(func:Callable[P,R])->Callable[Concatenate[int,P],R]:definner(a:int,*args:P.args,**kwargs:P.kwargs)->R:returnfunc(*args,**kwargs)returninner@remove_firstdefadd(a:int,b:int):returna+bprint(add(1,2,3))
使用remove_first裝飾器後,傳入的第一個參數會被忽略,所以add(1, 2, 3)其實是在計算add(2, 3)。注意理解這個Concatenate在的位置,如果是新增,那麼Concatenate加在裝飾器參數的Callable的類型聲明里,如果是刪除,加在返回的Callable的類型聲明里。
注意:Concatenate目前只在作為Callable的第一個參數時有效。Concatenate的最後一個參數必須是一個ParamSpec。
延伸閱讀