在 Python 中实施单一职责原则

2025-06-10

在 Python 中实施单一职责原则

最初发表在我的博客https://sobolevn.me/2019/03/enforcing-srp

单一职责原则(SRP)是软件开发中最重要的概念之一。该概念的核心思想是:所有软件部分必须只有一个职责。

为什么 SRP 如此重要?它是软件开发背后的核心思想。将复杂的任务分解成一系列简单的构建块,然后再将它们组合成复杂的软件。就像我们可以组合乐高积木或内置函数一样:

print(int(input('Input number: ')))

本文将指导您完成编写简单代码的复杂过程。我个人认为,如果您没有扎实的编程背景python,本文会比较复杂且难以理解,因此本文分为以下几个部分:

  1. 简单构建块的定义
  2. 函数组合存在的问题python
  3. 介绍可调用对象来解决函数组合问题
  4. 依赖注入减少了可调用对象的样板代码

每写完一篇,可以停下来修改一下,然后再写到最后一篇,因为这样可以确保你理解要点,或者至少初次校对已经证明了这一点。
如有修改建议或疑问,请随时提出。这个主题目前还没有深入探讨,我很乐意为您解答。

定义构建块

让我们首先定义一下我所说的“软件”和“最简单的构建块”是什么?

最简单的构建块通常是语言的表达式和语句。我们几乎可以用它们来构建一切。但是,由于它们太简单,我们不能完全依赖它们。而且我们也不想到处都是重复的代码。因此,我们发明了函数,将这些最简单的语言结构抽象成更有意​​义、更易于实际使用的东西。

我们期望这些最简单的构建块(也就是“函数”)是可组合的。为了易于组合,它们必须遵循单一职责原则。否则,我们就会遇到麻烦。因为当你只需要其中一部分功能时,你无法组合出同时执行多项功能的功能。

函数也可能很复杂

现在,让我们确保我们确实可以依赖函数作为简单的构建块。

我们可能已经知道函数也会变得复杂,我们都见过像这样的绝对难以阅读的函数:

def create_objects(name, data, send=False, code=None):
    data = [r for r in data if r[0] and r[1]]
    keys = ['{}:{}'.format(*r) for r in data]

    existing_objects = dict(Object.objects.filter(
        name=name, key__in=keys).values_list('key', 'uid'))

    with transaction.commit_on_success():
        for (pid, w, uid), key in izip(data, keys):
            if key not in existing_objects:
                try:
                    if pid.startswith('_'):
                        result = Result.objects.get(pid=pid)
                    else:
                        result = Result.objects.filter(
                            Q(barcode=pid) | Q(oid=pid)).latest('created')
                except Result.DoesNotExist:
                    logger.info("Can't find result [%s] for w [%s]", pid, w)
                    continue

                try:
                    t = Object.objects.get(name=name, w=w, result=result)
                except:
                    if result.container.is_co:
                        code = result.container.co.num
                    else:
                        code = name_code
                    t = Object.objects.create(
                        name=name, w=w, key=key,
                        result=result, uid=uid, name_code=code)

                    reannounce(t)

                    if result.expires_date or (
                          result.registry.is_sending
                          and result.status in [Result.C, Result.W]):
                        Client().Update(result)

                if not result.is_blocked and not result.in_container:
                    if send:
                        if result.status == Result.STATUS1:
                            Result.objects.filter(
                                id=result.id
                            ).update(
                                 status=Result.STATUS2,
                                 on_way_back_date=datetime.now())
                        else:
                            started(result)

            elif uid != existing_objects[key] and uid:
                t = Object.objects.get(name=name, key=key)
                t.uid = uid
                t.name_code = name_code
                t.save()
                reannounce(t)

它确实有效,并且为某些生产系统提供了动力。然而,我们仍然可以说,这个函数肯定承担了不止一项职责,应该重构。但是,我们该如何做出这个决定呢?
有多种形式化方法可以跟踪此类函数,包括:

应用这些方法后,我们会发现这个函数太复杂了,很难轻松地编写它。我们可以(并且建议)进一步自动化这个过程。代码质量工具就是wemake-python-styleguide一个典型的例子。

直接用它吧。它会检测出所有隐藏的复杂性,防止你的代码腐烂。

这是一个不太明显的例子,该函数执行了几件事并破坏了 SRP(不幸的是,这样的事情根本无法自动化,代码审查是发现此类问题的唯一方法):

