使用 Lambda 使代码更具表现力
- 一、Lambda VS. 仿函数
- 二、总结
一、Lambda VS. 仿函数
Lambda 是 C++11 中最引人注目的语言特性之一。它是一个强大的工具,但必须正确使用才能使代码更具表现力,而不是更难理解。
首先,要明确的是,Lambda 并没有为语言添加新的功能。任何可以用 Lambda 完成的事情,都可以用仿函数(Functor)来完成,虽然仿函数的语法更繁琐,需要更多的类型声明。
例如,比较检查一个整数集合中所有元素是否都在两个整数 a 和 b 之间的两种方式:
- 仿函数。
- Lambda 表达式。
仿函数版本:
class IsBetween
{
public:
IsBetween(int a, int b) : a_(a), b_(b) {}
bool operator()(int x) { return a_ <= x && x <= b_; }
private:
int a_;
int b_;
};
bool allBetweenAandB = std::all_of(numbers.begin(), numbers.end(), IsBetween(a, b));
Lambda 版本:
bool allBetweenAandB = std::all_of(numbers.begin(), numbers.end(),
[a,b](int x) { return a <= x && x <= b; });
很明显,Lambda 版本更简洁,更易于编写,这可能是 Lambda 在 C++ 中备受关注的原因。
对于像检查一个数字是否在两个边界之间这样简单的操作,许多人可能会同意 Lambda 是更好的选择。但也并非所有情况下都是如此。
除了编写和简洁性之外,在前面的例子中,Lambda 和仿函数之间的两个主要区别是:
- Lambda 没有名字。
- Lambda 不隐藏其代码,而是直接在调用点展示。
但是,通过调用具有有意义名称的函数将代码从调用点移出,是管理抽象级别的一种基本技巧。但是,上面的例子是可以接受的,因为这两个表达式:
IsBetween(a, b)
和
[a,b](int x) { return a <= x && x <= b; }
读起来很相似。它们的抽象级别是一致的。
但是,当代码变得更加复杂时,结果就会大不相同,以下例子将说明这一点。
一个表示盒子的类的例子,它可以根据尺寸和材质(金属、塑料、木材等)进行构建,并提供对盒子特性的访问:
class Box
{
public:
Box(double length, double width, double height, Material material);
double getVolume() const;
double getSidesSurface() const;
Material getMaterial() const;
private:
double length_;
double width_;
double height_;
Material material_;
};
有一个这样的盒子集合:
std::vector<Box> boxes = ....
想要选择能够安全地容纳某种产品(水、油、果汁等)的盒子。
通过一些物理推理,可以近似地将产品对盒子四个侧面的压力视为产品的重量,它分布在这些侧面的表面上。如果材料能够承受施加的压力,则盒子足够坚固。
假设材料可以承受的最大压力为:
class Material
{
public:
double getMaxPressure() const;
....
};
产品提供了它的密度,以便计算它的重量:
class Product
{
public:
double getDensity() const;
....
};
现在,要选择能够安全地容纳产品 product
的盒子,可以使用 STL 和 Lambda 编写以下代码:
std::vector<Box> goodBoxes;
std::copy_if(boxes.begin(), boxes.end(), std::back_inserter(goodBoxes),
[product](const Box& box)
{
const double volume = box.getVolume();
const double weight = volume * product.getDensity();
const double sidesSurface = box.getSidesSurface();
const double pressure = weight / sidesSurface;
const double maxPressure = box.getMaterial().getMaxPressure();
return pressure <= maxPressure;
});
以下是等效的仿函数定义:
class Resists
{
public:
explicit Resists(const Product& product) : product_(product) {}
bool operator()(const Box& box)
{
const double volume = box.getVolume();
const double weight = volume * product_.getDensity();
const double sidesSurface = box.getSidesSurface();
const double pressure = weight / sidesSurface;
const double maxPressure = box.getMaterial().getMaxPressure();
return pressure <= maxPressure;
}
private:
Product product_;
};
在主代码中:
std::vector<Box> goodBoxes;
std::copy_if(boxes.begin(), boxes.end(), std::back_inserter(goodBoxes), Resists(product));
尽管仿函数仍然需要更多的类型声明,但使用仿函数的算法代码行看起来比使用 Lambda 更清晰。不幸的是,对于 Lambda 版本来说,这一行代码更重要,因为它是主要代码。
在这里,Lambda 的问题在于它展示了如何进行盒子检查,而不是简单地说检查已经完成,因此它的抽象级别太低了。在该示例中,它会影响代码的可读性,因为它迫使读者深入 Lambda 的主体以弄清楚它做了什么,而不是简单地说明它做了什么。
在这里,有必要将代码从调用点隐藏,并为它赋予一个有意义的名称。仿函数在这方面做得更好。
但这是否意味着不应该在任何非平凡的情况下使用 Lambda?当然不是。
Lambda 被设计得比仿函数更轻便、更方便,同时仍然保持抽象级别有序。这里的技巧是通过使用中间函数将 Lambda 的代码隐藏在一个有意义的名称后面。以下是 C++14 中实现此目的的方法:
auto resists(const Product& product)
{
return [product](const Box& box)
{
const double volume = box.getVolume();
const double weight = volume * product.getDensity();
const double sidesSurface = box.getSidesSurface();
const double pressure = weight / sidesSurface;
const double maxPressure = box.getMaterial().getMaxPressure();
return pressure <= maxPressure;
};
}
在这里,Lambda 被封装在一个函数中,该函数只是创建它并返回它。这个函数的作用是将 Lambda 隐藏在一个有意义的名称后面。
以下是主代码,它从实现负担中解脱出来:
std::vector<Box> goodBoxes;
std::copy_if(boxes.begin(), boxes.end(), std::back_inserter(goodBoxes), resists(product));
现在,为了使代码更具表现力,在本文的其余部分使用范围(Range)而不是 STL 迭代器:
auto goodBoxes = boxes | ranges::view::filter(resists(product));
当调用算法周围有其他代码时,隐藏实现的必要性变得更加重要。为了说明这一点,添加一个要求,即盒子必须从用逗号分隔的文本测量描述(例如,“16,12.2,5”)和所有盒子的唯一材料进行初始化。
如果直接调用即时 Lambda,结果将如下所示:
auto goodBoxes = boxesDescriptions
| ranges::view::transform([material](std::string const& textualDescription)
{
std::vector<std::string> strSizes;
boost::split(strSizes, textualDescription, [](char c){ return c == ','; });
const auto sizes = strSizes | ranges::view::transform([](const std::string& s) {return std::stod(s); });
if (sizes.size() != 3) throw InvalidBoxDescription(textualDescription);
return Box(sizes[0], sizes[1], sizes[2], material);
})
| ranges::view::filter([product](Box const& box)
{
const double volume = box.getVolume();
const double weight = volume * product.getDensity();
const double sidesSurface = box.getSidesSurface();
const double pressure = weight / sidesSurface;
const double maxPressure = box.getMaterial().getMaxPressure();
return pressure <= maxPressure;
});
这变得非常难以阅读。但是,通过使用中间函数来封装 Lambda,代码将变成:
auto goodBoxes = textualDescriptions | ranges::view::transform(createBox(material))
| ranges::view::filter(resists(product));
这才是希望代码呈现的样子。
请注意,这种技术在 C++14 中有效,但在 C++11 中略有不同。
Lambda 的类型没有在标准中指定,而是由编译器的实现决定。这里,auto
作为返回值类型允许编译器将函数的返回值类型写为 Lambda 的类型。但在 C++11 中,不能这样做,因此需要指定一些返回值类型。Lambda 可以隐式转换为具有正确类型参数的 std::function
,并且可以在 STL 和范围算法中使用。请注意,std::function
会带来与堆分配和虚拟调用间接相关的额外成本。
在 C++11 中,resists
函数的建议代码将是:
std::function<bool(const Box&)> resists(const Product& product)
{
return [product](const Box& box)
{
const double volume = box.getVolume();
const double weight = volume * product.getDensity();
const double sidesSurface = box.getSidesSurface();
const double pressure = weight / sidesSurface;
const double maxPressure = box.getMaterial().getMaxPressure();
return pressure <= maxPressure;
};
}
请注意,在 C++11 和 C++14 的实现中,resists
函数返回的 Lambda 可能不会被复制,因为返回值优化可能会优化掉它。还要注意,返回 auto
的函数必须在其调用点可见。因此,这种技术最适合在与调用代码相同的文件中定义的 Lambda。
二、总结
- 对于对抽象级别透明的函数,请使用在调用点定义的匿名 Lambda。
- 否则,将 Lambda 封装在一个中间函数中。