理解 std::declval 和 decltype

hedzr發表於2021-10-27

std::declvaldecltype

題圖來自於 C++ Type Deduction Introduction - hacking C++ 但略有變形以適合 banner

關於 decltype

decltype(expr) 是一個 C++11 新增的關鍵字,它的作用是將實體或者表示式的型別求出來。

#include <iostream>
int main() {
  int i = 33;
  decltype(i) j = i * 2;
  std::cout << j;
}

它很簡單,無需額外解釋。

但如此簡單的一個東西,怎麼就需要新增一個關鍵字這麼大件事呢?還是超程式設計鬧的!超程式設計世界裡,長的懷疑人生的一串模板類宣告讓人崩潰,重複書寫它們更是累贅。例如一條執行時除錯日誌輸出:

image-20211018201558979

這不是我印象中最長的名稱,只是最順手就能擷取的一個引用。這樣的例子多的是。

借用我的 談 C++17 裡的 State 模式之二 也即 fsm-cxx 的使用例子稍加改寫來體現 decltype 的用處:

void test_state_meta() {
  machine_t<my_state, void, payload_t<my_state>> m;
  using M = decltype(m);
  // equals to: using M = machine_t<my_state, void, payload_t<my_state>>;

  // @formatter:off
  // states
  m.state().set(my_state::Initial).as_initial().build();
  // ...
}

顯然,using M = decltype(m) 更簡練,特別是當 machine_t<my_state, void, payload_t<my_state>> 可能是一超級長帶超級多模板引數的定義的字串時,decltype 的價值還會體現的更明顯。

在超程式設計裡,特別是涉及到大型類體系彼此糾結的情形時,很多時候可能不能不借助 decltype 的能力以及 auto 自動推導能力,因為在一個具體場景中可能我們不能預設具體型別會是什麼。

規範化的編碼風格

此外,善用 decltype 和 using 能夠為你的程式碼規範性和編碼省力性上貢獻力量。

在編寫一個類時,我們應該多加使用 using 提供的型別別名能力,當然同時這其中可能會涉及到對 decltype 的運用。

使用 using 的好處在於,可以提前顯式地催促編譯器進行相關型別推導,如果有錯可以在一組 using 語句處進行修正,不必在一大堆程式碼段落中去研究為何型別用錯。

用錯了型別又可能引發大堆程式碼的被迫改寫。

使用 using 還能幫助你減少程式碼段落修改。例如 using Container=std::list<T> 改為 using Container=std::vector<T> 時,你的已經寫就的程式碼段落乃至於 Container _container 宣告均可以一絲一毫不做修改,只需要重新編譯就夠。

本小節不給參考用例,因為那會喧賓奪主。而且時機不到,講給你聽也不起作用。

關於 std::declval

std::declval<T>() 也沒什麼好說的,它能返回型別 T 的右值引用參考。

但是 cppref 講的真是雲裡霧裡,說到底 declval 到底能幹什麼?它就是用於返回一個 T 物件的偽造例項,同時又具有右值引用參考。換句話說,它等價於下面的 objref 的編譯期態:

T obj{};
T &objref = obj{};

首先,它在詞法和語義上等價於 objref,是物件 T 的例項值,且具有 T&& 的型別;其次,它僅用於非求值的場合;再次,它並不真的存在。啥意思,說人話就是在編譯期中,需要一個值物件,但並不希望這個值物件被編譯為一個二進位制實體,那就用 declval 虛擬地構造一個,從而彷佛獲得了一個臨時物件,可以在該物件上施加操作,例如呼叫成員函式什麼的,但既然是虛擬的,就不會真的存在這麼個臨時物件,所以我稱之為偽例項。

我們常常並不真的直接需要 declval 求值求得的偽例項,更多的是需要藉助於這個偽例項來求取到相應的型別描述,也就是 T。所以一般情況下 declval 之外往往包圍著 decltype 計算,設法拿到 T 才是我們的真實目的:

#include <iostream>

namespace {
  struct base_t { virtual ~base_t(){} };

  template<class T>
    struct Base : public base_t {
      virtual T t() = 0;
    };

  template<class T>
    struct A : public Base<T> {
      ~A(){}
      virtual T t() override { std::cout << "A" << '\n'; return T{}; }
    };
}

int main() {
  decltype(std::declval<A<int>>().t()) a{}; // = int a;
  decltype(std::declval<Base<int>>().t()) b{}; // = int b;
  std::cout << a << ',' << b << '\n';
}

可以看到,A<int> 的偽例項能夠“呼叫” A 的成員函式 t(),然後藉助於 decltype 我們就可以拿到 t() 的返回型別,並用來宣告一個具體的變數 a。因為 t() 的返回型別為 T,所以 main() 函式中的這條變數宣告語句實際上等價於 int a{};

這個例子是為了幫助你理解 declval 的實際含義,例子本身是比較無意義的。

declval 的力量

declval(expr) 的核心力量在上面的例子中顯示的很明白:它不會對 expr 真正求值。所以你不必在 expr 處產生任何臨時物件,也不會因為表示式很複雜而發生真實的計算。這對於超程式設計的複雜環境是非常有用的。

下面的來自於某 ppt 的一頁還展示了表示式不必求值僅求型別的用例:

slide 14

FROM: HERE

但不僅如此,declval 的不求值還衍生出了進一步的力量。

