Python-类型注释、functools模块

1 Python类型注解

1.1 函数定义的弊端

1. Python是动态语言,变量随时可以被赋值,且能赋值为不同的类型;
2. Python不是静态编译型语言,变量类型是在运行期决定的;
3. 动态语言很灵活,但是这种特性也是弊端;

def add(x, y):
    return x + y

print(add(4, 5))#计算4+5
print(add('hello', 'world'))#字符串拼接
add(4, 'hello') #错误,不同类型不能+

通过上面示例发现:
1. 难发现:由于不做任何类型检查,直到运行期问题才显现出来,或者线上运行时才能暴露出问题;
2. 难使用:函数的使用者看到函数的时候,并不知道你的函数的设计,并不知道应该传入什么类型的数据;

如何解决这种动态语言定义的弊端呢?
1. 增加文档(Documentation String);
2. 这只是一个惯例,不是强制标准,不能要求程序员一定为函数提供说明文档;
3. 有缺陷:函数定义更新了,文档未必同步更新;

1.2 函数文档和注释

如何加文档:函数内直接使用引号书写,或者使用Pycharm,在函数定义下输入三引号后回车,自动提示文档注释:

def add(x, y):#下面的名字是Pycharm自动添加的,方便了文档编写
    """
    
    :param x: #这里填写对参数x的说明
    :param y: #这里填写对参数y的说明
    :return: #这里填写对返回值的说明
    """
    return x + y
print(help(add))#使用help就可以查看文档注释

虽然文档帮助理解函数的功能,但并不适合参数类型的检查,Python对此引用了函数注释(Function Annotations):
1. 对函数的参数、返回值进行类型注解;
2. 只对函数参数做的一个辅助说明,并不对函数参数进行类型检查;
3. 提供第三方工具,做代码分析,发现隐藏的bug;
4. 函数注解的信息,保存在__annotations__属性中;
!此外Python也可以使用变量注解,变量注解在3.6版本中引入;函数注解在3.5版本引入。

#函数注释(3.5之后才支持):
def add(x:int, y:int):#对x和y注释,它们为int类型
    return x + y
#变量注释(3.6之后才支持):
i:list = [] 

注意:所有注释都不会影响程序执行,它只是提示功能;
在Pycharm中设置了变量类型注释后,. 功能会自动匹配它类型的操作,非常实用;

1.3 inspect模块

inspect模块主要提供以下功能:
1. 对是否是模块,框架,函数等进行类型检查;
2. 获取源码;
3. 提供获取对象的函数,可以检查函数和类、类型;
4. 解析堆栈;

inspect模块中有很多is函数:
1. inspect.isfunction(object):是否是函数;
2. inspect.ismethod(object)):是否是类的方法;
3. inspect.isgenerator(object):是否是生成器对象;
4. inspect.isgeneratorfunction(object):是否是生成器函数;
5. inspect.isclass(object):是否是类;
6. inspect.ismodule(object):是否是模块;
7. inspect.isbuiltin(object):是否是内建对象;
8. inspect.isgeneratorfunction(object):是否是生成器函数;
9. inspect.isgenerator(object):是否是生成器;
其他is函数,请在需要的时候查阅inspect模块帮助。

最常用的就是第3种功能,如需求:需要对函数参数类型检查?
思路:
1. 函数参数的检查,一定是在函数外;
2. 函数应该作为参数,擦混入到检查函数中;
3. 检查函数拿到函数传入的实际参数,与形参声明对比;
4. __annotations__属性是一个字典,其中包括返回值类型的声明。假设要做位置参数的判断,无法和字典中的声明对应。可以使用inspect模块;

inspect.signature(callable, *, follow_wrapped=True):获取签名;函数签名包含了一个函数的信息:函数名、它的参数类型、它所在的类和名称空间及其他信息。

import inspect

def add(x:int, y:int, *args, **kwargs) -> int:#只对x、y和返回值进行类型注释
    return x + y

sig = inspect.signature(add)#获取add函数签名

print(sig)
#查看sig:
#(x:int, y:int, *args, **kwargs) -> int
print(type(sig))
#查看sig的类型:
#<class 'inspect.Signature'> 
print(sig.return_annotation)
#查看返回值注释:
#<class 'int'>
print(sig.parameters)
#parameters:参数名称到相应参数对象的有序映射;
#查看签名参数,是个有序字典(OrderedDict):
#OrderedDict([('x', <Parameter "x:int">), ('y', <Parameter "y:int">), ('args', <Parameter "*args">), ('n', <Parameter "n">), ('kwargs', <Parameter "**kwargs">)])
print(sig.parameters['y'],type(sig.parameters['y']),sig.parameters['y'].annotation)
#查看参数中的y、签名参数中y的类型、y的注解:
#y:int <class 'inspect.Parameter'> <class 'int'>
print(sig.parameters['args'],sig.parameters['args'].annotation)
#查看参数中args、args的注解:
#*args <class 'inspect._empty'>

