Python 复杂设计注意事项,在数据清洗的案例中,简单地删除杂散字符可能还不够。当处理地理位置数据时,我们会碰到各种输入格式,包括简单的度数(37.549016197
)、度数和分数(37° 32.94097′
)以及度-分-秒(37° 32′ 56.46′′
)。当然,可能还有更微妙的清洗问题,比如一些设备会用Unicode编码为U+00BA的字符O而不是编码为U+00B0且与之形似的度数字符°
作为输出。
因此,通常需要提供与转换函数绑定的单独清洗函数。该函数会对在格式上与经纬度格式相差较大的输入数据进行更复杂的转换处理。
那么该如何实现呢?对此有多种选择,例如简单的高阶函数,然而装饰器的效果却不太好。下面展示一个基于装饰器的设计,来说明装饰器的局限性。
这里要求有两个正交设计的考量,如下所示:
- 输出转换(
int
、float
和Decimal
); - 输入清洗(清除杂散字符,重新格式化坐标)。
理想情况下,其中一个可以是被封装的基本函数,另一个则以封装器的形式被引入。很难说清楚该选择哪个作为基本函数,哪个作为封装器,原因之一是上述示例比单一的两层复合要复杂一些。
考虑到上述示例,似乎应该将其看作一个三层复合函数:
- 输出转换(
int
、float
和Decimal
); - 输入清洗——简单替换或更复杂的多字符替换;
- 函数首先尝试转换,并执行清洗来应对异常,然后再次尝试转换。
这里执行尝试转换与重试的第三部分,才是真正意义上的封装器,同时也是复合函数的一部分。正如之前提到的,一个封装器包含了一个参数阶段和一个可以返回值的值,分别称为 w_{\alpha} 和 w_{\beta}。
下面用此封装器基于两个额外函数创建一个复合函数。设计上有两种选择,一种是将清洗函数作为转换装饰器的参数,如下所示:
@cleanse_before(cleanser)
def conversion(text):
something
这种设计声明了转换函数是其核心,而清洗函数作为辅助细节实现,它会修改程序行为,但会保留转换函数的原始意图。
也可以将转换函数作为清洗函数装饰器的一个参数,如下所示:
@then_convert(converter)
def cleanse(text):
something
第二种设计表明了清洗函数是其核心,而转换函数作为辅助细节实现。这有点令人困惑,因为清洗函数的类型通常是Callable[[str], str]
,而转换函数的类型Callable[[str], some other type]
是整个被封装函数所需的类型。
尽管这两种方法都可以创建出可用的复合函数,但第一种方法有个重要的优势,即conversion()
函数的类型签名同时也是生成的复合函数的类型签名。这凸显了装饰器的一个通用设计模式,即被装饰的函数其类型最容易保留。
当面临定义复合函数的不同选择时,保留被装饰函数的类型提示是很重要的一点。
因此,我们首选@cleanse_before(cleaner)
风格的装饰器。该装饰器形式如下:
def cleanse_before(
cleanse_function: Callable
) -> Callable[[F], F]:
def abstract_decorator(converter: F) -> F:
@wraps(converter)
def cc_wrapper(text: str, *args, **kw) -> Any:
try:
return converter(text, *args, **kw)
except (ValueError, decimal.InvalidOperation):
cleaned = cleanse_function(text)
return converter(cleaned, *args, **kw)
return cast(F, cc_wrapper)
return abstract_decorator
上面定义了一个三层装饰器,其核心是使用了converter()
函数的cc_wrapper()
函数。如果该操作失败,那么它会使用给定的cleanse_function()
函数并再次尝试使用converter()
函数。具体的装饰器函数abstract_decorator()
在cleanse_function()
函数和converter()
函数之外构建了一个cc_wrapper()
函数。该具体装饰器以cleanse_function()
函数作为自由变量,并由装饰器接口cleanse_before()
创建,后者由cleanse_function()
函数定制。
其中的类型提示强调了@cleanse_before
装饰器的作用。它会接收某个Callable
函数,即这里的cleanse_function
,并创建一个函数Callable[[F], F]
,该函数会把一个函数转换为一个被封装的函数。此过程有助于我们理解参数化装饰器的工作机制。
接下来构建一个稍灵活的清洗和转换函数to_int()
,如下所示:
def drop_punct2(text: str) -> str:
return text.replace(",", "").replace("$", "")
@cleanse_before(drop_punct)
def to_int(text: str, base: int = 10) -> int:
return int(text, base)
清洗函数装饰了整数转换函数。本例中,清洗函数删除了$
和,
字符。该清理函数封装了整数转换函数。
上面定义的to_int()
函数利用了内置的int()
函数。为了避免使用def
语句,可如下定义:
to_int2 = cleanse_before(drop_punct)(int)
这里使用了drop_punct()
函数封装内置的int()
转换函数。借助mypy工具中的reveal_type()
函数,可以看到to_int()
函数的类型签名与内置int()
函数的类型签名是相匹配的。
如下所示使用改进后的整数转换函数:
>>> to_int("1,701")
1701
>>> to_int("97")
97
对于被装饰的函数to_int()
来说,底层int()
函数的类型提示已经重写(和简化)了。这就是试图使用装饰器封装内置函数的一个结果。
复杂的参数化装饰器让设计如履薄冰。装饰器模型似乎不太适合这种设计,而复合函数的定义似乎比构建装饰器所需的机制更清晰。
通常,当我们想在给定函数(或者类)中加入一些相对简单和固定的内容时,装饰器很适用。当这些额外的内容对于应用程序代码的含义不重要,而被视作基础结构或作为支撑时,装饰器也是很重要的。
当涉及多个正交设计方面时,我们可能希望得到一个可调用的类定义,它具有各种插件的策略对象。这个类的定义可能比功能相同的装饰器更简单。替代装饰器的另一个方法是创建高阶函数。在某些情况下,具有各种参数组合的偏函数会比装饰器更简单。
横切关注点的典型示例包括日志记录和安全测试。可以将这些特性看作不特定于某个问题领域的后台处理。当我们能自如地使用装饰器时,它才能算是好用的设计技术。