C++23 成員函式明確的 this:deducing this

| | 0 Comments| 08:29
Categories:

在一般 C++ 類別的成員函式裡面,一般是都可以直接存取自己的其他成員;另外,也還有一個特別的 this 指標,會指向到自己、在必要時可以用來更明確地使用。

而在 C++23 在核心語言的部分、則是針對這部分則是一個強化、透過「explicit object parameter」(比較常見的是「deducing this」)、來讓開發者有更明確地方法,可以使用 this 這個指標。

他基本的使用形式是:

class CTest
{
public:
  int implicitFunc(                  int v);
  int explicitFunc(this CTest& self, int v);
};

在上面的 CTest 這個類別中,implicitFunc()explicitFunc() 這兩個成員函式的介面實際上是相同的;雖然 explicitFunc() 看起來多了一個名為 self 的引數,但是實際上在呼叫的時候並不需要傳入,而是和 implicitFunc() 一樣、傳入一個 int 即可。

explicitFunc() 的第一個引數,是 this CTest& self,重點是在前面多了一個 this;這代表它是用來取代本來的 this 指標的。

實際上把上面的程式寫完整一點,會變成像是下面的樣子:

#include <iostream>
 
class CTest
{
public:
  int implicitFunc(                  int v)
  {
    //return this->iVal + v;
    return iVal + v;
  }
 
  int explicitFunc(this CTest& self, int v)
  {
    return self.iVal + v;
  }
 
protected:
  int iVal = 0;
};
 
int main()
{
  CTest a;
  std::cout << a.implicitFunc(1) << std::endl;
  std::cout << a.explicitFunc(1) << std::endl;
}

在傳統的成員函式 implicitFunc() 中,可以直接去存取成員資料 iVal、也可以透過 this 這個指標來做存取。

而在 explicitFunc() 中,由於第一個引數是用來明確地取代 this,所以在函式內會變成不能使用 this 這個指標,要存取成員要透過 self 來操作。

在呼叫時,則是沒有任何差別,可以把第一個引數完全無視,系統會自己處理掉。


簡化 lvalue / rvalue、const / non-const 的對應

以上面的基本使用情境來說…好像看不出來有什麼用?不過實際上,self 的型別應該是得用 template 的形式來定義,才會讓他比較有用。

在《C++23’s Deducing this: what it is, why it is, how to use it》這篇裡有提到,透過這種 explicit object parameter 的寫法,基本上可以用來簡化對於同一個函式、針對 lvalue / rvalue、const / non-const 四種狀態的 overload。

他這邊舉的例子是 std::optional<>之前的介紹)。

本來針對呼叫的物件是 lvalue 或是 rvalue、還有是否有 const 性質、一個 value() 的函式可能會需要四種實作:

template <typename T>
class optional
{
  // version of value for non-const lvalues
  constexpr T& value()& {
    if (has_value())
      return this->m_value;
 
    throw bad_optional_access();
  }
 
  // version of value for const lvalues
  constexpr T const& value() const& {
    if (has_value())
      return this->m_value;
 
    throw bad_optional_access();
  }
 
  // version of value for non-const rvalues... are you bored yet?
  constexpr T&& value()&& {
    if (has_value())
      return std::move(this->m_value);
 
    throw bad_optional_access();
  }
 
  // you sure are by this point
  constexpr T const&& value() const&& {
    if (has_value())
      return std::move(this->m_value);
 
    throw bad_optional_access();
  }
  // ...
};

裡面基本上差異很小,所以會很繁瑣。而如果透過 explicit object parameter 的話,則可以把四個函式用一個 template 來做掉:

template <typename T>
struct optional {
  // One version of value which works for everything
  template <class Self>
  constexpr auto&& value(this Self&& self) {
    if (self.has_value())
      return std::forward<Self>(self).m_value;
 
    throw bad_optional_access();
  }
  // ...
};

透過這個寫法,可以一次對應上面四種不同的狀態。

而《C++23: Deducing This》這篇裡面,應該算是有比較清楚的說明:

#include <iostream>
 
struct Test {
  template <typename Self>
  void explicitCall(this Self&& self, const std::string& text) {
    std::cout << text << ": ";
    std::forward<Self>(self).implicitCall();
    std::cout << '\n';
  }
 
