PAYATHON 2026

至少有三个易变输入或输出时才适合使用 DIP 吗?是否应始终使用?

数据小许

不应把“至少有三个易变输入或输出”作为采用 DIP 的门槛,也不必在所有地方预先增加抽象。判断时要看:某个具体实现发生变化,是否会迫使本应稳定的业务逻辑一起修改。

你的数据源抽象有合理依据,因为代码已经需要在在线数据和文件数据之间切换。HistoricalPricesService 目前只是转发调用,是否值得单独保留,还要看它有没有明确的业务职责。

DIP 关注什么,为什么与数量无关?

依赖倒置原则包含两个相关要求:

  • 高层模块不应依赖低层模块,两者都应依赖抽象。
  • 抽象不应依赖实现细节,实现细节应依赖抽象。

在这个例子中,下载价格和读取 Parquet 文件是具体的数据获取方式。使用价格数据进行分析或计算的业务逻辑,可以只依赖“提供价格数据”这一约定。

即使只有一个在线数据源,如果测试业务逻辑时需要避免真实网络请求,引入这个边界也可能有价值。即使有三个输入来源,如果业务逻辑必须分别处理它们的不同含义,统一包装成一个接口也未必能减少复杂度。

实现数量可以帮助你发现共同点,但不能决定抽象是否成立。

当前代码体现了哪些设计?

HistoricalPricesService 接收 PricesData,而不是在内部直接创建 ApiOrMockPricesData。这使它能够接受不同的数据源实现,是朝 DIP 方向进行的设计。

通过构造函数接收依赖的做法叫依赖注入。依赖注入关注对象如何获得依赖,DIP 关注业务代码与实现细节之间的依赖方向。

因此,构造函数传参本身并不能证明整个设计满足 DIP。还要看 PricesData 是否表达了业务真正需要的能力,以及它是否让业务摆脱了具体下载库、文件路径等细节。

创建具体对象的代码仍然需要存在。将它放在程序入口或组装对象的位置即可,不必消除所有对具体类的引用。

怎样判断这个抽象是否值得保留?

可以按下面的顺序检查:

  1. 明确调用方需要什么:是任意 DataFrame,还是具有约定列、索引和时间范围的价格数据?
  2. 确认已有的替换需求:你已经有在线获取和文件读取两种方式,这比猜测未来可能增加数据源更有说服力。
  3. 检查替换后业务逻辑是否稳定:更换数据源后,业务代码是否仍能直接使用返回结果,还是需要增加大量条件分支?
  4. 评估维护成本:如果一个包装类始终只有一行转发代码,也没有独立职责,可以先由调用方直接接收数据源。

Python 中不一定需要通过 ABC 才能建立这种边界。抽象基类、Protocol 或有清楚约定的鸭子类型都可以使用,具体选择取决于是否需要继承约束、静态类型检查等能力。

先把数据契约定义清楚

原代码的抽象方法没有写返回类型,可以先补上:

from abc import ABC, abstractmethod
import pandas as pd


class PricesData(ABC):
    @abstractmethod
    def get_data(self) -> tuple[bool, pd.DataFrame]:
        pass

类型注解只说明返回一个布尔值和一个 DataFrame,还不足以保证不同实现可以互换。你还需要明确:

  • 布尔值表示“来自文件”“使用了兜底数据”,还是其他含义。
  • DataFrame 应有哪些列、索引和数据范围。
  • 无法提供符合约定的数据时,应返回什么或抛出什么异常。

例如,文件中的数据是否覆盖 assets_tickers 请求的资产,需要由数据源保证或校验。仅仅让两个类都有 get_data() 方法,还不能保证替换后业务行为正确。

如果调用方并不关心数据是否来自兜底来源,也可以考虑不把这个布尔值暴露给业务层。

修正当前实现中的状态问题

原来的 self.is_default 存在跨调用残留的风险:某次调用下载失败后,它会变成 True;同一个实例下一次下载成功时,代码没有将它重置为 False。

如果这个标志描述的是本次返回结果,使用局部变量更直接。可以将 ApiOrMockPricesData.get_data() 替换为:

def get_data(self) -> tuple[bool, pd.DataFrame]:
    data = robust_download(self.assets_tickers)

    if data is not None:
        return False, data

    print("[WARNING] Using fallback data")
    return True, pd.read_parquet("historical_prices.parquet")

这段代码沿用原有的 robust_download 和 pd,并假设下载失败时返回 None。它没有处理下载函数抛出异常、返回空表或文件读取失败的情况。这些行为需要根据实际函数约定决定,不能仅凭函数名称推断。

是否要进一步拆分在线获取和兜底逻辑?

ApiOrMockPricesData 同时负责在线获取和失败后读取文件。目前逻辑很短,保留在一个类中可以接受。

如果以后需要组合不同的主数据源、备用数据源,或在多处复用相同的兜底策略,再把它们拆成独立组件会更有价值。没有这些需求时,不必为了形式完整而增加一组类。

当前场景中,可以保留业务与数据获取方式之间的边界。是否增加服务类、兜底包装器或更多接口,应由实际职责和变化需求决定。

备注:内容仅供参考。