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 trong C++ đẩy ý tưởng đó đi xa hơn: nó cho phép 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 một 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 vào đo, và phát hiện chữ "constexpr" hứa ít hơn tôi tưởng.

constexpr tính sẵn lúc biên dịch

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 trong container gcc:13 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ó 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 ở đây, vì ngữ cảnh không phải là hằng.

Một lần tôi đo hớ: "constexpr" hứa ít hơn tôi tưởng

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". Đo ra tôi đã lẫn giữa 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.

Để làm rõ hai đầu của phổ, tôi đo thêm hai biến thể. 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. Đó là mức đảm bảo mạnh nhất, khi bạn muốn chắc chắn không có gì lọt xuống lúc chạy.

Bài học đo lường: 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 rồi cho rằng 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 một con số sẵn, 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ì sao điều này quan trọng khi lập trình

Hệ quả đầ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.

Hệ quả 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 (compile-time 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.

Hệ quả thứ ba là bài học đo lường bao trùm: xác minh tối ưu bằng mã sinh ra, đừng tin cái tên. Con số mang theo: constexpr CHO PHÉP tính lúc biên dịch nhưng chỉ ĐẢM BẢO khi ở ngữ cảnh hằng (biến constexpr / tham số template / consteval) — khi đó kết quả là một literal (objdump: 3 movk, 0 vòng, đọc 0,227 ns); gọi cùng hàm với đầu vào động thì nó chạy lúc chạy y hệt hàm thường (có vòng, 45625 ns/lần), và consteval mới ép hẳn compile-time. constexpr là một cánh cửa, không phải một tấm vé — bạn phải bước qua nó ở đúng chỗ.

Thử ba mươi giây

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à 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í".