[讀書筆記]Universal References in C++11 part 6: 儘量避免overloading

本篇主要參考effective modern C++ :

Item 17: Understand special member function generation.

Item 26: Avoid overloading on universal references.

Item 27: Familiarize yourself with alternatives to overloading on universal references.

 

本文開始

在理解完Universal Reference(UR)的強大之處後,應該有很多人躍躍欲試的想把這個概念導入到自己撰寫的程式碼裡面吧?不過這東西也不是沒有它的限制。由於template編譯期動態產生程式碼的特性,因此編譯器會針對傳入的參數,非常積極的去產生對應的程式碼。

雖然上一篇我們談到了該怎麼樣用Type Trait在編譯期防止使用者傳入非預期的參數型態,但那其實只是一種防呆機制,實際上的問題在於別處:若是我們試圖去overload 該function的話,那麼將會有高達八成的使用情境程式會呼叫到UR版本。為什麼會這樣呢?讓我們先來看看以下這個正常運作的例子:

 

class Resource{
public:
    Resource(){};
    ~Resource(){};
private:
    void memberwiseCopy(const Resource& other){
        m_data = other.m_data;
    }
public:
    std::string m_data;
};

class SpecialResource : public Resource {};


class URWidget{
public:
    URWidget(){};
    template< typename T >
    void setResource(T&& newResoure) // universal reference
    {
      static_assert(std::is_convertible<T, decltype(m_resourcePtr) >::value,
            "T can't assign to m_resourcePtr");
      m_resourcePtr = std::forward<T>(newResoure);
    }
    void setResource(std::shared_ptr<Resource>& newResourePtr) 
    {
        m_resourcePtr = newResourePtr;
    }
    void setResource(std::shared_ptr<Resource>&& newResourePtr)
    {
        m_resourcePtr = std::move(newResourePtr);
    }
private:
    std::shared_ptr<Resource> m_resourcePtr;

};
int main(){
    URWidget widget;
    auto newRes1 = std::make_shared<Resource>();
    widget.setResource(newRes1);
    return 0;

}

根據overload resolution的規則,雖然我們知道當這個function有二個同樣的type signature的時候,會偏好使用非template產生的版本,因此上面這個例子會正確的呼叫到void setResource(std::shared_ptr<Resource>& newResourePtr)這個版本。但是如果我們把SpecialResource這個type丟進去的話會怎麼樣?

int main(){
    auto newRes2 = std::make_shared<SpecialResource>();
    widget.setResource(newRes3);
    return 0;
}

真糟糕!居然去呼叫到UR版本了。為什麼會這樣?這是由於template根據我們傳入的參數,自動的產生了下面這個更符合呼叫型態的function:

 

void setResource(std::shared_ptr<SpecialResource>& newResourePtr);

 

這樣的話不就是要我們在overloading跟繼承之間選一個放棄的意思嗎?當然,我們不可能放棄使用繼承,那麼唯一能捨棄的就是overloading了。因為 就算沒有這個特性,我們還是可以從架構的設計面上來解決這個問題。通常主要解決有以下這幾種:

  • 使用別的名字:這個方法比較單純,就是避掉overload,例如,改名為setSpecialResource。
  • 放棄使用UR,改用Pass by const T&:這個方法是走回C++11之前的老路,雖然效能跟有move的情況比起來不高,但若是能夠減少設計方法時的複雜性,其實也是可以考慮採用。
  • 放棄使用UR,使用Pass by value:這個方法比較在以前是強烈被禁止的,但在C++11加入move semantics之後就有一些使用這個設計的空間。因為這個方法提供了我們所想要的設計:對於lvalue制、rvalue進行move。例如下面這個例子:

 

void setResourceByValue(std::shared_ptr<Resource> newResourePtr){
    m_resourcePtr = std::move(newResourePtr);
}

URWidget widget4;
auto newRes4 = std::make_shared<Resource2>();
widget4.setResourceByValue(newRes4); //copy to newResourePtr
widget4.setResourceByValue(std::move(newRes4)); //move to newResourePtr

 

只是跟方法2比起來,在rvalue的狀況下,我們必須要進行二次的move,因此這個方法通常只適用於move操作的成本不高,以及一定會將傳進去的value轉移給對應member的情況下。如果一個類別裡面都沒有用free storage宣告出來的空間,那麼move其實就等同於copy。另外還需注意使用這個方法時Object Slicing的問題。

 

 

  • Use Tag dispatch:這個方法的基本概念,其實就是用別的名字來區分該呼叫的版本,只是,跟方法一比起來,這裡對外的介面(public method)使用的還是UR版本,只是在方法內部的實作(private method)我們使用了一些編譯期的Type Trait來幫助我們。假設我們的需求是在把值塞給resourcePtr之前,對於SpecialResource跟其他的Resource Type進行不同的操作。因此在使用Tag dispatch的技巧時我們的實作如下:

 

public:
    template<typename T>
    void setResourceTagDispatch(T&& newResoure) // universal reference
    {
    static_assert(std::is_convertible<T, decltype(m_resourcePtr) >::value,
            "T can't assign to m_resourcePtr");
        setResourceTagDispatchImp(std::forward<T>(newResoure),
                                  std::is_base_of<std::shared_ptr<SpecialResource>, 
                                                  typename std::decay<T>::type >());   
    }