  void implicitCall()& {        std::cout << "non const lvalue"; }
  void implicitCall() const& {  std::cout << "const lvalue";     }
  void implicitCall()&& {       std::cout << "non const rvalue"; }
  void implicitCall() const&& { std::cout << "const rvalue";     }
};
 
int main()
{
  Test test;
  const Test constTest;
 
  test.explicitCall("test");                           // Self= Test&
  constTest.explicitCall("constTest");                 // Self= const Test&
  std::move(test).explicitCall("move(test)");          // Self= Test
  std::move(constTest).explicitCall("move(consTest)"); // Self= const Test
}

這邊的程式輸出的結果,會是:

test: non const lvalue
constTest: const lvalue
move(test): non const rvalue
move(consTest): const rvalue

可以看到,雖然呼叫的都是 explicitCall() 這個函式, 但是由於呼叫來源的物件會判定成不同的狀態,所以最後呼叫到的會是不同的實作。

理論上,如果想要讓自己定義的類別 getter 函式能對應各種狀況,都可以使用這樣的寫法;下面是個例子:

class cat {
  toy held_toy_;
 
public:
  template <class Self>
  auto&& get_held_toy(this Self&& self) {
    return std::forward<Self>(self).held_toy_;
  }
};

而也由於在使用 explicit object parameter 的形式來寫的狀況下,物件本身是否為 const 和是否為參考的狀態已經變成由 self 來負責了,所以這時候函示本身的後面是不能加上 cv-qualifier(const)或 ref-qualifier(&)的!

不過老實說,以往就算要寫 getter 函式好像也不會真的寫滿…所以個人對於這部分沒那麼有感覺就是了。


搭配 CRTP 的使用

如果是要寫 CRTP(curiously recurring template pattern) 的靜態多型的話,這個新語法或許算是比較實用了~

CRTP 的基本形式,會是像下面的樣子:

#include <iostream>
 
template <class T>
class Base
{
public:
  void interface()
  {
    static_cast<T*>(this)->implementation();
  }
};
 
class Derived : public Base<Derived>
{
public:
  void implementation() {}
};
 
int main()
{
  Derived d;
  d.interface();
}

基本上這邊會透過 Base 類別來定義出介面、但是要呼叫的時候需要把 this 轉換成實作的類別(Derived)來呼叫實作的函式。

而也因為在定義 Derived 時,需要繼承 Base<Derived> 才能完成這樣的操作,所以讓整個程式看起來有點詭異。

在這邊如果改成使用 C++23 的 deducing this 的話,則可以改寫成:

class Base
{
public:
  template<typename Self>
  void interface(this Self& self)
  {
    self.implementation();
  }
};
 
class Derived : public Base
{
public:
  void implementation() {}
};

這邊可以看到,基礎類別 Base 的 template 可以完全拿掉了~取而代之的,是用來作為介面的成員函式 interface() 要改成 explicit object parameter 的寫法、並需要搭配 template 來定義 self 的型別。

如此一來,當在透過 Derived 的物件來呼叫 interface() 這個函式的時候,Self 的型別就會是 Derived、所以就不需要特別再去轉換型別了~

而也由於在繼承的時候不需要把自身的型別當作 template 的引數,看起來好像也沒那麼詭異了。

不過這邊也要注意的,是這邊雖然看起來好像和用 virtual 函式的時候一樣、可以從 Base 的型別來呼叫 interface(),但是實際上是不行的!如果寫成下面的型式的話,是會編譯錯誤的!

Base* b = new Derived();
b->interface(); // ERROR!

所以呢,其實這樣的寫法和傳統的 CRTP 有一樣的限制(靜態多型的限制),那就是他雖然可以透過基礎型別來建立介面、但是並不能透過基礎型別來操作。


搭配 lambda

再來,這個新語法也可以搭配 lambda 來使用。

其中一個最直覺的應用,就是讓 lambda 的遞迴更好寫!下面就是一個簡單的例子:

auto Fibonacci = [](this auto&& self, int x) {
  if (x < 2)
    return x;

  return (self(x - 1) + self(x - 2));
};

