使用lambda表达式替换宏定义代码段

使用lambda表达式替换宏定义代码段

1.什么是宏定义代码段

C/C++宏定义属于预处理阶段的功能,操作的对象是源代码。在预处理阶段,编译器根据用户定义的预处理指令,将源代码改造成另外一种形式。

需要特别注意的是,预处理指令虽然跟C/C++代码放在一个源文件里,但并不属于C++的一部分,因此不受C++语法规则的约束。而这一点,正是我们使用宏定义代码段的最重要的原因。

下面的代码展示了一个宏定义代码段的例子。

//compile with : g++ lambda_test_1.cpp -E -o lambda_test_1_after.cpp

#include <stdio.h>

#define BEFORE_ADD() \
    int a, b, c; \
    a = 1; \
    b = 2

#define AFTER_ADD() \
    printf("a + b = %d\n", c)


int main()
{
    BEFORE_ADD();

    c = a + b;

    AFTER_ADD();

    return 0;
}

代码简单易懂,不再解释。

我们使用-E命令编译上述代码,命令如下:

g++ lambda_test_1.cpp -E -o lambda_test_1_after.cpp

查看编译后的代码,发现main函数被替换成了如下的代码:

宏定义中的代码被简单粗暴地替换到了源文件中。

2.宏定义代码段的坏处

宏定义最大的问题是,你看到的代码和编译器看到的代码是不同的,这中间有一个预处理器翻译的环节,再加上宏定义的作用域是全局的,有时候翻译的结果可能并不如你所想,因此代码编译或运行起来可能会有一些奇奇怪怪的错误,很难排查。

a) 举个例子

看下面的代码:

//compile with : g++ -o lambda_test_2 lambda_test_2.cpp

#include <stdio.h>

#define X 2
#define	Y 3+4

int main()
{
    printf("X * Y = %d\n", X * Y);

    return 0;
}

你觉得运行结果是什么?

答案是10而不是24:

看下预处理器翻译的代码你就了然了:

预处理器并没有计算3+4的结果与2相乘,而是直接把X、Y代表的文本进行了替换!这样的话,由于乘法的优先级比较高,2和3会先乘,然后再和4相加!

b) 再举个复杂点的例子

比如下面一段代码:

在阅读的时候就比较困难,像chg_flag、p_old、p_new、p_pkg等都要与具体的宏展开处的代码相关联,程序员需要用人脑将不同的代码段拼接起来,耗费脑力不说,还容易出错。

这段代码整体看起来还算清晰,对于一些一些有嵌套的宏定义代码段来讲,人脑拼接的时候会更困难,基本上代码跑通后就没人敢动了。所以为了少挨骂,还是不要写这么变态的代码了。

3.宏定义代码段的好处

宏定义也不是一无是处,如果自身的能力足够驾驭宏定义可能带来的坑,并且严格控制宏定义的使用场景以及代码规模的话,还是能带来运行效率和可读性的双重提升的。

提高运行效率,其实是使用宏定义代替函数,可以避免调用函数时保存和恢复现场的开销。这一点在C语言时代是比较重要的,但是在C++时代,其效率提升的功能可由内联函数代替,可读性大大提高,而性能又基本无损失。

提高可读性,其实是把一些简单到没必要写成函数的功能用宏定义代替了,以减少代码长度。比如下面这段代码:

但是像这种功能在现代C++中有更好的解决方法,那就是lambda表达式,这一点我们稍后讨论。

宏定义因其威力强大,可以突破语言的语法限制,用的好了确实能够达到出神入化的效果,但是需要相当的功力才行。据我所知,在开源领域还是有不少这种例子的。

比如linux内核中对list数据结构的操作就使用了大量的宏定义代码段(链接在此):

/**
 * list_for_each_entry	-	iterate over list of given type
 * @pos:	the type * to use as a loop cursor.
 * @head:	the head for your list.
 * @member:	the name of the list_head within the struct.
 */
#define list_for_each_entry(pos, head, member)				\
	for (pos = list_first_entry(head, typeof(*pos), member);	\
	     !list_entry_is_head(pos, head, member);			\
	     pos = list_next_entry(pos, member))