private:
    template<typename T>
    void setResourceTagDispatchImp(T&& newResoure, std::true_type){
        std::cout << "do something for SpecialResource type" << std::endl;
        m_resourcePtr = std::forward<T>(newResoure);  
    }
    template<typename T>
    void setResourceTagDispatchImp(T&& newResoure, std::false_type){
        std::cout << "do something for other Resource type" << std::endl;
        m_resourcePtr = std::forward<T>(newResoure);
    }

   //case 5: tag dispatch
    URWidget widget5;
    auto newRes5_1 = std::make_shared<Resource>();
    widget5.setResourceTagDispatch(newRes5_1); //copy to newResourePtr
    auto newRes5_2 = std::make_shared<SpecialResource>();
    widget5.setResourceTagDispatch(newRes5_2); //move to newResourePtr

從上面的程式碼我們可以看到,我們對外的介面還是setResourceTagDispatch,只是內部的實作分為二個setResourceTagDispatchImp:一個是當傳入的T如果是從SpecialResource繼承下來的情況(std::is_base_of),其tag標記為std::true_type,反之則是std::false_type。編譯器會在編譯期根據傳入的參數,動態的決定要呼叫哪份實作。至於為什麼要加入std::decay<T>::type?這是由於使用者可能會傳入以下的參數:

const auto&& newRes5_2 = std::make_shared<SpecialResource>();
widget5.setResourceTagDispatch(newRes5_2); //move to newResourePtr


std::decay的用途,就是用來把const跟reference移除,若是沒有加上這個的話,則這段程式碼將會去呼叫到std::false_type的版本。

理論上使用上面幾個方法可以解決大部份的使用情況。但若是還是不想放棄overloading的話,其實還是有辦法可以處理的。基本概念是在編譯期針對想要overload的特定型態參數,將UR版本從candidate function中移除。該怎麼做到這件事?跟tag dispatch相同,我們一樣需要藉助type trait來幫忙我們達成這件事。讓我們先瞄一下這段程式會長怎麼樣吧:

 

template<
    typename T,
    typename = std::enable_if_t<
    !std::is_convertible<std::shared_ptr<SpecialResource>, std::decay_t<T>>::value
    >
>
void setResource(T&& newResoure) // universal reference
{
   static_assert(std::is_convertible<T, decltype(m_resourcePtr) >::value,
        "T can't assign to m_resourcePtr");  
     m_resourcePtr = std::forward<T>(newResoure);
}     

void setResource(const std::shared_ptr<Resource>& newResourePtr)
{
    m_resourcePtr = newResourePtr;
}


被嚇到了嗎?先不用急著放棄思考。我第一次看到的時候也是腦筋一片空白,完全無法看懂這是什麼外星語言。其實使用到大部份的type trait概念,在前面我們已經都有使用過了,唯一沒看過的便是 std::enable_if_t。這個方法其實就是用來決定我們要不要將這個template function從candidate中移除。

在這裡我們使用的條件是 !std::is_convertible<std::shared_ptr<SpecialResource>, std::decay_t<T>>::value,也就是說,當SpecialResource無法assign給傳進來T的時候我們才啟用這個UR function,換句話說,當我們傳進來的參數是SpecialResource版本的時候,這個UR function就會從我們的binary code中移除,因此自然而然的就會去找到overloading的 void setResource(const std::shared_ptr<Resource>& newResourePtr)版本。

讓我們試試用下面這三種case呼叫看看吧:

 

//case 1
URWidget widget;
auto newRes1 = std::make_shared<Resource>();
// call void setResource(const std::shared_ptr<Resource>& newResourePtr)
widget.setResource(newRes1);   //case 2
URWidget widget2;
auto newRes2 = std::make_shared<SpecialResource>(); 
// call void setResource(const std::shared_ptr<Resource>& newResourePtr)
widget2.setResource(newRes2);
//case 3
URWidget widget3;
auto newRes3 = std::make_shared<Resource2>();
//call UR Version
widget3.setResource(newRes3);

嗯,一切如同我們預期,只有case 3會去呼叫到UR版本。

==================================================================

寫在最後:

文章到這裡,其實我們已經大致掌握住了現代C++的設計精髓:Template metaprogramming。這些技巧不僅貫穿了整個標準庫,也是為什麼C++能夠這麼有效率運行。當我們了解這些東西越多,也就越能幫助我們了解未來C++所追求的編程之道到底是什麼。

說句白話一點的:我終於可以開始看懂標準庫在寫什麼鬼東西了!!!

dorgon

dorgon

職業:LV3遊戲軟體工程師 為了追尋小時候玩遊戲的感動,而一頭栽入遊戲業界。 本來以撰寫遊戲劇本為主要志向,但回過神來才發現已經踏入程序猿的不歸路。 專長為client端跨平台遊戲開發架構與自動建置流程,主要使用引擎為cocos2d-x與UnrealEngine4。

More Posts - Website

Follow Me:
FacebookLinkedIn

有什麼想法嗎?請發表你的看法