def calculate_price(products: List[Product]) -> Decimal:
    """Returns the final price of all selected products (in rubles)."""
    price = 0
    for product in products:
       price += product.price

    logger.log('Final price is: {0}', price)
    return price

看看这个logger变量。它是怎么进入函数体的?它不是一个参数,而是一个硬编码的行为。如果我因为某些原因不想记录这个特定的价格怎么办?我应该用参数标志来禁用它吗?

如果我尝试这样做,最终会得到如下结果:

def calculate_price(products: List[Product], log: bool = True) -> ...
    ...

恭喜,我们现在在代码中发现了一个众所周知的反模式。不要使用布尔标志。它们很糟糕。

此外,我该如何测试这个函数呢?如果没有这个logger.log调用,它本来是一个完全可测试的纯函数。输入一些数据,我可以预测输出什么。但现在它不纯了。为了测试它logger.log是否真的有效,我必须以某种方式模拟它,并断言日志已创建。

你可能会争辩说,logger我们python只针对这种情况才使用全局配置。但对于同一个问题,这却是一个肮脏的解决方案。

就因为一行代码就搞得一团糟!这个函数的问题在于很难注意到它的双重职责。如果我们把这个函数从 重命名为calculate_price正确 ,calculate_and_log_price就会发现它明显不符合 SRP 原则。规则就这么简单:如果“正确且完整”的函数名包含andor、 或then—— 那么它就是重构的理想选择。

好吧,这听起来有点吓人,但总的来说,这种情况该怎么办呢?我们该如何改变这个函数的行为,让它最终遵循 SRP 的规则呢?

我想说实现 SRP 的唯一方法是组合:将不同的函数组合在一起,这样每个函数只做一件事,但它们的组合可以做我们想要的所有事情。

让我们看看可以用来组成函数的不同模式python

装饰器

我们可以使用装饰器模式将函数组合在一起。

@log('Final price is: {0}')
def calculate_price(...) -> ...:
    ...

这种模式会带来什么后果?

  1. 它不仅组合了功能,还将功能粘合在一起。这样,你就无法在calculate_price没有log
  2. 它是静态的。你不能从调用点更改内容。或者你必须在实际函数参数之前将参数传递给装饰器函数。
  3. 它会造成视觉干扰。当装饰器的数量增加时,大量的额外代码行会污染我们的函数。

总而言之,装饰器在特定情况下非常适用,但在其他情况下则不适用。例如:@login_required@contextmanager和 朋友。

功能组合

它与装饰器模式非常相似,唯一的区别是它是在运行时应用的,而不是“导入”时间。

from logger import log

def controller(products: List[Product]):
    final_price = log(calculate_price, message='Price is: {0}')(products)
    ...
  1. 通过这种方法,我们可以轻松地以我们实际想要的方式调用函数:带或不带log部分
  2. 另一方面,它会产生更多的样板和视觉噪音
  3. 由于样板量较大,并且将组合委托给调用者而不是声明,因此很难重构

但在某些情况下它也有效。例如,我@safe一直使用函数:

from returns.functions import safe

user_input = input('Input number: ')

# The next line won't raise any exceptions:
safe_number = safe(int)(user_input)

您可以在另一篇文章中了解更多关于异常为何可能对您的业务逻辑造成损害的信息。我们还提供了一个实用的类型安全compose函数returns库,您可以在运行时使用它来编写代码。

传递参数

我们可以直接传递参数。就这么简单!

def calculate_price(
    products: List[Product],
    callback=Callable[[Decimal], Decimal],
) -> Decimal:
    """Returns the final price of all selected products (in rubles)."""
    price = 0
    for product in products:
       price += product.price

    return callback(price)

然后我们就可以调用它:

from functools import partial

from logger import log

price_log = partial(log, 'Price is: {0}')
calculate_price(products_list, callback=price_log)

效果很好。现在我们的函数对日志记录一无所知。它只计算价格并返回其回调函数。现在我们可以提供任何回调函数,而不仅仅是log。它可以是任何接收回调函数Decimal并返回回调函数的函数:

def make_discount(price: Decimal) -> Decimal:
    return price * 0.95

calculate_price(products_list, callback=make_discount)