上面示例中y有注释,返回<class 'int'>,而*args没有注释类型,返回<class 'inspect._empty'>
注意:可变类型不需要写注解,它们默认把数据包装成固定类型,*args是元组,**kwargs是字典。

Parameter对象(保存在元组中,是只读的):
1. name:参数的名字;
2. annotation:参数的注解,可能没有定义;
3. default:参数的缺省值,可能没有定义;
4. empty:特殊的类,用来标记default属性或者注释annotation属性的空值;
5. kind:实参如何绑定到形参,就是形参的类型;
  5.1 POSITIONAL_ONLY,值必须是位置参数提供;
  5.2 POSITIONAL_OR_KEYWORD,值可以作为关键字或者位置参数提供;
  5.3 VAR_POSITIONAL,可变位置参数,对应*args
  5.4 KEYWORD_ONLY, keyword-only参数,对应*或者*args之后的出现的非可变关键字参数;
  5.5 VAR_KEYWORD,可变关键字参数,对应**kwargs

inspect模块中使用的属性具体关系:

使用add函数,检查用户输入是否符合参数注解的要求?
思路:
1. 调用时,判断用户输入的实参是否符合要求;
2. 调用时,用户感觉上还是在调用add函数;
3. 对用户输入的数据和声明的类型进行对比,如果不符合,提示用户;

先使用简单的方法做一下,可预先写成柯里化形式,方便后面做成装饰器,熟练些可以直接写成装饰器:

import inspect

def check(fn):
    def wrapper(*args, **kwargs):
        #完成参数检查
        sig = inspect.signature(fn)
        params= sig.parameters#有序字典 {变量名:参数对象}
        values = list(params.values())
        for i,x in enumerate(args):#处理*args接收的参数
            #*args形成元组,要从元组中取出值和其对应params字典中的类型判断
            if isinstance(x,values[i].annotation):
                print(x,"correct!")
            else:
                print(x,"error!")
        for k,v in kwargs.items():#处理**kwargs接收的参数
            #**kwargs形成字典,只需key就可以匹配
            if isinstance(v,params[k].annotation):
                print(v, "correct!")
            else:
                print(v, "error!")
        return fn(*args, **kwargs)
    return wrapper

@check
def add(x:int, y:int) -> int:#这里不考虑没变量注释的情况
    return x + y

add(4,5)
#4 correct!
#5 correct!
add('b','a')
#b error!
#a error!
add(4,y=5)
#4 correct!
#5 correct!
add(x='b',y='a')
#b error!
#a error!

上面示例使用isinstance类型判断完成,因为涉及到元组和字典匹配,所以先将字典的value转化成list类型再用下标去遍历即可。注意这里使用的是wrapper的签名。
上面示例有些缺陷,如果不写变量注释呢?应该不进行类型比较了,那么在上面示例基础上改进一下,顺便加上装饰器使签名和fn的一致,wrapper只判断是否错误即可:

import functools
#核心部分优化:
def check(fn):
    @functools.wraps(fn)#加上属性装饰
    def wrapper(*args, **kwargs):
        #完成参数检查
        sig = inspect.signature(fn)
        params= sig.parameters#有序字典 {变量名:参数对象}
        values = list(params.values())
        for i,x in enumerate(args):#处理*args接收的参数
            #参数注释不为空且类型不正确才报错
            if values[i].annotation != inspect._empty and not isinstance(x,values[i].annotation):
                print(x,"error!")
                #raise ValueError('Param Error')
        for k,v in kwargs.items():#处理**kwargs接收的参数
            #参数注释不为空且类型不正确才报错
            if params[k].annotation != params[k].empty and not isinstance(v,params[k].annotation):
                print(v, "error!")
                #raise ValueError('Param Error')
        return fn(*args, **kwargs)
    return wrapper

inspect._empty、params[k].empty、inspect.Parameter.empty三者等价;

2 functools模块

在装饰器那篇文章中已经使用过functools模块中的wraps函数,这次再讲几个比较常用的函数并解释下其中的原理:

2.1 functools.reduce()

1. functools.reduce(function, iterable[, initializer]) -> value
  1.1 使用函数方法累计,上一次的累计值作为下一次的参数,直到只剩一个值;
  1.2 有初始值时,第一个值为初始值,然后再从iterable中取值;
  1.3 无初始值时,iterable不能为空,第一个值从iterable中取;
下面是Python 3.6.6 Manuals给出的解释:

