C++ 17 constexpr 與 Lambda 表達式

C++ 17 將 Lambda Expression 納入常數表達式(constexpr)的範疇,所以我們也能在只接受編譯期常數的地方呼叫 Lambda Expression 定義的 Lambda Function:

void test() {
  auto f = [](int a, int b) constexpr {
    return a + b;
  };
  static_assert(f(1, 2) == 3, "");
}

本文會先回顧 C++ 11C++ 14 標準定義的 constexpr 再介紹 C++ 17 的改進。

C++ 11 constexpr

C++ 11 首次引入常數表達式的概念,讓我們能以 constexpr 定義編譯期常數

constexpr int size = 5;

int main() {
  static_assert(size > 0, "");
  int example[size] = {0};
}

同時,我們也能以 constexpr 定義「能於編譯期求值」的 Constexpr Function

#include <iostream>

constexpr int fib(int n) {
  return (n < 2) ? n : fib(n - 1) + fib(n - 2);  // #1
}

int main() {
  static_assert(fib(6) == 8, "");

  int input;
  if (std::cin >> input) {
    std::cout << fib(input) << std::endl;  // #2
  }
}

上面的程式碼有兩個重點:

  1. 在 C++ 11 標準下,Constexpr Function 只能有一個 Return 述句
  2. Constexpr Function 的實際參數可以是編譯期常數以外的數值。如果實際參數不是編譯期常數,該函式呼叫的求值(Evaluate)過程會延後至執行期(Run-time)。這個設計是讓「編譯期常數」與「一般數值」能共用同一份函式實作。

除此之外,我們也能以 constexpr 修飾建構式(Constructor)。例如:以下程式碼分別以 constexpr 修飾建構式與成員函式,接著在常數表達式當中建構 Rect 物件並呼叫 get_area() 成員函式(求值過程都於編譯期完成):

class Rect {
private:
  int height_;
  int width_;

public:
  constexpr Rect(int height, int width)
      : height_(height), width_(width) {}

  constexpr int get_area() const {
    return height_ * width_;
  }
};

int main() {
  static_assert(Rect(3, 5).get_area() == 15, "");
}

C++ 14 constexpr

C++ 14 稍微放寬 Constexpr Function 的限制。Constexpr Function 能使用更多種宣告或述句。例如前面的 fib 函式能以 for 述句改寫:

#include <iostream>

constexpr int fib(int n) {
  int a = 0;
  int b = 1;
  for (int i = 0; i < n; ++i) {
    int c = a + b;
    a = b;
    b = c;
  }
  return a;
}

int main() {
  static_assert(fib(6) == 8, "");

  int runtime_input;
  if (std::cin >> runtime_input) {
    std::cout << fib(runtime_input) << std::endl;
  }
}

然而因為 C++ 14 標準的制定時程,C++ 14 來不及將 Lambda Expression 納入常數表達式。呼叫 Lambda Function 的表達式也不被視為常數表達式:

constexpr int test_lambda() {
  auto f = []() {  // compile-time error
    return 3;
  };
  return f();  // compile-time error
}

作為替代方案,我們能以 Function Object 模擬 Lambda Function:

constexpr int test_func_obj() {
  class F {
  public:
    constexpr int operator()() {
      return 3;
    };
  };
  return F()();
};

C++ 17 constexpr 與 Lambda Expression

C++ 17 再次擴張常數表達式的涵蓋範圍,讓我們能在常數表達式裡面定義或呼叫 Lambda Function。

Lambda Expression 求值過程

為了能準確地講解 C++ 17 的改動,讓我先離題講解 Lambda Expression 的求值過程。編譯器在處理 Lambda Expression 時,會合成一個帶有 operator() 成員函式的 Closure Type(閉包型別)。被 Lambda Expression 捕捉的區域變數也會成為該 Closure Type 的成員變數。接著,Lambda Expression 會被代換為「以區域變數建構 Closure Object(閉包物件)」的代碼。以下面的函式為例:

#include <functional>

std::function<void (int)> test(int a, int b) {
  return [a, b](int x) {
    return a * x + b;
  };
}

概念上,編譯器會將上面的程式碼轉化為:

#include <functional>

class _ZZ4testiiENKUliE_ {
private:
  int a;
  int b;
public:
  _ZZ4testiiENKUliE_(int a_, int b_) : a(a_), b(b_) {}
  int operator()(int x) const {
    return a * x + b;
  }
};

std::function<void (int)> test(int a, int b) {
  return _ZZ4testiiENKUliE_(a, b);
}

另外,我們有時也會將 Closure Object 儲存於區域變數內:

std::function<void (int)> test(int a, int b) {
  auto f = [](int x) {
    return a * x + b;
  };
  return f;
}

這就相當於:

class _ZZ4testiiENKUliE_ { /* ... */ };

std::function<void (int)> test(int a, int b) {
  auto f = _ZZ4testiiENKUliE_(a, b);
  return f;
}

宣告 Constexpr Lambda Expression

為了能在常數表達式裡面定義或呼叫 Lambda Function,C++ 17 有以下改動:

  1. 如果 Lambda Expression 捕捉的區域變數是 Literal Type(常數表達式能使用的型別),則與 Lambda Expression 對應的 Closure Type 也會被視為 Literal Type。
  2. 新增 Constexpr Lambda Expression 的語法。
  3. 如果一個 Lambda Expression 滿足 Constexpr Function 的要求,即使沒有明確宣告,也能被編譯器推定為 Constexpr Lambda Expression。

首先,要明確地將一個 Lambda Expression 宣告為 Cosntexpr Lambda Expression,只需要在參數列表之後、回傳型別之前加上 constexpr 關鍵字(如果省略回傳型別,則要在大刮號之前加上):