看到了吗?没问题,按照你喜欢的方式编写函数就好。这种方法的隐藏缺点在于函数参数的性质。我们必须显式地传递它们。如果调用栈很大,我们就需要将很多参数传递给不同的函数。而且可能会涵盖不同的情况:我们需要A在 的情况下回调,在 的情况下a回调Bb

当然,我们可以尝试以某种方式修补它们,创建更多返回更多函数的函数,或者用@inject各处的装饰器污染我们的代码,但我认为这很丑陋。

未解决的问题:

  1. 混合逻辑参数和依赖参数,因为我们同时传递它们,很难分辨哪个是哪个
  2. 如果调用堆栈很大,那么显式参数可能很难甚至不可能维护

为了解决这些问题,让我向您介绍可调用对象的概念。

分离逻辑和依赖关系

在开始讨论可调用对象之前,我们需要先从面向对象编程 (OOP) 的角度来探讨一下对象和面向对象编程 (OOP) 的概念,并牢记其核心思想:“让我们将数据和行为结合在一起”。在我看来,OOP 的核心思想本身就存在一个主要问题:将数据和行为结合在一起。在我看来,这明显违反了面向对象编程 (OOP) 的核心思想,因为对象在设计上同时执行两件事:它们保存自身状态执行某些附加行为。当然,我们将通过可调用对象来消除这个缺陷。

可调用对象看起来像具有两个公共方法的常规对象:__init____call__。并且它们遵循使它们独一无二的特定规则:

  1. 仅处理构造函数中的依赖项
  2. 仅处理__call__方法中的逻辑参数
  3. 没有可变状态
  4. 没有其他公共方法或任何公共属性
  5. 没有父类或子类

实现可调用对象的直接方法如下:

class CalculatePrice(object):
    def __init__(self, callback: Callable[[Decimal], Decimal]) -> None:
        self._callback = callback

    def __call__(self, products: List[Product]) -> Decimal:
        price = 0
        for product in products:
            price += product.price
        return self._callback(price)

可调用对象和函数之间的主要区别在于,可调用对象具有传递依赖项的明确步骤,而函数将常规逻辑参数与依赖项混合在一起(您已经注意到可调用对象只是部分函数应用的一个特例):

# Regular functions mix regular arguments with dependencies:
calculate_price(products_list, callback=price_log)

# Callable objects first handle dependencies, then regular arguments:
CalculatePrice(price_log)(products_list)

但是,给出的示例并没有遵循我们强加于可调用对象的所有规则。具体来说,它们是可变的,并且可以有子类。让我们也修复这个问题:

from typing_extensions import final

from attr import dataclass


@final
@dataclass(frozen=True, slots=True)
class CalculatePrice(object):
    _callback: Callable[[Decimal], Decimal]

    def __call__(self, products: List[Product]) -> Decimal:
        ...

现在,通过添加限制此类为子类的@final装饰器以及@dataclass具有属性的装饰器,我们的类遵守了我们一开始施加的所有规则。frozenslots

  1. 仅处理构造函数中的依赖项。没错,我们只有声明式依赖项,构造函数由以下代码创建:attrs
  2. 仅处理__call__方法中的逻辑参数。根据定义,确实如此
  3. 没有可变状态。没错,因为我们frozen使用slots
  4. 没有其他公共方法或任何公共属性。大多数情况下,我们不能通过声明slots属性和声明式受保护实例属性来拥有公共属性,但我们仍然可以拥有公共方法。考虑使用 linter 来检查这一点
  5. 没有父类或子类。没错,我们明确继承object并标记了此类final,因此任何子类都将受到限制

它现在看起来像一个对象,但它肯定不是一个真正的对象。它不能拥有任何状态、公共方法或属性。但是,它非常适合单一职责原则。首先,它没有数据行为,只有纯粹的行为。其次,这种方式很难把事情搞乱。你总是只有一个方法来调用你拥有的所有对象。这就是 SRP 的精髓所在。只需确保这个方法不要太复杂,并且只做一件事。记住,没有人会阻止你创建受保护的方法来分解__call__行为。

但是,我们还没有解决将依赖项作为参数传递给函数(或可调用对象)的第二个问题:嘈杂的显式性。

依赖注入

DI 模式在外部世界广为人知且广泛使用python。但由于某些原因,在内部却不太流行。我认为这是一个应该修复的 bug。