"Apply function of two arguments cumulatively to the items of sequence, from left to right, so as to reduce the sequence to a single value. For example, reduce(lambda x, y: x+y, [1, 2, 3, 4, 5]) calculates ((((1+2)+3)+4)+5). The left argument, x, is the accumulated value and the right argument, y, is the update value from the sequence. If the optional initializer is present, it is placed before the items of the sequence in the calculation, and serves as a default when the sequence is empty. If initializer is not given and sequence contains only one item, the first item is returned."

"序列传给函数2个参数进行计算,计算方向是从左到右,直到序列只剩下一个值。例如:reduce(lambda x, y: x+y, [1, 2, 3, 4, 5]) 等价于计算: ((((1+2)+3)+4)+5)。左边参数x可以是累计值和从序列从取出的值,右边参数y是从序列中取出的值。如果可选项初始值存在,则将它位于从序列取值之前,并在序列为空时用作默认值。"

这方便了一些功能的实现,比如累加求和、求阶乘:

#累加求和:
functools.reduce(lambda x,y:x + y,range(1,5))#10
#阶乘:
functools.reduce(lambda x,y:x * y,range(1,5))#24

2.2 functools.partial()

2. functools.partial(func, *args, **keywords)
  2.1 偏函数,把函数部分的参数固定下来,相当于为部分的参数添加了一个固定的缺省值,形成一个新的函数并返回;
  2.2 partial生成的新函数,是对原函数的封装;
  2.3 相当于f(x0,x1,x2...)->f'(x2..)这里x0,x1变成有参数的被固定下来,注意f'是对f的包装,f'是内部包括了f而生成的新的函数;

有兴趣的可以观看Manuals文档,这里不再介绍,接下来看下partial的原理函数:

def partial(func, *args, **keywords):
    def newfunc(*fargs, **fkeywords):
        newkeywords = keywords.copy()#newkeywords是对旧函数的keywords拷贝
        newkeywords.update(fkeywords)
        #keywords是partial传的,是更新旧函数的"缺省值"
        #fkeywords是新函数调用时传的关键字参数
        #newkeywords和fkeywords都是字典,使用update更新,也就是有相同的key会被覆盖掉,新的覆盖旧的
        return func(*args, *fargs, **newkeywords)
        #func的*args和newfunc的*fargs都存下来(所以位置传参一次就固定了)
        #**keywords和**fkeywords组成没有重复key的**newkeywords(keyword-only就可以多次赋值)
    newfunc.func = func#保留旧函数名
    newfunc.args = args#保留旧函数args参数
    newfunc.keywords = keywords#保留旧函数旧函数kwwords参数
    return newfunc#newfunc记住了func函数的所有属性,是其复制品,以后的操作都对这个复制品进行

使用add函数:

def add(x,y=4):
    return x + y

newadd = functools.partial(add,y=5)#返回的新函数本质是newfunc 
newadd(1,y=6)#相当于newfunc(1,6)
#执行过程中相当于y=5被y=6覆盖掉了

总结下partial:

import functools
import inspect

def add(x,y=4,*args):
    return x + y

newadd = functools.partial(add,x=3,y=5)#关键字传参不是固定,而是缺省值,可以重新赋值但只能是keyword-only
#signature:(*, x=3, y=5)
newadd = functools.partial(add,1,6)#位置传参是固定,不能再给x、y赋值了,只有*args才能接收新值;
#同理**kwargs也能接收新keyword-only传参,这里没有添加
#signature:(*args)
print(inspect.signature(newadd))#上面newadd单个执行后看签名

分析functools.warps()的实现:
在前面装饰器讲解中我们使用过,它是用来拷贝函数签名信息的装饰器,但它内部是使用了偏函数实现的,把需要的函数都放在一起解读:

#partial
def partial(func, *args, **keywords):#3.(func=update_wrapper)->newfunc
    def newfunc(*fargs, **fkeywords):
        newkeywords = keywords.copy()
        newkeywords.update(fkeywords)
        return func(*args, *fargs, **newkeywords)
    newfunc.func = func
    newfunc.args = args
    newfunc.keywords = keywords
    return newfunc
#wraps
        #1.wraps(fn)(wrapper)->5.newfunc(wrapper)
def wraps(wrapped,
          assigned = WRAPPER_ASSIGNMENTS,
          updated = WRAPPER_UPDATES):
                    #4.update_wrapper->newfunc newfunc相当于固定参数后的update_wrapper
    return partial(update_wrapper, wrapped=wrapped,
                   assigned=assigned, updated=updated)
            #2.把update_wrapper的4参固定3个,只剩下wrapper
            #2.update_wrapper是做属性包装的
#update_wrapper
def update_wrapper(wrapper,
                   wrapped,
                   assigned = WRAPPER_ASSIGNMENTS,
                   updated = WRAPPER_UPDATES):
    
                    #属性包装,略
        
    return wrapper
