一种降低代码耦合度的方法

骚操作了解一下?

Posted by Hehesheng on February 22, 2020

一种降低代码耦合度的方法

运行环境

耦合度

写代码时除了保证功能正常,也都会注意一下代码质量,而其中一个容易被提及的一个质量要求,代码的耦合度。

那么什么叫做代码的耦合度呢?

一般的说法就是模块之间互相的关联程度的度量标准,举个例子,比如A文件中有个变量value,然后B文件用到了这个变量,那么就说A和B文件之间存在耦合。

什么是高耦合度的代码呢?还是上面的例子,假设B文件有个函数func,这个函数被A中的代码调用了,那么我们就发现,AB两文件之间耦合度很高,因为他们互相关联。这样的代码看起来会非常混乱。

低耦合的代码一般来说都是高质量代码的代名词,好处就是维护简单,结构清晰。我不记得从哪听来的一句话:低耦合的代码是就算你把代码中的几个文件删去,不管warning,你的代码能够编译通过,那么你的代码其实就耦合度很低了。

上面的说法听起来有点神奇,以写C为例,我们经常编写函数用的方法就是一个.c和一个.h,然后再逻辑代码中包含.h,这种方法在你删去.h的时候,编译器100%会报错,找不到你包含的头文件。

的确,写一个.h文件出函数这种方法在很多时候非常简单也很好用。因为大家都能理解,但是正如耦合度定义的一样,这样文件之间的关联就过大了,换句话就是,耦合度高。

起因

想起耦合度的相关话题,也是我最近在玩开源图形库lvgl时产生的。我的想法就是将功能一个个列在一个列表里,但是在我编写第二个功能的实现函数时,我就感觉到了一丝不对。

一开始以模块化思想,一个功能通过调用一个函数去实现。那么问题就出现了,如果每增加一个功能,就扩一个头文件,下方也用个数组去绑定事件,显然,对于这个文件,他的耦合度就会增高。

这里的效果应该是像一个个app一样,互相隔离,能不能做到呢?

我就想到了在rt-thread中广泛使用的一种方式,自动化启动流程,以及其finsh指令的导出方式,utest的tc导出方式:自定义段

自定义段

我们知道,程序里有各种段,代码段,数据段等等,这些一般是约定俗成的。基本上所有程序都会有,但是这些段也是可以自行定义的。这就是链接脚本:link script

这里利用这个特性,我们对我们的代码进行一些精细化定制。基本原理就是定义一些固定变量,将他们固定在我们设定的自定义段上,然后我们在程序中读出这些变量。

不同编译器有自己的链接脚本,这是keil的:

LR_IROM1 0x08000000 0x00100000  {    ; load region size_region
  ER_IROM1 0x08000000 0x00100000  {  ; load address = execution address
   *.o (RESET, +First)
   *(InRoot$$Sections)
   .ANY (+RO)
  }
  RW_IRAM1 0x20000000 0x00030000  {  ; RW data
   .ANY (+RW +ZI)
  }
}

而这是gcc的(省略部分):

/* Program Entry, set to mark it as "used" and avoid gc */
MEMORY
{
    CODE (rx) : ORIGIN = 0x08000000, LENGTH = 2048k /* 2048KB flash */
    RAM1 (rw) : ORIGIN = 0x20000000, LENGTH =  192k /* 192K sram */
    RAM2 (rw) : ORIGIN = 0x10000000, LENGTH =   64k /* 64K sram */
}
ENTRY(Reset_Handler)
_system_stack_size = 0x400;