/**
 * list_for_each_entry_reverse - iterate backwards over list of given type.
 * @pos:	the type * to use as a loop cursor.
 * @head:	the head for your list.
 * @member:	the name of the list_head within the struct.
 */
#define list_for_each_entry_reverse(pos, head, member)			\
	for (pos = list_last_entry(head, typeof(*pos), member);		\
	     !list_entry_is_head(pos, head, member); 			\
	     pos = list_prev_entry(pos, member))

比如谷歌的gtest单元测试框架也使用了大量的宏定义,使得最终用户可以用很简洁的形式编写单元测试代码(链接在此):

#define EXPECT_TRUE(condition) \
  GTEST_TEST_BOOLEAN_(condition, #condition, false, true, \
                      GTEST_NONFATAL_FAILURE_)
#define EXPECT_FALSE(condition) \
  GTEST_TEST_BOOLEAN_(!(condition), #condition, true, false, \
                      GTEST_NONFATAL_FAILURE_)
#define ASSERT_TRUE(condition) \
  GTEST_TEST_BOOLEAN_(condition, #condition, false, true, \
                      GTEST_FATAL_FAILURE_)
#define ASSERT_FALSE(condition) \
  GTEST_TEST_BOOLEAN_(!(condition), #condition, true, false, \
                      GTEST_FATAL_FAILURE_)

4.lambda表达式的核心特性

a) 核心特性快速回顾

C++11标准引入了lambda表达式特性,使得C++往现代语言又前进了一大步。

lambda表达式是一种可调用对象,在C语言时代,可调用对象只有函数和函数指针两种,C++11之前,重载了函数调用运算符的类对象也算是可调用对象的一种。

我们可以将lambda表达式理解为一个未命名的内联函数。与任何函数类似,一个lambda表达式具有一个返回类型、一个参数列表和一个函数体:

[capture list] (parameter list) -> return type { function body }

其中,捕获列表(capture list)是一个lambda表达式所在函数中定义的局部变量的列表。返回类型、参数列表和函数体都与普通函数无异。

如无必要,lambda表达式可以省略参数列表和返回类型不写,但捕获列表和函数体必须包含:

auto lambda_fucn = [] { return 2*3; }

b) lambda表达式怎么用

lambda表达式定义在函数内,捕获的也是函数的局部变量,这一特性决定了lambda表达式非常适合封装那些函数体内较为繁琐的、重复出现的代码段。

lambda表达式自动捕获局部变量的特性,使得不用编写长长的参数列表即可使用一些变量,代码会因此变得简洁清晰,这一点是函数比不了的。

lambda表达式并不是来替代函数的,他其实是函数的一种补充,一些较为简单的,重复出现的,局部性的逻辑,可以使用lambda表达式实现,但较为复杂的,需要在较大范围内重用的逻辑,还得用函数实现。

5.使用样例

在实际的软件开发过程中,我们使用宏定义代码段,往往是用函数实现不了,或者不够经济。

宏定义突破语法限制对源代码进行文本替换的功能,别说函数,就是lambda表达式也替代不了,因此这部分功能该用还得用(但是除非非常有必要,尽量不要用)。

但是对于另一种情况,即用函数实现不够经济的情况,用lambda表达式往往能完美解决。

下面举几个例子:

a)函数比参数列表还长

比如像下面这段代码:

//compile with : g++ -o lambda_test_4 lambda_test_4.cpp -std=c++11

#include <stdio.h>

struct counter_t
{
    int val;
};

int sum_counter(counter_t &a, counter_t &b, counter_t &c, int multply_d)
{
    return (a.val + b.val + c.val) * multply_d;
}

int main()
{
    counter_t a, b, c;
    a.val = b.val = c.val = 1;

    printf("sum = %d\n", sum_counter(a , b, c, 2));

    auto lambda_sum_cnter = [&a, &b, &c](int multply_d)->int
        {
            return (a.val + b.val + c.val) * multply_d;
        };

    printf("sum = %d\n", lambda_sum_cnter(2));

    return 0;
}

像上述这种极其简单的逻辑,如果用函数写起来,参数列表比功能逻辑还长,用函数写显然不太经济。

而利用lambda表达式自动捕获局部变量的功能,我们让他自动捕获a、b、c三个变量,这样我们只需要传递一个参数multiply_d即可,看起来简洁了很多。

