前言
惰性編譯乃是 C++ 有別於絕大部分語言中最重要的特性,
這個特性使得 C++ 一定程度上以靜態編譯語言做到了一部分原本動態語言才能做到的事情,
同時還保一定程度的安全和效能。
隨後這個特性衍伸出了一種更獨特,名為 SFINAE 的機制
基於此機制,C++ 終於把 Concept 的技能樹點開......
本篇文章的先備知識需求:
- function pointer 相關應用
- 基礎 STL
- 基本 template 使用
函數型別的困境
關注以下幾種應用情境:
callback
#include <cstdio>
#include <cstdlib>
int cmpfunc (const void * a, const void * b) {
return ( *(int*)a - *(int*)b );
}
int main() {
int values[] = { 6, 4, 2, 1, 3, 5, 7};
const int n = sizeof(values) / sizeof(int);
printf("%d\n", n);
for(int i = 0 ; i < n; i++ ) {
printf("%d ", values[i]);
}
printf("\n");
qsort(values, n, sizeof(int), cmpfunc);
for(int i = 0 ; i < n; i++ ) {
printf("%d ", values[i]);
}
return 0;
}
stateful function
#include <cstdio>
int auto_inc() {
static int i = 0;
return i++;
}
int main() {
std::printf("%d\n", auto_inc());
std::printf("%d\n", auto_inc());
std::printf("%d\n", auto_inc());
return 0;
}
function table
#include <iostream>
#include <vector>
void slot0(int& n) {
std::cout << "slot0 called " << n++ << "\n";
}
void slot1(int& n) {
std::cout << "slot1 called " << n++ << "\n";
}
void slot2(int& n) {
std::cout << "slot2 called " << n++ << "\n";
}
int main() {
std::vector<void(*)(int&)> signal;
signal.push_back(slot0);
signal.push_back(slot1);
signal.push_back(slot2);
int i = 0;
for(auto& s : signal) {
s(i);
}
}
基本上這些例子都是用了同一套機制,也就是透過將函數指標指向一個函數之後,
透過傳遞該指標來達成「將函數變為參數」這一目的。
後來我們有了型別推導(模板函數、auto)
我們一樣用一個簡單的範例說明模板函數
#include <iostream>
#include <string>
template<class A, class B>
auto less(A a, B b) {
return a < b;
}
int main() {
std::string s0 = "zxcv";
std::string s1 = "asdf";
std::cout << less(10, 20) << std::endl;
std::cout << less(s0, s1) << std::endl;
}
像 less 這種模板函數寫著非常方便,我們不需要知道或假設任何具體型別就能實作演算法,
而關於 A, B 兩個型別是由編譯時期推得,所以執行期沒有額外代價。
那麼問題來了,我們怎麼用一樣的理念實現 callback?
#include <iostream>
template<class CB, class T>
auto foo(CB cb, T o) {
return cb(o);
}
template<class T>
auto callback(T o) {
std::cout << o << std::endl;
}
int main() {
foo(callback, 42);
}
以上這段代碼理念上是對的,實務上卻是錯的。
關鍵原因在於 foo 的型別參數 CB 在進行推導的時候由於函數 callback 無具體的型別所以 CB 無法推出正確的型別。
怎麼改?
頭痛醫頭,腳痛醫腳
這裡我們給一個直觀卻錯誤的解法。
既然原因出在 callback 那我們給 callback 一個具體型別就是了:
void callback(int o) {
// ...
}
搞定! build pass, runtime 看起來正確......
頭痛醫頭,醫完手斷了
我們思考一下原本的 callback 具備什麼樣的功能和限制:
template<class T>
auto callback(T o) {
std::cout << o << std::endl;
}
int main() {
callback(42);
callback(3.14);
callback("abcd");
callback(std::string("zxcv"));
// 能通過編譯的包括但不限於以上
}
基本上,但凡可以與 ostream 一同被 operator<< 運算的物件,
無論是任何型別都應該可以適用 callback 這個函數。
這就是 callback 這個函數的原始「功能」,而「頭痛醫頭」的做法實際上破壞了這個功能
這是一種副作用。
以有型包裹無型
以下給出一個不破壞代碼架構、功能的前提下解決問題的方案,具體一點來說就是:
callback(...)
這個 expression 必須保留。- 改動盡可能少的 function
- 保持參數無型
既然 CB 無法解析出型別,那麼我們就要給 callback 一個具體的型別,同時保持參數無型
#include <iostream>
template<class CB, class T>
auto foo(CB cb, T o) {
return cb(o);
}
struct Callback {
template<class T>
auto operator()(T o) {
std::cout << o << std::endl;
}
} callback;
int main() {
foo(callback, 42);
foo(callback, 3.14);
foo(callback, "abcd");
foo(callback, std::string("zxcv"));
}
It works! but why?
簡單來說 struct Callback { ... } ...
作為一個具體的型別給 CB 一個推導的根據,
而 operator()(T o)
中的 T 則拖延到了 foo
內部呼叫 cb
的當下才進行解析。
到這裡,演示本篇核心理念的代碼已經結束,
但你可能會發現 Callback 本身仍然是一種副作用,
因為它創造了一個本來沒有的符號。
事實上,直接刪掉 Callback 這個型別符號是不影響整個範例運行的,
這裡基於論述方便才保留了這個符號
讀者可以自行嘗試。
好像哪裡怪怪的......
精通 Java, C#, python 的大師們或許潛意識會有一些無法言表的違和感,
這個違和感的來源大約在於
為什麼 Callback 可以以一個成員不完整解析的狀態作為一個正常的型別符號被推導。
換言之,就是「完整型別」在編譯過程中的定義。
C++ 在編譯過程中對 template 的惰性處理使成員解析不完全的型別被視為「完整型別」成為一種可能。
至於可以到多不完整呢?
對於 C++ 來說,一個型別的成員物件是重要的,而成員函數其實無所謂,
也就是說,對於一個完整型別來說,成員物件是已解析且確定的。
基於這種特性,我們可以設計「成員編譯測試」來對型別作符號以外的檢查,
也就是 concept 語法在做的事情,
這件事情具體怎麼實行會在未來文章中提到。
2021/3/4 author by John Chang