SECTIONS
{
    .text :
    {
        . = ALIGN(4);
        _stext = .;
        KEEP(*(.isr_vector))            /* Startup code */

        . = ALIGN(4);
        *(.text)                        /* remaining code */
        *(.text.*)                      /* remaining code */
        *(.rodata)                      /* read-only data (constants) */
        *(.rodata*)
        *(.glue_7)
        *(.glue_7t)
        *(.gnu.linkonce.t*)
        ...
        ...
}

一般情况下,我们都不需要自己编辑链接脚本,所以在keil中,这个功能也被隐藏,它会按照默认值配置,所以这里我们要将他打开:

然后就要修改一下链接脚本文件,在text段添加以下内容,以gcc为例:

/* section information for lvgl apps */
. = ALIGN(8);
__lvgl_apps_tab_start = .;
KEEP(*(LVObjTab))
__lvgl_apps_tab_end = .;
. = ALIGN(8);

这六行,第一行是注释,第二和第六行是表示按照8字节对齐,中间的三行就是精髓了。这里是分别定义了__lvgl_apps_tab_start和__lvgl_apps_tab_end两个变量,记住这两个变量,他们接下来将被用到!中间创建名叫LVObjTab的自定义段。

代码导出到段

光有这个段还不够,我们还需要精确控制我们的变量能够落在这个段中。

这里就要引进一个新的关键词:__attribute__

这个关键字的中文意思是属性,这个关键词属于拓展语法,功能呢非常强大,有兴趣的自行搜索一下,这里我们就靠他来将变量输出到固定段。

先做一些宏定义和必要的结构体定义:

struct __lvgl_app_item
{
    const char *name;
    const char *text;
    const void *img;
    lv_event_cb_t cb;
};
#define SECTION(x)                  __attribute__((section(x)))

#define RT_USED                     __attribute__((used))

然后就是重头戏,导出:

#define LVGL_APP_ITEM_EXPORT(text, img, cb)                                                       \


    const char __lvgl_app_item_##text##_##img##_name[] SECTION(".rodata.lvgl") = "__app_" #text;  \
    const char __lvgl_app_item_##text##_##img##_text[] SECTION(".rodata.lvgl") = #text;           \
                                                                                                  \
    const struct __lvgl_app_item __lvgl_app_item_##text##_##img##_obj SECTION(".rodata.lvgl") = { \
        __lvgl_app_item_##text##_##img##_name, __lvgl_app_item_##text##_##img##_text, img, cb};   \
    RT_USED const void *__lvgl_app_##text##_##img##_point SECTION("LVObjTab") = &__lvgl_app_item_##text##_##img##_obj;

可以说这个宏是非常复杂的了,放一个截图

可以说是可读性非常差了,结合一两个例子来看看,去掉一些干扰项:

LVGL_APP_ITEM_EXPORT(info, LV_SYMBOL_DRIVE, show_information_cb)
------>等效以下内容
const char __lvgl_app_item_info_LV_SYMBOL_DRIVE_name[] SECTION(".rodata.lvgl") = "__app_" "info";
const char __lvgl_app_item_info_LV_SYMBOL_DRIVE_text[] SECTION(".rodata.lvgl") = "info";
const struct __lvgl_app_item __lvgl_app_item_info_LV_SYMBOL_DRIVE_obj SECTION(".rodata.lvgl") = {
    __lvgl_app_item_info_LV_SYMBOL_DRIVE_name,
    __lvgl_app_item_info_LV_SYMBOL_DRIVE_text,
    LV_SYMBOL_DRIVE,
    show_information_cb
};
RT_USED const void *__lvgl_app_info_LV_SYMBOL_DRIVE_point SECTION("LVObjTab") = &__lvgl_app_item_info_LV_SYMBOL_DRIVE_obj;

还是很乱,去掉一些附加描述语句并整理以下格式:

char __lvgl_app_item_info_LV_SYMBOL_DRIVE_name[] = "__app_" "info";
char __lvgl_app_item_info_LV_SYMBOL_DRIVE_text[] = "info";
struct __lvgl_app_item __lvgl_app_item_info_LV_SYMBOL_DRIVE_obj = {
    __lvgl_app_item_info_LV_SYMBOL_DRIVE_name,
    __lvgl_app_item_info_LV_SYMBOL_DRIVE_text,
    LV_SYMBOL_DRIVE,
    show_information_cb
};
void *__lvgl_app_info_LV_SYMBOL_DRIVE_point = &__lvgl_app_item_info_LV_SYMBOL_DRIVE_obj;

ok,现在看起来就很清晰了,实际上这个宏就是定义了两个字符串和一个结构体,最后定义一个指针指向结构体。然后我们再一点点加上描述符。先加上const,这个属于我们比较熟悉的,意思是不可修改的,还有一个意义,就是编译器会将const变量尽可能的存在不易失存储器中,在单片机中就是flash等。于是上面就变成了:

const char __lvgl_app_item_info_LV_SYMBOL_DRIVE_name[] = "__app_" "info";
const char __lvgl_app_item_info_LV_SYMBOL_DRIVE_text[] = "info";
const struct __lvgl_app_item __lvgl_app_item_info_LV_SYMBOL_DRIVE_obj = {
    __lvgl_app_item_info_LV_SYMBOL_DRIVE_name,
    __lvgl_app_item_info_LV_SYMBOL_DRIVE_text,
    LV_SYMBOL_DRIVE, show_information_cb
};
const void *__lvgl_app_info_LV_SYMBOL_DRIVE_point = &__lvgl_app_item_info_LV_SYMBOL_DRIVE_obj;

还记得我们的目的是将变量固定到我们指定的代码段,这里就用前面介绍的__attribute__描述:#define SECTION(x) __attribute__((section(x))),用它去附加描述各个变量:

const char __lvgl_app_item_info_LV_SYMBOL_DRIVE_name[] SECTION(".rodata.lvgl") = "__app_" "info";
const char __lvgl_app_item_info_LV_SYMBOL_DRIVE_text[] SECTION(".rodata.lvgl") = "info";
const struct __lvgl_app_item __lvgl_app_item_info_LV_SYMBOL_DRIVE_obj SECTION(".rodata.lvgl") = {
    __lvgl_app_item_info_LV_SYMBOL_DRIVE_name,
    __lvgl_app_item_info_LV_SYMBOL_DRIVE_text,
    LV_SYMBOL_DRIVE,
    show_information_cb
};
RT_USED const void *__lvgl_app_info_LV_SYMBOL_DRIVE_point SECTION("LVObjTab") = &__lvgl_app_item_info_LV_SYMBOL_DRIVE_obj;

其中.rodataread only data的意思,这里不给他们描述也可以,他们会被编译器优化到程序中的各个位置,虽然不影响运行,但是他们没有集中在一起。LVObjTab就是在链接脚本中定义的段。而最后一行的RT_USED是防止编译器判断这个变量不被使用而被优化掉。

至此,导出到自定义段的工作就完成了。

调用

还记得之前定义的__lvgl_apps_tab_start和__lvgl_apps_tab_end吗,现在我们要靠他定位我们的导出变量。

调用方法直接看代码:

extern const int __lvgl_apps_tab_start;
extern const int __lvgl_apps_tab_end;
const void *lvgl_apps_start_point = &__lvgl_apps_tab_start;
const void *lvgl_apps_end_point   = &__lvgl_apps_tab_end;
struct __lvgl_app_item *obj;
struct __lvgl_app_item **point;

for (point = (struct __lvgl_app_item **)lvgl_apps_start_point; point < (struct __lvgl_app_item **)lvgl_apps_end_point;
     point++)
{
    obj = *point;

    if (strncmp(obj->name, "__app_", 6) == 0)
    {
        /* do something */
    }
}

这里就体现我们给每个导出变量的名字命名且前接一个“__app_”的目的了,这样就能增加导出变量的灵活性。只要对结构体的定义明确,那么这个解耦方式可以有很广的应用范围。例如,这里的例子我是想导出一个文本和一个函数指针,那么能不能导出一些全局变量呢?当然可以!这是我导出一个lv_obj_t而再编写了一个宏:

struct __lvgl_widget_item
{
    const char *name;
    const char *text;
    lv_obj_t **obj;
    lv_event_cb_t cb;
};

#define LVGL_WIDGET_ITEM_EXPORT(text, widget, cb)                                                          \


    const char __lvgl_widget_item_##text##_##widget##_name[] SECTION(".rodata.name") = "__widget_" #text;  \
    const char __lvgl_widget_item_##text##_##widget##_text[] SECTION(".rodata.name") = #text;              \
    lv_obj_t *__lvgl_widget_item_##text##_##widget##_widget                          = widget;             \
                                                                                                           \
    const struct __lvgl_widget_item __lvgl_widget_item_##text##_##widget##_obj SECTION(".rodata.name") = { \
        __lvgl_widget_item_##text##_##widget##_name, __lvgl_widget_item_##text##_##widget##_text,          \
        &__lvgl_widget_item_##text##_##widget##_widget, cb};                                               \
    RT_USED const void *__lvgl_widget_##text##_##widget##_point SECTION("LVObjTab") =                      \
        &__lvgl_widget_item_##text##_##widget##_obj;

像这样,调用取出方式也变为

extern const int __lvgl_apps_tab_start;
extern const int __lvgl_apps_tab_end;
const void *lvgl_apps_start_point = &__lvgl_apps_tab_start;
const void *lvgl_apps_end_point   = &__lvgl_apps_tab_end;
struct __lvgl_widget_item *obj;
struct __lvgl_widget_item **point;

for (point = (struct __lvgl_widget_item **)lvgl_apps_start_point; point < (struct __lvgl_widget_item **)lvgl_apps_end_point;
     point++)
{
    obj = *point;

    if (strncmp(obj->name, "__widget_", 9) == 0)
    {
        /* do something */
    }
}

这样,我们的代码就算是解耦成功!

效果

文章开始时的那张图,就可以重写了:

非常精简,并且在添加新的功能时这个文件不需要任何修改,文件目录也得到精简:

原目录:

新目录:

新的方式高度解耦,上图所示的五个文件,lvgl_page.c是初始化界面,并列出功能。包含tools的文件都可以互相沟通又隔离,这里的例子就是usage和terminal是功能,这两个文件任何一个删除,代码都可以被运行,并不会报错。而这函数就是功能函数,我们希望的就是他们之间互相无关联。实现这些的时候需要做的仅仅每个需要导出的函数下添加指令,如:

static void exp_fun(lv_obj_t *obj, lv_event_t event)
{
    /* do something */
}
LVGL_APP_ITEM_EXPORT(exp_text, exp_img, exp_fun)/* 导出 */

板载效果,注释导出:

运行效果:

导出:

运行效果:

效果非常amazing啊,轻松加入一个功能只需要修改这一行代码,其他一个字符都不需要改变。

这种解耦方式对于分工合作有着非常棒的优势,并且效率也非常高。缺点就是实现方式可读性偏差,对于编写者的C语言水平是一定的考验,但是对于调用者来说确是十分方便的,约好固定的api接口就可以高效合作。没有需求也可以试着写一下,非常利于提高对于硬件地址的理解。

日常附上表情包和仓库,觉得不错就赏个follow和star吧,秋梨膏