b) 局部性的重复逻辑

看下面这个例子:

//compile with : g++ -o lambda_test_5 lambda_test_5.cpp -std=c++11

#include <stdio.h>

#define FID_A       15
#define FID_B       6
#define FID_C       88
#define FID_D       23
#define FID_E       121

struct buffer_t
{
    int val_vec[5];
};

void copy_buf(buffer_t &buf_x, buffer_t &buf_y)
{
    if(buf_x.val_vec[0] != buf_y.val_vec[0])
    {
        //p_pkg->SetVal(buf_y.val_vec[0], FID_A);
        buf_x.val_vec[0] = buf_y.val_vec[0];
    }
    if(buf_x.val_vec[1] != buf_y.val_vec[1])
    {
        //p_pkg->SetVal(buf_y.val_vec[1], FID_B);
        buf_x.val_vec[1] = buf_y.val_vec[1];
    }
    if(buf_x.val_vec[2] != buf_y.val_vec[2])
    {
        //p_pkg->SetVal(buf_y.val_vec[2], FID_C);
        buf_x.val_vec[2] = buf_y.val_vec[2];
    }
    if(buf_x.val_vec[3] != buf_y.val_vec[3])
    {
        //p_pkg->SetVal(buf_y.val_vec[3], FID_D);
        buf_x.val_vec[3] = buf_y.val_vec[3];
    }
    if(buf_x.val_vec[4] != buf_y.val_vec[4])
    {
        //p_pkg->SetVal(buf_y.val_vec[4], FID_E);
        buf_x.val_vec[4] = buf_y.val_vec[4];
    }
}

void copy_buf_by_lambda(buffer_t &buf_x, buffer_t &buf_y)
{
    auto lambda_cpy_buf = [&buf_x, &buf_y](int idx, int fid)
        {
            if(buf_x.val_vec[idx] != buf_y.val_vec[idx])
            {
                //p_pkg->SetVal(buf_y.val_vec[idx], fid);
                buf_x.val_vec[idx] = buf_y.val_vec[idx];
            }
        };
    
    lambda_cpy_buf(0, FID_A);
    lambda_cpy_buf(1, FID_B);
    lambda_cpy_buf(2, FID_C);
    lambda_cpy_buf(3, FID_D);
    lambda_cpy_buf(4, FID_E);
}

int main()
{

    buffer_t buf_x, buf_y;

    //copy_buf(buf_x, buf_y);
    copy_buf_by_lambda(buf_x, buf_y);

    return 0;
}

我们的函数需要做一件事情,即检查buf_y的五个字段相对于buf_x是否有变化,如果有变化,把变化的字段填写到数据包中发出去。

我们提供了两个版本的实现,一个是用普通函数(注意五个FID是没有规律的,因此不能用循环),里面包含了大量的重复判断,但是又不能用循环实现。一个是用lambda表达式,捕获函数内的buf_x和buf_y变量,缩减了参数列表,同时又消除了重复部分,看起来非常简洁。

这个场景的关键是,局部重复。如果逻辑具有全局性,那么尽量用函数;如果逻辑不具备重复性,那么还不如将大函数拆解成小函数,用lambda表达式反而有点得不偿失。

c) 高级用法:拓展作用域

在上面的文本中,我们多次用加粗的黑体字提醒您,lambda函数是局部的,只能捕获函数的局部变量。到那时其实我们可以利用一个小技巧,实现将lambda表达式的作用域拓展到函数外面。

我们将上面的例子稍加改造,如下:

//compile with : g++ -o lambda_test_6 lambda_test_6.cpp -std=c++11

#include <stdio.h>
#include <functional>

#define FID_A       15
#define FID_B       6
#define FID_C       88
#define FID_D       23
#define FID_E       121

struct buffer_t
{
    int val_vec[5];
};

typedef std::function<void(int idx, int fid)> copy_func_t;

void copy_levels(copy_func_t cp_func)
{
    cp_func(0, FID_A);
    cp_func(1, FID_B);
    cp_func(2, FID_C);
    cp_func(3, FID_D);
    cp_func(4, FID_E);
}

