元编程在项目中的应用

掐指一算,进行 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()

但是你是否思考过:为什么我们在类的定义中,field1field2的属性明明是TextField对象,但是我们在后面修改数据时,却可以直接赋值?

这就是用到了元编程。事实上,在我们这个类定义时,执行了类的 __new__() 静态方法,在这个方法中,就直接将对应的属性给替换了。__new__ 就是元编程的一种表象。流程如下:

  1. 收集该对象的所有属性值,逐一判断是否为 Field 的子类(如TextFieldIntegerField等)
  2. 如果是子类,则将这个属性值保存到 _raw_fields 字典里面,形如 {"field1": TextField()}
  3. 遍历 _raw_fields,逐个获取默认值等预处理,并更新到 __dict__,实现属性值的替换,比如 {"field1": "default_value1", "field2": None}

这样,就可以直接通过 data.field1 实现属性值的获取了,而不是获取到一个 TextField 实例。

要编辑这些属性值,然后存储到数据库,也很简单。比如 save() 函数执行流程如下:

  1. 遍历 _raw_fields,并从当前实例拿到对应的属性值
  2. 根据 _raw_fields 中各 Field 的实例,对属性值进行处理,得到验证后的数据
  3. 将这些数据拼接成 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

发表评论