Hãy nghĩ tới một đầu bếp giỏi sơ chế trước. Anh ta có thể làm sẵn nước dùng từ đêm hôm trước, để khi khách gọi thì chỉ việc múc ra — nóng hổi, tức thì, gần như không tốn công lúc phục vụ. Nhưng "có thể" không phải "luôn luôn". Nếu bạn gọi một món đặc biệt với yêu cầu riêng mà anh ta không đoán trước được, anh ta vẫn phải nấu từ đầu ngay lúc đó, đủ cả các bước. constexpr trong C++ đúng là anh đầu bếp này: nó cho phép nấu sẵn lúc biên dịch, nhưng có thật sự nấu sẵn hay không lại tùy bạn gọi món kiểu gì.

Ở gập hằng và lan truyền hằng, compiler tự nhận ra các biểu thức hằng và tính sẵn. constexpr đẩy ý tưởng đó đi xa hơn: nó cho bạn yêu cầu compiler chạy cả một hàm — vòng lặp, đệ quy, tính bảng — ngay lúc biên dịch, để kết quả nằm sẵn trong nhị phân dưới dạng hằng số, không tốn một chu kỳ nào lúc chạy. Nghe như phép màu "0 chi phí". Tôi đo trong container gcc:13, và phát hiện chữ "constexpr" hứa ít hơn tôi tưởng.

constexpr cho phép, không bắt buộc

Điểm mấu chốt mà nhiều người bỏ qua: constexpr cho phép một hàm được tính lúc biên dịch, nhưng không bắt buộc điều đó xảy ra. Nó chỉ đảm bảo tính lúc biên dịch khi kết quả được dùng trong một ngữ cảnh hằng (constant context): khởi tạo một biến constexpr, làm tham số template, làm kích thước mảng, hoặc bên trong một hàm consteval. Nếu bạn gọi cùng hàm constexpr đó với một đối số động (giá trị chỉ biết lúc chạy), nó sẽ chạy lúc chạy như một hàm bình thường — vòng lặp và tất cả.

Tôi đo với g++ -O2 -std=c++17. Một hàm constexpr work(n) chạy một vòng 200.000 phép tính (cận dưới giới hạn vòng lặp constexpr của gcc, 262.144). Dùng nó theo hai cách.

Đo: literal 0,227 ns so với vòng 45625 ns

Cách một, ngữ cảnh hằng: constexpr long CV = work(200000);. Compiler chạy toàn bộ vòng 200.000 lần lúc biên dịch, ra kết quả 197769786911, và nướng thẳng con số đó vào nhị phân.

đọc hằng constexpr (CV) : 0,227 ns/lần

objdump xác nhận: hàm trả về CV chỉ gồm ba lệnh movk ghép con số 64-bit 197769786911 vào thanh ghi, rồi ret. Không có vòng lặp nào. Toàn bộ phép tính đã xong lúc biên dịch; lúc chạy chỉ còn việc đọc một literal — 0,227 ns, đúng bằng chi phí đọc một hằng số.

Cách hai, đầu vào động: gọi work(vn) với vn là biến volatile (compiler không biết giá trị).

tính lúc chạy (n động) : 45625 ns/lần   (0,2281 ns/phần tử)

objdump cho thấy hàm này có vòng lặp thật — nó chạy đủ 200.000 phép tính mỗi lần gọi. Cùng một hàm constexpr work, nhưng vì đối số động nên nó chạy lúc chạy, mất 45625 ns — chậm hơn phiên bản hằng khoảng 200.000 lần. constexpr không cứu được gì ở đây, vì ngữ cảnh không phải là hằng.

Tôi đã lẫn "khả năng" với "lời hứa"

Tôi vào đo với niềm tin phổ biến: "cứ đánh dấu constexpr là chắc chắn hàm được tính lúc biên dịch, 0 chi phí lúc chạy". Hóa ra tôi đã lẫn hai chuyện khác nhau: khả năng tính lúc biên dịch (constexpr cấp) và sự đảm bảo tính lúc biên dịch (chỉ ngữ cảnh hằng cấp). Cùng một hàm, đặt kết quả vào constexpr long CV thì nó thành literal 0,227 ns; gọi với vn động thì nó là vòng lặp 45625 ns. Bản thân từ khóa constexpr không quyết định — ngữ cảnh dùng mới quyết định.

Để thấy rõ cả phổ, tôi đo thêm hai biến thể ở hai đầu. Một, const thường (không constexpr): const long PV = work_plain(200000); với work_plain không đánh dấu constexpr. objdump cho thấy PV không phải literal — nó là một biến toàn cục, và giá trị được tính một lần lúc chương trình khởi động (dynamic initialization, qua một hàm GLOBAL__sub_I static-init), rồi các nơi dùng đọc từ biến đó. Nghĩa là không có constexpr, phép tính vẫn chạy — chỉ dời sang lúc startup thay vì compile-time. Hai, đầu kia của phổ, consteval (C++20): một hàm consteval bắt buộc phải tính lúc biên dịch — gọi nó với đối số động là lỗi biên dịch luôn. Đó là mức đảm bảo mạnh nhất, khi bạn muốn chắc chắn không gì lọt xuống lúc chạy.

Rút lại thì bài học nằm gọn trong một câu: một từ khóa mô tả khả năng không phải một lời hứa vô điều kiện về kết quả. constexpr mở cánh cửa tính-lúc-biên-dịch; bạn phải đặt kết quả vào đúng ngữ cảnh hằng thì cánh cửa mới thực sự dẫn tới cái literal 0 chi phí. Nếu tôi chỉ rắc constexpr khắp nơi rồi tin mọi thứ đã miễn phí, tôi đã nhầm — như bản work(vn) với 45625 ns chứng minh thẳng thừng.

constexpr mở đường cho các tối ưu khác

Khi một constexpr thành literal, nó không chỉ tiết kiệm phép tính — nó mở đường cho các tối ưu tiếp theo. Một giá trị đã là hằng compile-time sẽ được lan truyền hằng liên thủ tục vào mọi nơi dùng nó, cho phép gập thêm biểu thức, biến phép chia thành nhân, và thậm chí khử mã chết những nhánh mà với hằng này không bao giờ chạy. Ví dụ một if constexpr (CV > 0) sẽ bị compiler loại bỏ hẳn một nhánh ngay lúc biên dịch, không để lại một lệnh rẽ nhánh nào. Đây là lý do constexpr mạnh hơn nó thoạt nhìn: không phải nó chỉ tính sẵn một con số, mà con số hằng đó trở thành nguyên liệu cho cả một chuỗi tối ưu phía sau. Nhưng chuỗi đó chỉ khởi động khi giá trị thật sự là hằng compile-time — tức là khi bạn đặt nó vào ngữ cảnh hằng, đúng như phần đo đã cho thấy.

Về đến code hằng ngày

Điều đầu tiên: muốn chắc chắn 0 chi phí lúc chạy, hãy đặt kết quả vào ngữ cảnh hằng. Gán vào một biến constexpr, dùng làm tham số template hay kích thước mảng, hoặc dùng consteval cho những thứ phải tính lúc biên dịch. Chỉ đánh dấu hàm constexpr là chưa đủ nếu bạn vẫn gọi nó với dữ liệu chạy-mới-biết.

Điều thứ hai: hiểu ba mức "khi nào phép tính chạy". consteval — luôn compile-time; constexpr trong ngữ cảnh hằng — compile-time, ngoài ngữ cảnh hằng — runtime; const thường — thường là runtime (hoặc startup nếu compiler gập được). Chọn đúng mức cho ý định của bạn, đừng để một giả định sai biến một bảng tra tưởng-đã-tính-sẵn thành một vòng lặp chạy mỗi lần.

Và cái bẫy "có thể, nhưng không đảm bảo" này lặp lại ở khắp các ngôn ngữ, dưới những cái tên khác. Java: static final với một literal hằng được inline ngay lúc biên dịch (đến mức đổi hằng trong thư viện mà không biên dịch lại nơi dùng thì giá trị cũ vẫn kẹt lại) — nhưng static final gán bằng một lời gọi hàm thì chỉ chạy một lần lúc nạp lớp, y như cái const startup ở trên. JIT của JVM cũng chỉ "có thể" tối ưu một vòng nóng, và chỉ thật sự làm sau khi nó chạy đủ nhiều lần để được biên dịch. Go thì thẳng thắn tách bạch: const chỉ nhận biểu thức hằng compile-time thật, còn mọi thứ tính toán đều là runtime, không có vùng xám. Ngay cả CSDL cũng vậy — một GENERATED ALWAYS AS ... STORED tính sẵn và lưu, còn VIRTUAL thì tính lại mỗi lần đọc; cùng một công thức, chi phí nằm ở chỗ bạn khai nó "được lưu" hay không. Mẫu số chung: một nhãn nói cái gì có thể xảy ra không thay bạn quyết định nó có xảy ra — ngữ cảnh mới là thứ bật công tắc.

Muốn tự thấy thì viết constexpr long f(long n){ long s=0; for(long i=0;i<n;i++) s+=i; return s; } rồi tạo constexpr long V = f(100000);. Biên dịch g++ -O2 -S và tìm trong assembly — bạn sẽ thấy V chỉ là một con số literal, không có vòng nào của f. Giờ đổi thành volatile long n=100000; long V2 = f(n); và xem lại: f biến thành một vòng lặp thật, chạy lúc chạy. Cùng một hàm constexpr, hai kết quả assembly khác hẳn — chỉ vì đối số một bên là hằng, một bên động. Ba mươi giây đó cho bạn thấy constexpr là một khả năng có điều kiện, và ngữ cảnh mới là thứ bật hay tắt cái "0 chi phí".