C++ lambda的重載

先說結論,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重載,雖然一般不推薦使用多繼承,但這裏的多繼承不會引發問題,而且可讀性能獲得很大提升,優勢很明顯,所以首選這種方案。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章