void copy_buf_by_lambda(buffer_t &buf_x, buffer_t &buf_y)
{
    auto lambda_cpy_buf = [&buf_x, &buf_y](int idx, int fid)
        {
            if(buf_x.val_vec[idx] != buf_y.val_vec[idx])
            {
                //p_pkg->SetVal(buf_y.val_vec[idx], fid);
                buf_x.val_vec[idx] = buf_y.val_vec[idx];
            }
        };

    copy_levels(lambda_cpy_buf);
}

int main()
{

    buffer_t buf_x, buf_y;

    //copy_buf(buf_x, buf_y);
    copy_buf_by_lambda(buf_x, buf_y);

    return 0;
}

要点如下:

  • 我们用std::function定义了一个与lambda表达式参数列表相同的function类型(注意这里没有标明捕获的两个变量)
  • 我们再定义一个函数,参数是上面定义的funciton类型,内部直接调用参数中的function(注意这个函数中并没有看到lambda表达式捕获的两个变量)
  • 在原先的函数中,我们只定义lambda表达式,把定义的结果传递给另外的函数去调用

在上面的例子中,lambda捕获的变量在定义lambda表达式的函数中定义,但实际的使用确是在另外的函数中。相当于拓展了lambda表达式的作用范围。

这样有什么好处呢?

利用这个特性,我们可以把逻辑的实现集中起来,并且将逻辑的实现与调用分开。

对于一些较为通用的操作,我们可以定义一个入口函数,把实现以lambda表达式的形式集中写在入口函数中,这样方便将来维护;相关变量则统一传给入口函数,由lambda表达式捕获,这样入口函数调用下级函数的时候就不用写长长的参数列表了;最后入口函数将这些lambda表达式通过参数的形式传递给下级函数,由他们进行逻辑的调用。

6.并非万能

lambda表达式并非万能的,至少在以下两点上,lambda表达式尚不能取代宏定义代码段。

a) 不支持泛型

在前面的例子中,我们定的场景比较理想,buffer_t的所有的参数都是int类型的。而如果实际的开发工作中,重复代码存在,但参数不同,那我们就需要为每套参数都写一套lambda表达式,虽然他们的函数体一模一样。

这种情况下,lambda表达式消除重复代码的功能就大大削弱了。

好在C++20标准中预计加入泛型支持,让我们期待吧~~

而宏定义则完全不受限制,因为对宏定义来讲,就没有类型可言,何来泛型之说:

#define COPY_FIELD(OLD_VAL, NEW_VAL, FID) \
    if(OLD_VAL != NEW_VAL) \
    { \
        p_pkg->SetVal(NEW_VAL, FID); \
        OLD_VAL = NEW_VAL; \
    }

比如上面这段代码就可以适配N多种类型,消除重复代码。

b) 不能突破语言语法限制

举一个简单的场景,假设我们要操作的是两个结构体的同名参数,用宏定义就可以简单的写成下面这种形式,很简洁:

#define COPY_FIELD(FLD_NAME, FID) \
    if(old_buf.FLD_NAME != new_buf.FLD_NAME) \
    { \
        p_pkg->SetVal(new_buf.FLD_NAME, FID); \
        old_buf.FLD_NAME = new_buf.FLD_NAME; \
    }

//调用
COPY_FIELD(fld_a, FID_A);

因为宏定义是简单的文本替换,在你传入字段名称的时候,他会把下面的“对象名.字段名”补全,形成一段完整的代码。而lambda表达式就不行,必须直接完整的写出“对象名.字段名”,示例代码如下:

auto lambda_cpy_fld = [](int &old_val, int new_val, int fid)
    {
        if(old_val != new_val)
        {
            p_pkg->SetVal(new_val, fid);
            old_val = new_val;
        }
    };

lambda_cpy_fld(buf_x.fld_a, buf_y.fld_a, FID_A);

是不是看起来啰嗦多了?

7.参考资料

  • C++ Primer中文版 第五版 10.3.2

2 thoughts on “使用lambda表达式替换宏定义代码段

  1. 看这代码,我估计你是在上期技术工作的。方便的话 加个联系方式 哈哈

  2. 代码是之前预研用的老代码,具体工作抱歉不太方便透露,有问题可以随时留言,经常看网站

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注