让我们看一个新的例子。假设我们有一个明信片发送应用程序。用户创建明信片,并在特定日期(例如节假日、生日等)发送给其他用户。出于分析目的,我们也需要了解有多少人发送了明信片。让我们看看这个用例是什么样的:

from project.postcards.repository import PostcardsForToday
from project.postcards.services import (
   SendPostcardsByEmail,
   CountPostcardsInAnalytics,
)

@final
@dataclass(frozen=True, slots=True)
class SendTodaysPostcardsUsecase(object):
    _repository: PostcardsForToday
    _email: SendPostcardsByEmail
    _analytics: CountPostcardInAnalytics

    def __call__(self, today: datetime) -> None:
        postcards = self._repository(today)
        self._email(postcards)
        self._analytics(postcards)

接下来,我们必须调用这个可调用类:

# Injecting dependencies:
send_postcards = SendTodaysPostcardsUsecase(
    PostcardsForToday(db=Postgres('postgres://...')),
    SendPostcardsByEmail(email=SendGrid('username', 'pass')),
    CountPostcardInAnalytics(source=GoogleAnalytics('google', 'admin')),
)

# Actually invoking postcards send:
send_postcards(datetime.now())

在这个例子中,问题显而易见。我们有很多依赖相关的样板代码。每次创建一个实例时,SendTodaysPostcardsUsecase我们都必须创建它的所有依赖项。这真是越写越深。

所有这些样板代码看起来都是多余的。我们已经在类中指定了所有类型的预期依赖项,以及类依赖项中的传递依赖项等等。为什么还要再次重复这些代码呢?

其实我们不必这么做。我们可以使用某种 DI 框架。我个人推荐dependenciespunq。它们的主要区别在于解析依赖项的方式:dependencies使用名称和使用类型。在本例中,punq我们选择使用。punq

不要忘记安装它:

pip install punq

现在我们的代码可以简化了,不用再处理依赖关系了。我们创建一个单独的位置来注册所有的依赖关系:

# project/implemented.py

import punq

container = punq.Container()

# Low level dependencies:
container.register(Postgres)
container.register(SendGrid)
container.register(GoogleAnalytics)

# Intermediate dependencies:
container.register(PostcardsForToday)
container.register(SendPostcardsByEmail)
container.register(CountPostcardInAnalytics)

# End dependencies:
container.register(SendTodaysPostcardsUsecase)

然后在任何地方使用它:

from project.implemented import container

send_postcards = container.resolve(SendTodaysPostcardsUsecase)
send_postcards(datetime.now())

几乎无需重复的样板代码,开箱即用,可读性强,类型安全。现在我们无需手动连接任何依赖项。它们将通过注解连接punq。只需按照您需要的方式在可调用对象中键入声明性字段,在容器中注册依赖项,即可开始使用。

当然,有一些高级类型模式可以更好地实现控制反转,但最好在punq的文档中介绍。

何时不使用可调用对象

显而易见,所有编程概念都有其局限性。
可调用对象不应该在应用程序的基础架构层中使用。因为太多现有的 API 不支持此类类和 API。在业务逻辑中使用可调用对象,可以提高其可读性和可维护性。

考虑将returns库添加到组合中,这样您也可以摆脱异常。

结论

我们走了很长的路。从功能复杂、功能可怕的函数,到遵循单一职责原则、依赖注入的简单可调用对象。一路走来,我们探索了不同的工具、实践和模式。

但是我们的努力真的带来了巨大的改变吗?最重要的问题是:经过这么多重构之后,我的代码真的更好了吗?

我的答案是:是的。这对我来说是一个重大的改变。我可以轻松地将简单的构建块组合成复杂的用例。它类型明确、可测试且可读性强。

你怎么看?在下面的评论区分享你的看法。

关键要点:

  1. 使用易于组合的简单构建块
  2. 为了实现可组合性,所有实体应该只负责一件事
  3. 使用代码质量工具来确保这些块确实“简单”
  4. 为了让高层事物只负责一件事——使用简单块的组合
  5. 要处理组合依赖关系,请使用可调用对象
  6. 使用依赖注入来减少组合的样板

就是这样!

鏂囩珷鏉ユ簮锛�https://dev.to/wemake-services/enforcing-single-responsibility-principle-in-python-2il8
PREV
Python 异常被视为反模式
NEXT
1 分钟了解 Python 中的实数常量