能這樣玩的原因,主要是因為實際上 lambda 就是一個會產生對應的 operator() 的類別了。


以值的型態傳遞 this(pass this by value)

由於 explicit object parameter 的寫法可以自己去定義物件本身傳遞進來的型態,所以其實也可以不要使用參考、而是使用「值」的形式來傳遞的。

這樣有什麼好處呢?這邊基本上是牽扯到編譯器產生機器碼的最佳化了。以結論來說,就是在針對比較小型的物件,這樣寫在執行階段會比較快!

如果是使用一般的函式寫法的話,中間還會需要需要去 stack 裡面分配空間、儲存 this 的指標等等,步驟比較多;但是如果是使用 「pass this by value」的形式的話,這些步驟則可以省略、在產生出來的 assembly 裡面程式會明顯簡化很多。

以微軟給的範例(參考)來說,如果程式是:

struct just_a_little_guy {
  int how_smol;
  int uwu();
};
 
int main() {
  just_a_little_guy tiny_tim{ 42 };
  return tiny_tim.uwu();
}

那 MSVC 產生出來的組合語言會是:

sub     rsp, 40                           
lea     rcx, QWORD PTR tiny_tim$[rsp]
mov     DWORD PTR tiny_tim$[rsp], 42     
call    int just_a_little_guy::uwu(void)  
add     rsp, 40                            
ret     0

但是如果用 pass this by value 來定義 uwu() 的話,可以寫成:

struct just_a_little_guy {
  int how_smol;
  int uwu(this just_a_little_guy);
};

這時候他產生出來的組語就會變成:

mov     ecx, 42                           
jmp     static int just_a_little_guy::uwu(this just_a_little_guy)

感覺上,真的簡化不少啊~不過,Heresy 這邊其實沒有搞得很清楚,到底哪些情況下可以靠這種方法來加速就是了?


特化的 static?

最後,其實在個人來看,explicit object parameter 在各方面來看都有點像是特化的 static member function?

主要的差別大概就只有在要呼叫的時候、會和 static 函式不一樣吧?下面是一個例子:

#include <iostream>
 
class CTest
{
public:
  int implicitFunc(int v)
  {
    //return this->iVal + v;
    return iVal + v;
  }
 
  int explicitFunc(this CTest& self, int v)
  {
    return self.iVal + v;
  }
 
  static int staticFunc(CTest& self, int v)
  {
    return self.iVal + v;
  }
 
protected:
  int iVal = 0;
};
 
int main()
{
  CTest a;
  std::cout << a.implicitFunc(1) << std::endl;
  std::cout << a.explicitFunc(1) << std::endl;
  std::cout << CTest::staticFunc(a, 1) << std::endl;
}

而同時,explicit object parameter 的成員函式的指標,由於已經把本身(this)分離了,所以和一般的函式不一樣,也比較接近 static member function:

auto pif = &CTest::implicitFunc;
// int (__cdecl CTest::*)(int) __ptr64
std::cout << (a.*pif)(1) << "\n";

auto pef = &CTest::explicitFunc;
// int (__cdecl*)(class CTest & __ptr64,int)
std::cout << pef(a, 1) << "\n";

auto psf = &CTest::staticFunc;
// int (__cdecl*)(class CTest & __ptr64,int)
std::cout << psf(a, 1) << "\n";

如果想要統一呼叫的寫法的話,則可以使用 std::invoke() 這個函式(參考)。

而其他像是內部沒有 this、不能加 cv-qualifier/ref-qualifier 的特性,其實都和 static 是一致的。實際上,在 Visual Studio 裡面如果把 explicit object parameter 加上 const 的話,某些地方給的錯誤訊息也是「靜態成員函式不能有類型限定詞」。


這篇大概就這樣了?其實他還有一些其他的特性、功能,只是有的 Heresy 自己也沒搞得很懂,所以就先跳過了。 XD

而目前 C++23 雖然應該已經定稿了,但是實際上還算是在一個很早期的階段,各家編譯器的支援都很不完整;以這個新語法來說,目前應該只有 Visual Studio 有支援,g++ 和 clang 都還沒能支援。

所以就算要用,看來還是再等一段時間、看看各家編譯器的實作狀況再說吧~


參考:

Leave a Reply

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *