如何在C语言中进行依赖注入?

29

我正在寻找一种在C语言中进行DI(依赖注入)的好的技术解决方案。

我已经看到了一些关于DI的问题,但是我还没有看到实际示例或具体的实现建议。

所以,假设我们有以下情况:

我们有一组C语言模块;我们想要重构这些模块,以便我们可以使用DI来运行单元测试等操作。

每个模块实际上都包含一组C函数:

module_function(...);

模块之间存在依赖关系。也就是说,通常会有像这样的调用:

int module1_doit(int x) {
  int y = module2_dosomethingelse(x);
  y += 2;
  return(y);
}

如何正确地进行依赖注入?

可能的解决方案包括:

  • (1) 对于所有模块函数,使用函数指针,并在调用函数时执行以下操作(或类似操作):

    int y = modules->module2->dosomethingelse(x);

  • (2) 编译多个库(mock、std等),这些库具有相同的符号,并动态链接到正确的实现。

(2) 似乎是正确的做法,但配置起来很困难,而且令人烦恼的是,你必须为每个单元测试构建多个二进制文件。

(1) 看起来可能可行,但在某些情况下,你的DI控制器将陷入一种需要动态调用通用工厂函数(例如void ( factory) (...))以及需要在运行时注入其他模块的情况中?

在c语言中是否有另一种更好的方法?

什么是“正确”的做法?


7
对于你的问题,实在没有一个好的答案。在 C 语言中,你尝试做的事情并没有太多意义。“你在用错误的方法”或者“选择一种不同的语言”并不能成为一个好的回答。我并不是想要惹恼你,但我想说的是你要求 C 做一些并不符合它优势的事情。 - Rafe Kettler
14
看看 Linux 内核吧。例如,每个驱动程序都是一个模块,实现特定接口(取决于驱动程序类型)。所有模块都是松散耦合的,根据机器的具体配置,模块(/依赖项)在运行时被动态地连接起来。这是通过每个驱动程序模块填写一组函数指针的结构体(即接口),然后将该结构体动态提供给相关模块来完成的。实际上,这样做非常好地进行了结构化设计。 :) - Bjarke Freund-Hansen
10
完全不同意 @RafeKettler 的观点——依赖注入是一种软件工程最佳实践,尤其是在过去10年中成为了一种重要的编码风格。这篇文章只是试图将一种“新”的最佳实践应用到一个已经存在很久的语言中,这没有任何问题。 - smeeb
@BjarkeFreund-Hansen,这段代码看起来符合你在Linux内核驱动程序方面提到的要求吗? - overexchange
@BjarkeFreund-Hansen 查询已迁移到此处。让我删除我的先前评论。 - overexchange
显示剩余4条评论
6个回答

17

这是一个关于该主题的好帖子。函数指针似乎是一个不错的方法;你只需要注意,如果没有链接时优化,使用它们会导致一定的性能损失(与ABI方法相比,后者将链接或动态加载需要使用的符号的单个特定实例)。 - Doug

14

我得出结论,在 C 语言中没有一种“正确”的做法。相比其他语言,这总是会更加困难和繁琐。然而,我认为重要的是不要为了单元测试而使代码晦涩难懂。在 C 语言中将所有内容都变成函数指针可能听起来不错,但最终我认为它只会让代码变得非常难以调试。

我的最新方法是保持简单。我不改变任何 C 模块内的代码,除了在文件顶部放置一个小的 #ifdef UNIT_TESTING 用于 extern 和内存分配跟踪。然后我拿这个模块并将其编译时去除所有依赖项,以便导致连接失败。一旦我检查过未解析的符号确保它们是我想要的,我就运行一个脚本,解析这些依赖关系并生成所有符号的存根原型。这些原型都会被倾入单元测试文件中。根据外部依赖项的复杂性而异。

如果我需要在一个实例中模拟依赖项,在另一个实例中使用真实依赖项,或者在另一个实例中使用存根依赖项,那么我最终会有三个单元测试模块来测试一个被测试的模块。拥有多个二进制文件可能并不理想,但这是 C 语言中唯一的真正选择。它们都会同时运行,所以对我来说这不是问题。


1
+1 对于多个二进制文件来说确实是唯一的解决方案。 - Doug
你能否将依赖分离到一个单独的 dependency.h 文件中,然后通过配置构建系统,在生产代码中使用 real_dependency.cc,在测试代码中使用 test_dependency.cc - mercury0114

9
这是一个Ceedling的完美应用案例。
Ceedling是一个综合项目,汇集了Unity和CMock等工具,可以自动化你描述的大部分工作。
一般来说,Ceedling/Unity/CMock是一组ruby脚本,它们扫描你的代码并根据你的模块头文件自动生成mocks,同时生成测试运行器以查找所有测试并创建可运行它们的运行器。
为每个测试套件生成一个单独的测试运行器二进制文件,并在你的测试套件实现中按照你的请求链接适当的mock和真实实现。
起初我对于将ruby作为我们的构建系统测试依赖感到犹豫不决,似乎会带来很多复杂性和魔法,但在尝试过使用自动生成的mocking代码编写一些测试后,我就被迷住了。

3
有点晚了,但这是我工作中最近的一个话题。
我看到的两种主要方法是使用函数指针或将所有依赖项移动到特定的C文件中。
后者的一个很好的例子是FATFS。 http://elm-chan.org/fsw/ff/en/appnote.html fatfs的作者提供了大部分库函数,并将某些特定的依赖项委托给库用户编写(例如串行外设接口函数)。
函数指针是另一个有用的工具,使用typedef可以帮助代码保持整洁。
以下是我模拟数字转换器(ADC)代码的一些简化片段:
typedef void (*adc_callback_t)(void);

bool ADC_CallBackSet(adc_callback_t callBack)
{
    bool err = false;
    if (NULL == ADC_callBack)
    {
        ADC_callBack = callBack;
    }
    else
    {
        err = true;
    }
    return err;
}

// When the ADC data is ready, this interrupt gets called
bool ADC_ISR(void)
{
    // Clear the ADC interrupt flag
    ADIF = 0;

    // Call the callback function if set
    if (NULL != ADC_callBack)
    {
        ADC_callBack();
    }

    return true; // handled
}

// Elsewhere
void FOO_Initialize(void)
{
    ADC_CallBackSet(FOO_AdcCallback);
    // Initialize other FOO stuff
}

void FOO_AdcCallback(void)
{
    ADC_RESULT_T rawSample = ADC_ResultGet();
    FOO_globalVar += rawSample;
}

现在,Foo的中断行为已经注入到ADC的中断服务例程中。

你甚至可以更进一步,通过将函数指针传递给FOO_Initialize,以便应用程序管理所有依赖关系问题。

//dependency_injection.h
typedef void (*DI_Callback)(void)
typedef bool (*DI_CallbackSetter)(DI_Callback)

// foo.c
bool FOO_Initialize(DI_CallbackSetter CallbackSet)
{
    bool err = CallbackSet(FOO_AdcCallback);
    // Initialize other FOO stuff
    return err;
}

但在这种情况下,ADC中断会被阻塞,直到回调函数被调用。如果回调本身调用另一个回调,它会使ISR变得更长。 - prasad
像任何东西一样,它是一个工具。使用不当会导致糟糕的结果。然而,链接回调函数并不是我迄今为止在任何嵌入式代码中看到的问题。通常相反的情况才是问题所在。 - Ryan B
这是一个不错的技巧,你只需要注意一下如果你正在处理ISR回调函数时尽可能保持实现简短和高效。如果您需要根据该ISR执行做许多事情,则只需使用标志、另一个函数指针、消息(在RTOS的情况下)或其他方式来处理ISR外部的操作。 - m4l490n
@RyanB 为什么在if (NULL == ADC_callBack)这行之前没有声明ADC_callback - Mehdi Charife
@MehdiCharife 这是一个不完整的代码片段。我可能只是忘记在片段中包含那行代码,或者认为它对于重点来说并不重要。当时我还没有通过编译器运行过这个代码。但是没错,你说得对,我们必须在if语句或其他地方之前声明ADC_callback - Ryan B

2

有两种方法可供使用。如Rafe所指出的,无论你是否真的想要,都取决于你。

第一种:在静态库中创建“动态”注入方法。链接库并在测试期间简单地替换它。方法就被替换了。

第二种:基于预处理提供编译时替换:

#ifndef YOUR_FLAG

    /* normal method versions */

#else

    /* changed method versions */

#endif

/* methods that have no substitute */

0
这是一个关于依赖注入在 C 语言中的示例(基本上就是 OP 提到的第二种选项):
// person.h file
struct Person {
    char birthday[sizeof("YYYY-MM-DD")];
};
int get_age(const Person *person);

// person.cc file
#include "person.h"
#include "get_todays_date.h" // we import get_todays_date() dependency

int get_age(const Person *person) { 
    char *today = get_todays_date();
    return compute_difference(today, person->birthday);
}

如果在生产环境中执行get_todays_date()函数,则它应该始终返回准确的日期。

如果在测试环境中执行get_todays_date()函数,我们将其设置为返回一个固定日期,以便编写以下测试:

// person_test.cc file
#include "get_todays_date.h"

const char today[] = "2020-01-06";
char* get_todays_date(void) { return today; } 

int main() {
    Person person = {.birthday = "2000-01-05"};
    assert(get_age(&person) == 20); // our test
    return 0;
}

get_todays_dateperson.h 文件的依赖项。

当你编译用于生产环境的代码时,链接包含真正实现的翻译单元 get_todays_date()(即将 get_todays_date.cc 文件包含到编译过程中)

当你编译用于测试的代码时,不要链接对应 get_todays_date.cc 的翻译单元


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接