#外部调用
def logger(fn):
    #6.functools.wraps(fn)==functools.wraps(fn)(wrapper)->newfunc(wrapper) 用newfunc对wrapper属性包装
    @functools.wraps(fn)
    def wrapper():
        ...

2.3 functools.lru_cache()

3. @functools.lru_cache(maxsize=128, typed=False)
  3.1 Least-recently-used装饰器。lru:最近最少使用,cache:缓存;
  3.1 如果maxsize设置为None,则禁用LRU功能,并且缓存可以”无限制“增长。当maxsize是二的幂时,LRU功能执行得最好;
  3.1 如果typed设置为True,则不同类型的函数参数将单独缓存。例如:f(3)和f(3.0)将被视为具有不同结果的不同调用;

import time

@functools.lru_cache()#对add函数缓存 add = functools.lru_cache()(add)
def add(x,y):
    time.sleep(2)#增加时间测试用
    return x + y
#试试执行2次的效果,哪些是从缓存中取?    
print(add(4,5))
print(add(4.0,5.0))#如果typed为True呢?
print(add(4,y=5))
print(add(x=4,y=5))
print(add(y=4,x=5))

通过上面示例,大致了解了这个函数功能是把计算过的情况缓存,等下次遇到相同情况就可以直接从缓存中取出使用。
上面示例中print函数都应该属于同一种情况,但只有前两个被判断是相同的,也就是说它判断几乎一模一样才从缓存取值。
typed设置为True时,唯一被认为是相同的add(4.0,5.0)也被判断为不同了,真正的是一模一样才从缓存中取值。

它判断哪些情况是属于同一种呢?这里我们只关心key是如何判断的,阅读下部分原码:

def lru_cache(maxsize=128, typed=False):
   
    def decorating_function(user_function):#2.调用_lru_cache_wrapper函数
        wrapper = _lru_cache_wrapper(user_function, maxsize, typed, _CacheInfo)
        return update_wrapper(wrapper, user_function)

    return decorating_function#1.返回的是内部函数名

#已略去部分原码
def _lru_cache_wrapper(user_function, maxsize, typed, _CacheInfo):
    
    sentinel = object()          
    make_key = _make_key         # 3.用_make_key函数做key判断
    PREV, NEXT, KEY, RESULT = 0, 1, 2, 3  

    cache = {}#4.用字典缓存

def _make_key(args, kwds, typed,
             kwd_mark = (object(),),
             fasttypes = {int, str, frozenset, type(None)},
             tuple=tuple, type=type, len=len):#5.假设args:(4,),kwds:{'y':5}

    key = args#6.位置传参给key,key==(4,)
    if kwds:#7.关键字传参不为空进入
        key += kwd_mark#8.在位置传参后加上一个分界符,key==(4,object())
        for item in kwds.items(): 
            key += item  #9.key==(4,object(),'y',5) 这里有个小问题,如果value是不可hash的,那么最终放到cache中将失效,因为cache是字典
    if typed:
        key += tuple(type(v) for v in args)#10.尾部追加args参数类型,key==(4,object(),'y',5,int)
        if kwds:
            key += tuple(type(v) for v in kwds.values())#11.尾部追加kwds参数类型,key==(4,object(),'y',5,int,int)
    elif len(key) == 1 and type(key[0]) in fasttypes:
        return key[0]
    return _HashedSeq(key)#12.返回的key用可hash列表包裹,变成[4,object(),'y',5,int,int]

理解了上面代码执行过程,测试一下具体key会形成什么:

functools._make_key((4,5),{},True)
#[4, 5, int, int]
functools._make_key((4,),{'y':5},True)
#[4, <object at 0x202c3feb0b0>, 'y', 5, int, int]
functools._make_key((),{'x':4,'y':5},True)
#[<object at 0x202c3feb0b0>, 'x', 4, 'y', 5, int, int]

通过上面示例,发现生成的key都不一样,所以当然就不能当成一种情况从缓存取值了。
还记得递归写的菲波那切数列数列吗?每次计算都是很耗时的,因为它并不缓存上次的结果,所以每次执行都是重新计算,这次使用lru_cache来解决这个问题:

import functools

@functools.lru_cache()
def fib():
    return 1 if n < 3 else fib(n-1) + fib(n-2)

这次计算大的值就会很快了,因为缓存了上次执行结果。

使用前提:
1. 同样的函数参数一定得到同样的结果;
2. 函数执行时间很长,且要多次执行;
其本质是函数调用的参数=>返回值
缺点:
1. 不支持缓存过期,key无法过期、失效;
2. 不支持清除操作;
3. 不支持分布式,是一个单机的缓存;
适用场景:单机上需要空间换时间的地方,可以用缓存来将计算变成快速的查询

猜你喜欢

转载自blog.csdn.net/u013340216/article/details/86664462