無預設建構函式

如果一個類沒有定義預設建構函式,在超程式設計環境中可能是很麻煩的。例如下面的 decltype 就無法通過編譯:

struct A{
  A() = delete;
  int t(){ return 1; }
}

int main(){
  decltype(A().t()) i; // BAD
}

因為 A() 是不存在的。

但改用 declval 就能夠繞過問題了:

int main(){
  decltype(std::declval<A>().t()) i; // OK
}
純虛類

在純虛基類上有時候超程式設計會比較麻煩,這時候可能可以藉助 declval 來避開純虛基類不能例項化的問題。

在第一個示例中有相應的參考 decltype(std::declval<Base<int>>().t()) b{}; // = int b;

Refs

Tricks

上面的程式碼涉及到了一些慣用法,下面做一簡單的背景介紹,也包含一點點聯想延伸。

採用一個普通的抽象類作為基類

模板類的體系設計中,如果基類的程式碼、資料很多,可能會導致膨脹問題。一個解決方法是採用一個普通的基類,並在其基礎上建立模板化的基類:

struct base {
  virtual ~base_t(){}
  
  void operation() { do_sth(); }
  
  protected:
  virtual void do_sth() = 0;
};

template <class T>
  struct base_t: public base{
    protected:
    virtual void another() = 0;
  };

template <class T, class C=std::list<T>>
  struct vec_style: public base_t<T> {
    protected:
    void do_sth() override {}
    void another() override {}
    
    private:
    C _container{};
  };

這樣的寫法,可以將通用邏輯(不必泛型化的)抽出到 base 中,避免留在 base_t 中隨著泛型例項化而膨脹。

純虛類如何放入容器裡

順便也談談純虛類,抽象類,的容器化問題。

對於類體系設計,我們鼓勵基類純虛化,但這樣的純虛基類就無法放到 std::vector 等容器中了:

#include <iostream>

namespace {
  struct base {};

  template<class T>
    struct base_t : public base {
      virtual ~base_t(){}
      virtual T t() = 0;
    };

  template<class T>
    struct A : public base_t<T> {
      A(){}
      A(T const& t_): _t(t_) {}
      ~A(){}
      T _t{};
      virtual T t() override { std::cout << _t << '\n'; return _t; }
    };
}

std::vector<A<int>> vec; // BAD

int main() {
}

怎麼破?

這裡用 declval 是沒意義的,應該使用智慧指標來裝飾抽象基類:

std::vector<std::shared_ptr<base_t<int>>> vec;

int main(){
  vec.push_back(std::make_shared<A<int>>(1));
}
由於我們為泛型類 base_t 宣告瞭非泛型的基類 base,所以還可能採用 std::vector<base> 的方法,但這要求你將所有 virtual 介面都抽取到 base 中,那樣做的話,總會有一部分泛型介面無法抽取,所以這種方法有可能是行不通的。

如果覺得虛擬函式與其過載如此痛苦竟然不能忍的話,你可以考慮 談 C++17 裡的 Builder 模式 所介紹的 CRTP 慣用法的能力,CRTP 在模板類繼承體系中是個很強大的編譯期多型能力。

除此而外,還可以放棄基類抽象化的設計方案,改用所謂的執行時多型 trick 來設計類體系。

Runtime Polymorphism

這是一種由 Sean Parent 提供的 執行時多型 編碼技術:

#include <iostream>
#include <memory>
#include <string>
#include <vector>

class Animal {
 public:
  struct Interface {
    virtual std::string toString() const = 0;
    virtual ~Interface()                 = default;
  };
  std::shared_ptr<const Interface> _p;

 public:
  Animal(Interface* p) : _p(p) { }
  std::string toString() const { return _p->toString(); }
};

class Bird : public Animal::Interface {
 private:
  std::string _name;
  bool        _canFly;

 public:
  Bird(std::string name, bool canFly = true) : _name(name), _canFly(canFly) {}
  std::string toString() const override { return "I am a bird"; }
};

class Insect : public Animal::Interface {
 private:
  std::string _name;
  int         _numberOfLegs;

 public:
  Insect(std::string name, int numberOfLegs)
      : _name(name), _numberOfLegs(numberOfLegs) {}
  std::string toString() const override { return "I am an insect."; }
};

int main() {
  std::vector<Animal> creatures;

  creatures.emplace_back(new Bird("duck", true));
  creatures.emplace_back(new Bird("penguin", false));
  creatures.emplace_back(new Insect("spider", 8));
  creatures.emplace_back(new Insect("centipede", 44));

  // now iterate through the creatures and call their toString()

  for (int i = 0; i < creatures.size(); i++) {
    std::cout << creatures[i].toString() << '\n';
  }
}

其特點是基類不是基類,基類的巢狀類才是基類:Animal::Interface 才是用於類體系的抽象基類,它是純虛的,但卻不影響 std::vector<Animal> 的有效編譯與工作。Animal 使用簡單的轉接技術將 Animal::Interface 的介面(如 toString())對映出來,這種轉接有點像 Pimpl Trick,但也有一點微小的區別。

後記

總的一句話,declval 就是專門治那些無法例項化具體物件的場合的。

std::declval<T>() 也被典型地用在編譯期測試等用途,下一次有閒再做探討吧,那個話題太大了。

相關文章