使用 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 封裝在一個中間函數中。