C++泛型与多态(4): Duck Typing - 简书
James Whitcomb Riley
在描述这种is-a
的哲学时,使用了所谓的鸭子测试(Duck Test
):
当我看到一只鸟走路像鸭子,游泳像鸭子,叫声像鸭子,那我就把它叫做鸭子。(When I see a bird that walks like a duck and swims like a duck and quacks like a duck, I call that bird a duck.)
鸭子测试
Duck Typing不
是动态语言的专利。C++
作为一门强类型的静态语言,也对此特性有着强有力的支持。只不过,这种支持不是运行时,而是编译时。
其实现的方式为:一个模板类或模版函数,会要求其实例化的类型必须具备某种特征,如某个函数签名,某个类型定义,某个成员变量等等。如果特征不具备,编译器会报错。
通过之前的解释我们不难发现,Duck Typing
要表达的多态语义如下图所示:
DuckTyping的语义
适配器:类型萃取
Duck Typing
需要实例化的类型具备一致的特征,而模板特化的作用正是为了让不同类型具有统一的特征(统一的操作界面),所以模板特化可以作为Duck Typing
与实例化类型之间的适配器。这种模板特化手段称为萃取(Traits
),其中类型萃取最为常见,毕竟类型是模板元编程的核心元素。
所以,类型萃取首先是一种非侵入性的中间层。否则,这些特征就必须被实例化类型提供,而就意味着,当一个实例化类型需要复用多个Duck Typing
模板时,就需要迎合多种特征,从而让自己经常被修改,并逐渐变得庞大和难以理解。
Type Traits的语义
另外,一个Duck Typing
模板,比如一个通用算法,需要实例化类型提供一些特征时,如果一个类型是类,则是一件很容易的事情,因为你可以在一个类里定义任何需要的特征。但如果一个基本类型也想复用此通用算法,由于基本类型无法靠自己提供算法所需要的特征,就必须借助于类型萃取。
结论
这四篇文章所介绍的,就是C++
泛型编程的全部关键知识。
从中可以看出,泛型是一种多态技术。而多态的核心目的是为了消除重复,隔离变化,提高系统的正交性。因而,泛型编程不仅不应该被看做奇技淫巧,而是任何一个追求高效的C++
工程师都应该掌握的技术。
同时,我们也可以看出,相关的思想在其它范式和语言中(FP
,动态语言)也都存在。因而,对于其它范式和语言的学习,也会有助于更加深刻的理解泛型,从而正确的使用范型。
最后给出关于泛型的缺点:
- 复杂模板的代码非常难以理解;
- 编译器关于模板的出错信息十分晦涩,尤其当模板存在嵌套时;
- 模板实例化会进行代码生成,重复信息会被多次生成,这可能会造成目标代码膨胀;
- 模板的编译可能非常耗时;
- 编译器对模板的复杂性往往会有自己限制,比如当使用递归时,当递归层次太深,编译器将无法编译;
- 不同编译器(包括不同版本)之间对于模板的支持程度不一,当存在移植性需求时,可能出现问题;
- 模板具有传染性,往往一处选择模板,很多地方也必须跟着使用模板,这会恶化之前的提到的所有问题。
这篇作者对此的原则是:在使用其它非泛型技术可以同等解决的前提下,就不会选择泛型。
作者:_袁英杰_
链接:https://www.jianshu.com/p/4939c934e160
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
template <typename T>
void f(T& object)
{object.f(0); // 要求类型 T 必须有一个可让此语句编译通过的函数。
}//换成下面版本,会出现编译错误。主要是C1和C4编不过
//template <typename T>
//void f(T& object)
//{
// int result = object.f(0);
// std::ignore = result;
// // ...
//}struct C1
{void f(int i){++i;std::cout << R"(f(int))" << std::endl;return ;}
};struct C2
{int f(char){std::cout << R"(f(char))" << std::endl;return 2;}
};struct C3
{int f(unsigned short, bool isValid = true){std::cout << R"(f(unsigned short, bool isValid = true))" << std::endl;return 3;}
};struct C4
{struct Object{};struct Foo{};Foo* f(Object*){std::cout << R"(f(Object*))" << std::endl;return NULL;}
};//void f(C1& object)
//{
// object.f(0); // 要求类型 T 必须有一个可让此语句编译通过的函数。
//}int main()
{C1 o1;C2 o2;C3 o3;C4 o4;f(o1);f(o2);f(o3);f(o4);return 1;
}输出:
f(int)
f(char)
f(unsigned short, bool isValid = true)
f(Object*)
Duck Typing with C++ templates
class foo
{
public:std::string as_string() const{return "i am foo";}
};class baz
{
public:// look ma, no 'as_string'
};template <typename T> class repr_type
{
public:repr_type(const T& o) : m_o(o){}std::string as_string() const{return call_as_string<T>(nullptr);}private:template <class C> std::string call_as_string(decltype(&C::as_string)) const{return m_o.as_string();}template <class C> std::string call_as_string(...) const{return "print pointer";//return string::format("%p", &m_o);}const T& m_o;
};template <typename T>
std::string as_string(const T& o)
{return repr_type<T>(o).as_string();
}int main()
{foo o;as_string(o);baz b;as_string(b);return 1;
}
对一个类进行增强class foo
{
public:std::string as_string() const{return "i am foo";}
};class baz
{
public:// look ma, no 'as_string'
};template <typename T> class repr_type
{
public:repr_type(const T& o) : m_o(o){}std::string as_string() const{return call_as_string<T>(nullptr);}private:template <class C> std::string call_as_string(decltype(&C::as_string)) const{return m_o.as_string();}template <class C> std::string call_as_string(...) const{return "print pointer";//return string::format("%p", &m_o);}const T& m_o;
};template <typename T>
std::string as_string(const T& o)
{return repr_type<T>(o).as_string();
}class spreadsheet
{
public:void set(int x, int y, const char* text){//给表格中的一个单元设置内容std::cout << "hello" << std::endl;}//相当于以一种简介的接口对各种数据类型进行接收,没有as_string的就不做任何事情template <typename T> void set(int x, int y, const T& instance){set(x, y, as_string(instance).c_str());}
};int main()
{spreadsheet s;foo obj;s.set(0, 0, obj); // where i could be "anything"return 1;
}
Duck Typing with C++ templates
So, folks, what is this Duck Typing thingy? Trusty wikipedia says: In duck typing, an object's suitability is determined by the presence of certain methods and properties (with appropriate meaning), rather than the actual type of the object. Let's take a look at a concrete example from a pet library of mine.
Often in code - especially often when logging stuff - you want to represent things as a string to be printed. A useful start is to use function overloading, mainly because there are some types of things that you cannot really extend. For example, you could have something like this:
inline std::string as_string(const char* p){return p ? p : "<nullptr>";}inline std::string as_string(bool value){return value ? "true" : "false";}inline std::string as_string(int32_t number){return string::format("%d", number);}
The nice thing of this is: you can type as_string(x) and for many things, you will get out a useful string representation without having to care what the actual object is.
Enter objects. For many of these, the above approach will work as well:
inline std::string as_string(const std::vector<std::string> v){return string::format("vector<string> with %d objects", (int) v.size());}inline std::string as_string(const my_elaborate_class& c){return string::format("my_elaborate_class at %p", &c);}
Now, maybe you want to have more information in as_string, and maybe you need to be able to access private data members of the objects. This can be solved by introducing a member function as_string, starting with this design:
class foo{public:std::string as_string() const;};std::string as_string(const foo& x){return x.as_string();}class bar{public:std::string as_string() const;};std::string as_string(const bar& x){return x.as_string();}
This works only if you write separate as_string overloads for each class that implements as_string members, a rather pointless task. Nothing for us lazy programmer folk!
You could define an interface and force all objects to inherit it:
interface can_be_represented_as_string{virtual std::string as_string() const = 0;};class foo : public can_be_represented_as_string{virtual std::string as_string() const override;};class bar : public can_be_represented_as_string{virtual std::string as_string() const override;};std::string as_string(const can_be_represented_as_string& something){return something.as_string();}
OK, nice! Now you can throw almost anything at as_string, and get a string back. This works, but it requires you to define inheritance on all your objects, which is an eyesore and anyway may sometimes not be feasible, because you may have objects that you don't have control over (or you already have a large hierarchy of objects and people would complain if you'd start messing up their inheritances). Enter duck typing
As written at the beginning, in duck typing, an object's suitability is determined by the presence of certain methods and properties (with appropriate meaning), rather than the actual type of the object
Looking back where we started:
class foo{public:std::string as_string() const;};class bar{public:std::string as_string() const;};
Both foo and bar are objects that have methods named as_string with appropriate meaning, but the types are different. What can we do about that? This:
template <typename T> std::string as_string(const T& o){return o.as_string();}
So this is a generic method that works because there are methods with the proper meaning - not because the type has inherited something special. Nice!
OK, but now what about objects that do no implement a method as_string with appropriate meaning? It would be nice if we could say something like
- If there is a as_string overload, use that
- If the object has as_string(), use that
- Otherwise, default to just dumping the address of the object
At this point, the first two work, but the third one is causing a headache. It turns out that there is a SFINAE based idiom to detect if a type has a member but that is only half the solution: most examples just end up in a template function that can be used to detect the fact, but not really do something about it. To see the problem, let's assume you've followed the examples I've linked to and have a template has_as_string<type>::value. And you have the following setup:
class foo{public:std::string as_string() const;};class baz{public:// look ma, no 'as_string'};...static_assert(has_as_string<foo>::type, "");static_assert(!has_as_string<baz>::type, "");...template <typename T> std::string as_string(const T& o){if(has_as_string<T>::value)return o.as_string();return string::format("%p", &o);}
The static-asserts will work (provided your implementation is correct), but the as_string template won't, because it will attempt to generate code that uses as_string even if the object doesn't have the method. So you need something more involved:
template <typename T> class repr_type{public:repr_type(const T& o):m_o(o){}std::string as_string() const{return call_as_string<T>(nullptr);}private:template <class C> std::string call_as_string(decltype(&C::as_string)) const{return m_o.as_string();}template <class C> std::string call_as_string(...) const{return string::format("%p", &m_o);}const T& m_o;};template <typename T> std::string as_string(const T& o){return repr_type<T>(o).as_string();}
It is worth looking at the fine print on this one:
- Based on whether the type passed has a method as_string, this template will print either the result of that function, or simply the objects address.
- Note the template function call_as_string: the template resolver will prefer the more specific type, and it will be able to use the first template if the address of as_string can be taken.
- Note that because this is a template function, it is not compiled until it is actually used: so for things that don't have as_string a call to as_string will never be issued.
- Also note for this to work, I cannot also pass in the object instance: so I need a proxy object - repr_type template - to hold a reference to the object while letting SFINAE do its work
So what use is any of this?
Well, assume you have a set of as_string thingies as discussed here: functions using overloading, some template magic and so on. All is fine and well and one day you start writing a method that takes strings. For example, assume you have a spreadsheet and you want to fill it with text:
class spreadsheet{public:void set(int x, int y, const char* text);};
Now for most of the things you have, you can write
spreadsheet s;...s.set(0,0,as_string(i)); // where i could be "anything"
But wait, it gets better: let's enhance the spreadsheet class:
class spreadsheet{public:void set(int x, int y, const char* text);template <typename T> void set(int x, int y, const T& instance){set(x,y, as_string(instance));}};
This will result in even more clean client code:
spreadsheet s;...s.set(0,0,i); // where i could be "anything"
Personally, I like my client code as readable as possible. I know that can be hard, because C++ tries really hard to be ugly and uglier still, but sometimes after years disparate features like overloading and templates and improved rules in C++11 work together to make the code look simple. Nice!