C++ lambda的過載

apocelipes發表於2024-05-05

先說結論,lambda是不能過載的(至少到c++23依舊如此,以後會怎麼樣沒人知道)。而且即使程式碼完全一樣的兩個lambda也會有完全不同的型別。

但雖然不能直接實現lambda過載,我們有辦法去模擬。

在介紹怎麼模擬之前,我們先看看c++裡的functor是怎麼過載的。

首先類的函式呼叫運算子是可以過載的,可以這樣寫:

struct Functor {
    bool operator()(int i) const
    {
        return i % 2 == 0;
    }

    bool operator()(const std::string &s) const
    {
        return s.size() % 2 == 0;
    }
};

在此基礎上,c++11還引入了using的新用法,可以把基類的方法提升至子類中,子類無需手動重寫就可直接使用這些基類的方法:

struct IntFunctor {
    bool operator()(int i) const
    {
        return i % 2 == 0;
    }
};

struct StrFunctor {
    bool operator()(const std::string &s) const
    {
        return s.size() % 2 == 0;
    }
};

struct Functor: IntFunctor, StrFunctor {
    // 不需要給出完整的簽名,給出名字就可以了
    // 如果在基類中這個名字已經有過載,所有過載的方法也會被引入
    using IntFunctor::operator();
    using StrFunctor::operator();
};

auto f = Functor{};

現在Functor可以直接使用bool operator()(const std::string &s)bool operator()(int i)了。

現在可以看看怎麼模擬lambda過載了:我們知道c++標準要求編譯器把lambda轉換成類似上面的Functor的東西,因此也能使用上面的辦法模擬過載。

但還有兩個致命問題:第一是需要寫明需要繼承的lambda的型別,這個當然除了模板之外是做不到的;第二是繼承的基類的數量得明確給出這限制了靈活性,但可以用c++11新增的新特性——變長模板引數來解決。

解決上面兩個問題其實很簡單,方案如下:

template <typename... Ts>
struct Functor: Ts...
{
    using Ts::operator()...;
};

auto f = Functor<StrFunctor, IntFunctor>{};

使用變長模板引數後就可以繼承任意多的類了,然後再使用...在類的內部逐個引入基類的函式呼叫運算子。

這樣把繼承的物件從普通的類改成lambda就可以模擬過載。但是怎麼做呢,前面說了我們沒法直接拿到lambda的型別,用decltype的話又會非常囉嗦。

答案是可以依賴c++17的新特性:CTAD。簡單得說就是可以提前指定規則,讓編譯器從建構函式或者符合要求的構造方式裡推導需要的型別引數。於是可以這樣寫:

template <typename... Ts>
Functor(Ts...) -> Functor<Ts...>;

箭頭左邊的是建構函式,右邊的是推匯出來的型別。

現在又有疑問了,Functor裡不是沒定義過任何建構函式嗎?是的,正是因為沒有定義,使得Functor符合條件成為了“聚合”(aggregate)。“聚合”可以做聚合初始化,形式類似:聚合{基類1初始化,基類2初始化, ...,成員變數1的值,成員變數2的值...}

作為一種符合要求的初始化方式,也可以使用CTAD,但形式上會用圓括號包起來導致看著像建構函式。另外對於聚合,c++20會自動生成和上面一樣的CTAD規則無需再手寫。

現在把所有程式碼組合起來:

template <typename... Ts>
struct Functor: Ts...
{
    using Ts::operator()...;
};

int main()
{
    const double num = 2.0;
    auto f = Functor{
        [](int i) { return i+1; },
        [&num](double d) { return d+num; },
        [s = std::string{}](const std::string &data) mutable {
            s = data + s;
            return s;
        }
    };

    std::cout << f(1) << '\n';
    std::cout << f(1.0) << '\n';
    std::cout << f("apocelipes!") << '\n';
    std::cout << f("Hello, ") << '\n';
    // Output:
    // 2
    // 3
    // apocelipes!
    // Hello, apocelipes!
}

有沒有替代方案?c++17之後是有的,可以利用if constexpr或者if consteval對型別分別進行處理,編譯器編譯時會忽略其他分支,實際上這不是過載,但實現了類似的效果:

int main()
{
    auto f = []template <typename T>(T t) {
        if constexpr (std::is_same_v<T, int>) {
            return t + 1;
        }
        else if constexpr (std::is_same_v<T, std::string>) {
            return "Hello, " + t;
        }
        else {
            return t;
        }
    };
    std::cout << f(1) << '\n';
    std::cout << f("apocelipes") << '\n';
    std::cout << f(1.2) << '\n';
    // Output:
    // 2
    // Hello, apocelipes
    // 1.2
}

要注意的是這裡的f本身並不是模板,f的operator()才是。這個方案除了囉嗦之外和上面靠繼承的方案沒有太大區別。

lambda過載有啥用呢?目前一大用處是可以簡化std::visit的使用:

std::variant<int, long, double, std::string> v;
// 對v一頓操作
std::visit(Functor{
    [](int arg) { std::cout << arg << ' '; },
    [](long arg) { std::cout << arg << ' '; },
    [](double arg) { std::cout << std::fixed << arg << ' '; },
    [](const std::string& arg) { std::cout << std::quoted(arg) << ' '; }
}, v);

這個場景中需要一個callable物件,同時需要它的呼叫運算子有對應型別的過載,在這裡不能直接用模板,所以我們的模擬lambda過載派上了用場。

如果要我推薦的話,我會選擇繼承的方式實現lambda過載,雖然一般不推薦使用多繼承,但這裡的多繼承不會引發問題,而且可讀效能獲得很大提升,優勢很明顯,所以首選這種方案。

相關文章