void test() {
  auto fib = [](int n) constexpr -> int {  // #1
    int a = 0, b = 1;
    for (int i = 0; i < n; ++i) {
      int tmp = a + b;
      a = b;
      b = tmp;
    }
    return a;
  };

  static_assert(fib(5) == 5, "");
  static_assert(fib(6) == 8, "");
}

Constexpr Lambda Expression 和 Constexpr Function 一樣必需滿足以下規定[1]

  • 回傳型別是 Literal Type
  • 參數型別都是 Literal Type
  • 函式定義:
    • 沒有使用內嵌組合語言(Inline Assembly)
    • 沒有使用 goto 述句或 Label
    • 沒有使用 try ... catch ... 述句(C++ 20 會再修訂,參見 P1002R0
    • 沒有宣告或使用非 Literal Type 的變數或者使用 Thread Local Storage

如果一個 Lambda Expression 滿足上述規定,即使該 Lambda Expression 沒有被 constexpr 關鍵字修飾也能自動地被視為 Constexpr Lambda Expression

void test() {
  auto fib = [](int n) -> int {  // changed
    int a = 0, b = 1;
    for (int i = 0; i < n; ++i) {
      int tmp = a + b;
      a = b;
      b = tmp;
    }
    return a;
  };

  static_assert(fib(5) == 5, "");
  static_assert(fib(6) == 8, "");
}

Constexpr Lambda Expression 與編譯期錯誤

和 Constexpr Function 一樣,「Constexpr Lambda Expression 的參數」與「Constexpr Lambda Expression 捕捉的變數」都有可能不是編譯期常數。在那個情況下,Constexpr Lambda Expression 會退化為普通的 Lambda Expression

#include <iostream>

int main() {
  int x = 0;
  std::cin >> x;

  auto f = [x](int a, int b) constexpr {
    return a * x + b;
  };

  std::cout << f(2, 3) << std::endl;
}

當然,如果 Constexpr Lambda Expression 捕捉的變數不是編譯期常數,對應的 Closure Object 就不會是編譯期常數。以下的 Lambda Expression 捕捉了區域變數 x。因為 x 不是編譯期常數,Lambda Expression 產生的 Closure Object 也不會是編譯期常數。當我們將 Closure Object 指派給以 constexpr 修飾的區域變數 f 的時候,編譯器會回報錯誤:

int main() {
  int x = 0;
  constexpr auto f = [x]() { return x; };  // compile-time error
}

下面的範例雖然比較複雜,但也是相同的道理。因為 Constexpr Function 在參數不是編譯期常數的時候會退化為普通函式,那時候就不能把 Closure Object 指挀給以 constexpr 修飾的區域變數 f

constexpr int test(int x) {
  constexpr auto f = [x] { return x; };  // compile-time error
  return f();
}

以下程式碼有考慮退化的情況,test 函式的區域變數 f 可以接收「編譯期常數」與「一般數值」,所以可以正常運作:

#include <iostream>

constexpr int test(int x, int a, int b) {
  auto f = [x](int y) constexpr {
    return x * y;
  };
  return f(a) + f(b);
}

int main() {
  static_assert(test(1, 2, 3) == 5, "");
  static_assert(test(4, 5, 6) == 44, "");

  int a = 0;
  int b = 0;
  int c = 0;
  std::cin >> a >> b >> c;
  std::cout << test(a, b, c) << std::endl;
}

另外,如果編譯器在編譯期計算 Constexpr Lambda Expression 函式呼叫的過程中遇到「需要執行期資訊」的語言構件[2],編譯器會產生編譯期錯誤。例如:throw 述句或者 new 表達式都會造成編譯錯誤:

void test_compile_error() {
  auto f = [](int i) constexpr -> const char * {
    if (i > 0) {
      return new char[i];  // #1
    }
    throw i;  // #2
  };

  static_assert(f(1) != nullptr, "");  // compile-time error #1
  static_assert(f(0) != nullptr, "");  // compile-time error #2
}

不過如果計算 Constexpr Lambda Expression 函式呼叫的過程不會執行到「需要執行期資訊」的語言構件,就不會產生問題:

void test_ok() {
  auto f = [](int a, int b) constexpr {
    if (b == 0) {
      throw 0;  // not reached by f(13, 5)
    }
    return a / b;
  };
  static_assert(f(13, 5) == 2, "");  // fine
}

參考資料

  • P0170R1: Wording for Constexpr Lambda
  • N4487: Constexpr Lambda
  • N3337: Working Draft, Standard for Programming Language C++ (C++ 11 Draft); Section 5.19. Constant expressions; Section 7.1.5. The constexpr specifier
  • N4140: Working Draft, Standard for Programming Language C++ (C++ 14 Draft); Section 5.19. Constant expressions; Section 7.1.5. The constexpr specifier
  • N4659: Working Draft, Standard for Programming Language C++ (C++ 17 Draft); Section 8.1.5. Lambda expressions; Section 8.20. Constant expressions; Section 10.1.5. The constexpr specifier
  • P1018R1: Evolution status after Rapperswil 2018, "P1002R0, Try-catch blocks in constexpr functions was approved. A constexpr function can contain a try-catch, and constant evaluation is okay as long as an exception isn't thrown," cited from P1018R1.
  • P1002R0: Try-catch blocks in constexpr functions

[1]關於 Constexpr Function 的要求,請參閱 N4659 第 162 頁第 10.1.5 節 The constexpr specifier [dcl.constexpr] 第 3 款。
[2]關於不能於 Constexpr Lambda Expression 內部使用的語言構件,請參閱 N4659 第 139 頁第 8.20 節 Constant expressions [expr.const] 第 2 款。

Note

如果你喜歡這篇文章,請追蹤羅根學習筆記粉絲專頁。最新文章都會在粉絲專頁發佈通知。