C++裡也有菱形運算子?

apocelipes發表於2024-04-29

最近在翻《c++函數語言程式設計》的時候看到有一小節在說c++14新增了“菱形運算子”。我尋思c++裡好像沒什麼運算子叫這名字啊,而且c++14新增的功能很少,我也不記得有新增這種語法特性。一瞬間我有些懷疑我的記憶了,所以為了查漏補缺,我寫了這篇文章。

什麼是菱形運算子

這個概念在Java裡比較多見:

List<String> myList = new ArrayList<>();

這東西在Java裡的學名是diamond operator,表示使用泛型類並且型別引數在左側的表示式已給出因此在右側可以省略。

簡單的說就是讓你少寫幾次重複的型別引數。因為看起來像個菱形所以得名菱形運算子。

然後我們偶爾會在c++裡看到形狀上很相似的東西:

std::sort(vec.begin(), vec.end(), std::greater<>());

<>出現在模板的特化中是我們所熟悉的,但這個std::greater<>()是什麼呢?

c++沒有菱形運算子

先說結論,從語言標準來說,c++裡沒有什麼菱形運算子。

c++20裡雖然新增了一個運算子operator<=>,但這個和所謂的菱形運算子沒有任何關係。

那問題來了,std::greater<>()是什麼以及為什麼書裡說是c++14新增的特性呢?難道書裡瞎說的嗎?但事實是這樣的示例程式碼在c++14以及之後的標準下可以正常編譯執行,而且這本書的質量尚可,雖然會在措辭上犯些小錯(比如c++沒有菱形運算子)但不至於花大篇幅去胡說八道。

當然,要想回答這個問題我們得先複習點基礎知識。

<>在c++裡的作用

先說結論,在c++裡看到<>,絕大多數都是在為模板提供型別引數,當然這種東西我們不討論:(a<1, 2>b),這裡<>是在兩個不同的表示式裡。

那既然用來提供型別引數,那為什麼可以啥都不提供呢?答案是有兩類情況確實可以。

第一類是在函式模板上,型別引數可以自動推導時:

template <typename T>
void f(const T&)
{
    std::cout << "f<T>\n";
}
template <>
void f(const int&)
{
    std::cout << "f<int>\n";
}

void f(const int&)
{
    std::cout << "f\n";
}

int main()
{
    f(1);    // f
    f<>(1);  // f<int>
    f(1.2);  // f<T>
}

非模板函式在過載決議中的優先順序總是高於模板的,因此f(1)這樣的表示式總是會用到最下面定義的那個非模板函式f。這時候我們可以用f<int>(1)來直接呼叫函式模板f,而函式模板的型別引數如果能從引數推匯出來的話,可以不明確給出(也就是後面的f(1.2)那樣的),而在我們現在這句表示式裡,我們既要明確使用函式模板,又想讓型別引數被自動推導,就得使用f<>(1)

另一種情況不分類别範本還是函式模板,當模板的型別引數有預設值時,可以靠<>來使用這些預設值:

template <typename T = void>
struct Wrapper
{
    using wrappered = T;
};

// Wrapper<> 等於 Wrapper<void>
static_assert(std::is_same_v<Wrapper<>::wrappered, Wrapper<void>::wrappered>);

在第二種情況下,因為沒顯示給出型別引數,且這裡沒法使用型別推導,因此編譯器使用了型別引數的預設值,這裡是void。

觀察比較仔細的話其實會發現上面兩種情況其實是一件事,<>相當於沒有顯示給出任何型別引數,於是對這些沒有顯示指定的型別引數,編譯器會先嚐試型別推導,如果沒法推導則會檢查這些型別引數是否有預設值,有就利用預設值。如果上面這兩步都沒法得到能正常使用的型別引數,模板會被SFINAE淘汰或者報出編譯錯誤。

這並不是什麼新語法,是從有模板開始就一直存在的規則。

現在我們可以看看std::greater<>()是什麼了,首先std::greater是個類别範本,然後它接受一個型別引數,這個引數在c++14之後有了預設值void,因此std::greater<>()std::greater<void>()

c++14中究竟新增了什麼

既然c++14並沒有新增“菱形運算子”,那究竟新增了什麼呢?

在已經知道了std::greater<>()的真身後,找起來就很容易了,所以我很快找到了對應的新特性:n3421

這個特性是這樣的:原先我們要用標準庫提供的謂詞模板,需要自己指明引數型別,這樣寫起來很麻煩而且對於那種巢狀的或者元素型別複雜的容器來說寫明引數型別不僅費時而且費力,更要命的是對於map,一不小心是會有效能問題的:

for_each(map.begin(), map.end(), std::pred<std::pair<std::string, int64_t>>());

上述程式碼的問題在於正確的引數型別應該是std::pair<const std::string, int64_t>,我們漏掉了const,這會導致pair整個被複制一遍,效能是無比底下的。要徹底避免這種錯誤,就得利用自動型別推導。

然而前面說了,標準庫提供的謂詞基本全是類别範本,類别範本的模板引數要麼依賴預設值要麼得顯示指定,怎麼才能依賴自動推導呢。

於是這個新特性最精彩的地方來了:原先的模板的呼叫運算子不是模板引數也是定死的,但我們可以新加一個預設引數,然後針對這個預設引數的型別進行完全特化,在特化裡提供一個泛型的operator(),這樣就能利用函式模板來自動推導引數型別了,而且以前的程式碼不受影響。

預設引數的設定也是有講究的,需要用一個謂詞用不到的且不會影響老程式碼的型別,運氣不錯,void正好符合條件(void上幾乎沒法做什麼操作,因此也不會被指定給這些謂詞做型別引數),因此現在的greater的程式碼是下面這樣的:

// 注意預設值是void
template <typename T = void> struct greater {
    constexpr bool operator()(const T& lhs, const T& rhs) const 
    {
        return lhs > rhs;
    }
};

// 針對greater<void>的完全特化
template <> struct greater<void> {
    template <class T, class U> auto operator()(T&& t, U&& u) const
    -> decltype(std::forward<T>(t) > std::forward<U>(u))
    { return std::forward<T>(t) > std::forward<U>(u); }
};

當使用std::greater<T>()的時候,程式碼的邏輯和原來一樣,當使用std::greater<void>()的時候,返回的Functor的函式呼叫運算子是個模板,可以自己推導引數型別和返回值型別。至於為啥greater<void>的內部構造可以和其他情況例項化的greater區別這麼大,這個是c++的特性:模板的不同例項之間是可以異構的。

而且因為型別引數的預設值就是void,因此可以簡寫成std::greater<>()

所以c++14只是給標準庫裡可以代替運算子的模板們增加了預設型別引數和一個泛型的呼叫運算子,利用這些可以簡化程式碼並確保型別安全。

真相是其實沒啥菱形運算子,只是利用了以前就存在的模板的特性簡化了標準庫的使用,讓人少寫點字。達成的效果倒是和Java的菱形運算子差不多。

總結

顯然書裡有誇大成分,老話說盡信書不如無書,還得小心檢驗才是。

順便我們複習了現代c++的重要原則:能依賴自動型別推導的地方,沒必要自己手寫。

因此應該多寫這樣的程式碼:std::sort(vec.begin(), vec.end(), std::greater<>());

不過還有最後一個問題,為啥不直接用lambda呢?那是因為能指定型別引數的泛型lambda要在c++20才出現,在這之前想要讓lambda完全做到型別安全得費點功夫,而且lambda整體上也不如直接用標準庫提供的std::greater<>()std::less<>()之類的簡潔易懂。

相關文章