掐指一算,进行 System Framework 开发工作已经持续了快两年了,也算是对框架有了一些自己的感悟。框架要做的核心事情就是尽量减轻开发者的工作。怎么去减轻开发者的工作呢?可以从开发、测试和维护两个方面解读。开发方面就是尽可能提高代码复用程度,无需重复造轮子;测试方面就是尽可能提高代码的可测试性,排除无关因素快速定位问题;维护方面就是提高代码的可读性、架构的可理解性,大家能很快地理解整个工程。
而要实现这三个方面的提升,在框架设计的过程中,各种语言特性、设计模式往往无所不用其极,以最简洁的方式提供框架的功能,来尽可能给开发者带来更多的收益。而元编程技术的应用,就是其中重要的一方面。
什么是元编程
所谓元编程(Meta Programming),在各大论坛上讨论的还是比较少的,而且也往往各执一词。有的说元编程是一种代码生成代码的技术,只有像 bash、python、ruby 这种脚本类型的解释型语言才能使用。有的则覆盖比较全面,以下几种都叫元编程[1]:
- 可以动态生成代码
- 可以动态改变代码本身的逻辑
- 能轻易实现反射
- 能实现特定领域语言(DSL)
我比较赞同后者的观点,在我看来,前者的观点只是后者“可以动态生成代码”的狭义理解。我认为“可以动态生成代码”不仅仅是生成可解释、可执行的代码,让解释器去重新解释执行。在编译型语言中,根据需要选择性地去激活特定区域的代码,也是一种元编程。比如 C/C++ 中,通过数据表驱动执行对应指针指向的函数,是一种元编程;Rust中,通过宏来批量生成代码,也可以视作一种元编程。而对于 Python,Java 这种,通过装饰器去改变被装饰对象的功能或行为,也都可以视为一种元编程。
推广到更大范围,我认为元编程是面向对象思想的一种体现。只不过在这里,认为代码(或更加广义地,逻辑)也是一种对象,也可以进行继承、组合等操作。
案例一:配置一个类
在项目中,我使用了一个类来包装一个任务,用于标记该任务的各种内容。这个是一个自治的多实例分布式任务。因此我们需要在任务中标记队列长度、系列任务的名称、报告时间。每个系列的任务都将共享一个任务队列,排队运行。如果有任务长时间未汇报,一旦超过ttl,则视为失败。同时每个任务需要有自己的任务UUID和相关信息。
很显然,在这个类中,既有系列任务的配置,又有每个任务自己的配置。如果我们将其放在一起管理,会显得有点乱,每次初始化任务,都需要手动将这些值全部赋一次:
或者,可以通过多态的设计理念,首先继承出一个 MyAdtSeries
,进行一些基础的配置,再继承一个 MyAdt
:
但这就显得太冗余了。有没有更加方便的方式呢?装饰器派上用场了。以python为例,可以设计这样一个wrapper:
def adt_configure(name, queue_len, report_intv, ttl):
def decorator(cls):
setattr(cls, 'queue_length', queue_len)
...
return decorator
这样,就可以直接通过这个装饰器来配置这个类了:
@adt_configure('my', 5, 30, 60)
class MyAdt(AutonomousDistributedTask):
def runnable(self):
...
从代码生成的角度来看,似乎python帮我们生成了MyAdt
类的配置代码,自动完成了相关配置。但实际上也是如此,python在定义这个class时,就自动执行了装饰器内的代码,将相关配置填入了另一块内存空间。有点像自动工厂一样。自然无需在运行时在__init__
中动态配置,提高了执行效率。
当然了,你也可以通过Metaclass实现,看你喜欢哪种,我是比较喜欢装饰器的形式 : )
class MyAdtMeta:
queue_length = 1
series_name = 'my'
report_interval = 30
ttl = 60
class MyAdt(MyAdtMeta, AutonomousDistributedTask):
def runnable(self):
...
案例二:实现ORM
ORM (Object Relational Mapping),即对象—关系映射,以一个类对应一个表或一行数据的关系,通过对对象的操作即可实现对表的操作,可以大大提升开发效率。
我们以django的ORM为例,我们通常通过以下方式操作数据表:
class MyData(Model):
field1 = TextField()
field2 = TextField()
# 查
MyData.objects.filter(field1=value1, field2=value2)
# 增
MyData(field1=value1, field2=value2).save()
# 改
data = MyData.objects.get(id=123)
data.field1 = value1
data.field2 = value2
data.save()
# 删
data.delete()
但是你是否思考过:为什么我们在类的定义中,field1
,field2
的属性明明是TextField
对象,但是我们在后面修改数据时,却可以直接赋值?
这就是用到了元编程。事实上,在我们这个类定义时,执行了类的 __new__()
静态方法,在这个方法中,就直接将对应的属性给替换了。__new__
就是元编程的一种表象。流程如下:
- 收集该对象的所有属性值,逐一判断是否为
Field
的子类(如TextField
,IntegerField
等) - 如果是子类,则将这个属性值保存到
_raw_fields
字典里面,形如{"field1": TextField()}
- 遍历
_raw_fields
,逐个获取默认值等预处理,并更新到__dict__
,实现属性值的替换,比如{"field1": "default_value1", "field2": None}
这样,就可以直接通过 data.field1
实现属性值的获取了,而不是获取到一个 TextField
实例。
要编辑这些属性值,然后存储到数据库,也很简单。比如 save()
函数执行流程如下:
- 遍历
_raw_fields
,并从当前实例拿到对应的属性值 - 根据 _
raw_fields
中各Field
的实例,对属性值进行处理,得到验证后的数据 - 将这些数据拼接成 SQL 语句等,发送给数据库,实现存储。
这里就用到了许多元编程的思想,我们一个一个来梳理:
第一,开发者填入的 TextField
会在编译时被替换。开发者填入的属性为一个 Field
实例,但在编译/类定义时被自动收集和替换,悄悄改变了这个属性的用途,以方便后续使用。
第二,要实现这些功能,需要大量使用反射机制,这也是元编程,大量使用 getattr
, setattr
等方法来通过字符串对类属性的映射。
关于 ORM 的设计理念,我后续还会编写一系列文章来说明。
案例三:在测试中实现注入
在单元测试中,我们有时候需要做一些跨平台测试。比如我们基于 psutil
库编写了一些上层应用,但是不同平台的网卡不同:比如windows下,loopback网卡ipv6的掩码是None
,而linux下是 ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
。这样差异性就来了。如何保证自研代码在不同平台下代码的通用性,同时节约测试成本,在一个平台下就能跑通所有测试?
首先我们在设计上就需要有可测试性(DFT, Design For Testing)的考量,尽量将这种不同平台表现不同的内容分隔开来:
def get_psutil_netif_addrs():
return psutil.net_if_addrs()
def my_code():
...
addrs = get_psutil_netif_addrs()
...
然后在测试时,我们就可以将这个函数给替换了,进而实现模拟不同平台的数据来测试:
def set_monkey_function(obj, attr, value):
def monkey(*args, **kwargs):
return data
def test_my_code():
# 替换 get_psutil_netif_addrs 函数,使之返回 windows_if_data
set_monkey(mylib, "get_psutil_netif_addrs", windows_if_data)
ret = my_code()
assert ret == xxx
# 替换 get_psutil_netif_addrs 函数,使之返回 linux_if_data
set_monkey(mylib, "get_psutil_netif_addrs", linux_if_data)
ret = my_code()
assert ret == xxx
幸运的是,这是一个真实的例子。多亏了这种跨平台测试的实现,我才能发现 psutil
库在不同平台下的返回不同,进而避免了上线问题的发生。
这种反射+替换的方式,不正也是元编程的理念?元编程思想之强大,正在于它的灵活性。我们再举一个例子。
案例四:Hook以方便调试
在编写比较大的项目时,尤其是接收别人的项目时,随着继承和组合的复杂度的增加,以及多线程的使用,一些依赖关系会比较复杂,有时可能需要迫切知道某个类的属性到底是在哪里被改了。此时我们就可以通过元编程的方式来注入hook,以方便调试。
python中有个非常好用的装饰器 @property
。它装饰的函数,可以以属性的方式调用:
class MyClass:
@property
def value(self):
return self._inner_value
@value.setter
def value(self, val)
breakpoint()
self._inner_value = val
def outer_code():
c = MyClass()
c.value = 123
print(c.value)
因此如果想追溯类里面某个属性的修改,就可以使用类似的方式,将 value
包裹起来。一旦对 value
进行了属性的设置,就会触发断点,然后再pdb控制台中打印堆栈信息就可以了。
当然了,这种调试方式对代码会有侵入性修改。还可以以非侵入性的方式进行修改:
# my_class.py
class MyClass:
def __init__(self):
self.data = 123
def my_code():
c = MyClass()
c.data = 456
# test.py
import my_class
class AttrTracingMeta:
def __getattr__(self, name):
if name in ["data"]:
print(f'Access to name: {name}')
return super().__getattribute__(name)
def __setattr__(self, name, value):
if name in ["data"]:
print(f'Modify value: {name} = {value}')
return super().__setattr__(name, value)
# 注入上面的meta到已有类中,形成一个新的类
class MyClassInjected(AttrTracingMeta, my_class.MyClass):
pass
def test_data():
# 注入这个新的类到 my_class 模块中
setattr(my_class, 'MyClass', MyClassInjected)
# 运行方法
my_class.my_code()
运行结果如下,可以看到注入成功生效了:
Modify value: data = 123
Modify value: data = 456
结语(找AI写的)
通过上述几个案例,我们可以看到元编程在实际项目中的广泛应用。无论是通过装饰器简化类的配置,还是通过元编程实现ORM,亦或是利用元编程技术进行跨平台测试和调试,都能显著提升开发效率,减少代码冗余,提高代码的可读性和可维护性。
元编程的核心在于将代码本身视为一种数据,通过编程手段对这些数据进行操作。这不仅要求开发者具备扎实的语言基础,还需要对设计模式、软件架构有深入的理解。掌握元编程技术,能够帮助开发者更好地应对复杂的项目需求,提高代码质量,降低维护成本。
然而,元编程并非万能钥匙。不当使用元编程可能会导致代码难以理解和维护,甚至引入难以追踪的错误。因此,在实际应用中,应遵循“适度原则”,合理评估元编程的适用场景,确保其带来的便利大于潜在的风险。
总之,元编程是一种强大的工具,能够在多个层面提升软件开发的效率和质量。希望本文能够帮助读者更好地理解和应用元编程技术,从而在项目开发中取得更好的成果。
参考
[1] What Is Metaprogramming & How It Works? – Kacper Rafalski – netguru
https://www.netguru.com/blog/metaprogramming
[2] Chain of Responsibility – Refactoring Guru
https://refactoring.guru/design-patterns/chain-of-responsibility