"绝境之中才窥见 winner winner 无限的精彩"
我们给出一组类定义和函数实现(无意义):
class Widget
{
public:
Widget();
virtual ~Widget();
virtual size_t size() const;
virtual void normalize();
void swap(Widget& other);
};
void doProcessing(Widget& w)
{
if (w.size() > 0 && w != someNastyWidget)
{
// w != someNastyWidget 仅仅作为一个条件 实际无意义
Widget tmp(w);
tmp.normalize();
tmp.swap(w);
}
}
■ 其中w的类型被声明为Widget,因此我们一定可以在源代码(类)中找到,支持创建Widget类型的显式接口(explicit interfaces)。
■ 由于Widget中的某些成员函数是virtual,w对象对这些成员函数的调用又表现出运行期多态(runtime polymorphism),根据w的动态类型,才会决定去调用哪一个函数。
在面向对象编程的世界里,这两种解决问题的方式较为重要。在Template以及泛型编程的世界中,它们虽然存在,但重要性却降低了。反而,隐式接口(implicit interfaces)、编译期多态(compile-time polymorphism)移到了前头。
这该怎么理解呢?我们给doProcessing函数增添模板,让它变成一个模板函数,此时这个函数会发生什么事情?
template
void doProcessing(T& w)
{
if (w.size() > 0 && w != someNastyWidget)
{
// w != someNastyWidget 仅仅作为一个条件 实际无意义
Widget tmp(w);
tmp.normalize();
tmp.swap(w);
}
}
此时,我们又该如何看待doProcessing函数里的变量"w"呢?
■ w支持什么接口,支持什么调用,现如今完完全全由template中的T类型自身决定。从本例来看,w类型的T必须支持size、normalize、swap、copy构造、不等比较(w!=someNastyWidget)……在此看来(前提一定是template进行有效编译),这些上述的所有函数,便是T必须支持的一组隐式接口(implicit interface)。
■ 由此,w涉及的任何调用,甚至是operator>、operator!=,都可能造成template具现化(instantiated 可以理解为实例化),使得这些调用得以成功。此时,这样的具现行为只是发生在编译期。
“以不同的template参数具现化function templates,会导致调用不同的函数,这就是所谓的编译期多态(compile-time polymorphism)"。
也许,你还是不理解编译器多态和运行期多态之间的差别,它类似于"哪一个重载函数需要被调用"(发生于编译期)和 “哪一个virtual函数该被绑定"(发生于运行期)之间的差异。当然仅仅凭上述的例子便草草区别显式接口和隐式接口的差异,这是不严谨的。
通常显示接口由函数的签名式(函数名称、参数、返回类型)构成。例如Widget class:
class Widget
{
public:
Widget();
virtual ~Widget();
virtual size_t size() const;
virtual void normalize();
void swap(Widget& other);
};
像上述的析构、构造、size等等都可以被称为显式接口,另外也包括typedefs\using等重名的函数重命名。
与显式接口截然不同的是,它并不基于函数签名式,而是由有效表达式(valid expression)组成。我们再来反观doProcessing这个函数接口:
template
void doProcessing(T& w)
{
if (w.size() > 0 && w != someNastyWidget)
{
// w != someNastyWidget 仅仅作为一个条件 实际无意义
Widget tmp(w);
tmp.normalize();
tmp.swap(w);
}
}
T类型看起来会受到w类型的接口约束:
■ 它必须提供一个名为size的成员函数,并返回一个整数值.
■ 它必须支持一个 operator != 函数,用来对两个T对象进行比较(这里我们假设someNastyWidget同T是同类型)。
然而,事实真的是这样吗?我们真得感谢操作符重载(operator overloading)带来的可能性,这两个约束,也许对T类型而言根本不值一谈。
T必须支持size函数,这个函数可以是成员函数,也可以是从base class继承得到的,而这个函数的返回值也不一定非得是数值类型,同样,也可以不需要满足能够进行 "比较" 的类型。它唯一需要做的是返回一个类型为X,加上一个int(10)类型能够调用一个 "operator>"。这个 "operator>",也不一定非得取X为返回类型,甚至可以取得Y类型的参数,只要存在一个隐式转换能够把X类型转换为Y类型的对象!
同样的道理,T也可以不支持 "operator !=","operator!="可以重载为接受一个X类型和Y类型的对象,T可以被转换为X而someNastyWidget可以转换为Y类型,就可以有效调用"operator!="。
(当然你一定不能小瞧"&&",不能忽视 "operator&&"被重载的可能性,从一个连接词转换为某些不知道的行为,改变了其原有的意义)。
由此,如果我们以此种方式思考隐式接口,真的会让人头疼。然而事实上是,隐式接口看起来很繁杂,但它仅仅是由一组有效表达式构成,也许表达式自身看起来复杂,但它们的约束条件一般而言直接又明确。
if (w.size() > 0 && w != someNastyWidget)
关于函数size,operaotr>,operator&&……身上的约束条件实在太多,但是if的条件是必须是个布尔表达式,所以无论最终的结果类型是怎样,"if (w.size() > 0 && w != someNastyWidget)" 都应与bool类型兼容。当然这仅仅是template doProcessing加诸于其类型参数"T"的隐式接口的一部分。像其他隐式接口:copy构造、normalize和swap也必须对T类型对象有效。
由此,不管是加诸于template参数上的隐式接口,或者是class对象上的显式接口, 它们都得在编译期间完成检查。正如,"你无法使用class对象提供的显式接口之外的接口、方式",用来使用对象,你也无法在template中使用,"template不支持的隐式接口"的对象。
请记住:
◆ classes和templates都支持接口(interfaces)和多态(polymorphism).
◆ 对classes而言接口是显式的(explicit),以函数签名为中心。多态则是通过虚函数virtual,函数发生于运行期。
◆ 对templates而言,接口是隐式的(implicit),奠基于有效表达式。多态则是通过template具象化和函数重载解析,发生于编译期。
我们经常看到如下的声明式,这两个表达式有什么不同呢?
template class Widget; // 使用"class"
template class Widget; // 使用 "typename"
答案是,没有任何不同。当我们使用"typename"声明类型参数时,class与typename的意义完完全全相同。有些人使用“typename”只是在暗示参数并非一定是一个class类型。
但在有些时候C++不是总把,"class" 和 "typename" 视为等价。有时候你一定得使用typename。什么时候使用呢?那么我们就得先谈谈template内指涉的两种名称。
假设我们有个template function。接收一个STL兼容容器为参数,容器内持有的对象可被赋值为ints,并且这个函数仅仅是打印这个容器的第二个元素。
注:这是一个无聊的函数和实现方式
// 这个函数甚至不能通过编译
template
void print2nd(const C& container)
{
if (container.size() > 2)
{
C::const_iterator iter(container.begin()); // 取得容器的第一个元素
iter++; // iter移向第二个元素
int val = *iter; // 将元素赋值到某个int
std::cout << val; // 打印那个val
}
}
我们把目光聚焦于代码中的两个local变量: "iter" 和 "value"。iter的类型是"C::const_iterator",但具体是什么迭代器类型,取决于template的参数“C”。template内出现的名称,如果依附于某个template参数,则称之为从属名称(dependent names)。如果从属名称在class内呈嵌套状,我们则称为嵌套从属名称(nested dependent names)。C::const_iterator实际上就是一个嵌套从属类型,也就是个嵌套从属名称并且指涉某类型。
函数内还有一个变量"value",其类型为int。int是一个内置类型,不依赖任何template参数名称。这样的名称称为非从属名称(non-dependent names)。
对于嵌套从属名称有可能导致解析困难。举个例子,我们更愚蠢地这样写:
template
void print2nd(const C& container)
{
C::const_iterator* x;
...
}
看起来,我们声明了一个local变量x,它是一个指针,类型是"C::const_iterator"。可是,如果C::const_iterator不是一个类型,而是一个C对象中的一个静态成员变量呢?它的命名恰好是const_iterator?亦或是x是一个global变量,上述代码甚至可能变为一个"相乘"的动作:“C::iterator”乘以x。
所以,我们问题就在于我们只有在确切地知道C这个类型是什么之前,没有任何办法知道"C::const_iterator"是一个什么类型,或者是其他什么。当编译器进行编译解析模板函数时,是不知道C是一个什么东西的。C++有一个规则可以解析这一存在歧义的状态,即: "如果解析器在template中遇到嵌套从属名称,便假设这个名称不是一个类型,除非你告诉它是。缺省情况下嵌套从属名称不是类型。"
谨记上述的这些,我们把目光再次转回函数起始处:
template
void print2nd(const C& container)
{
if (container.size() > 2)
{
C::const_iterator iter(container.begin()); //这个名称会被认为是 非类型的
...
}
所以,知道了吧,这也是为什么这段愚蠢的代码甚至不能经过编译!iter声明式只有在C::const_iterator是一个类型是才变得合理,但是我们又没有告诉C++说它是,于是C++在缺省的情况下会认为它不是。为了矫正这个错误,我们必须告诉C++,"C::const_iterator"是一个类型。我们只需要在它之前跟上 "typename" 即可.
template
void print2nd(const C& container)
{
if (container.size() > 2)
{
typename C::const_iterator iter(container.begin()); //这个名称会被认为是 非类型的
...
}
一般性规则很简单,任何时候,只要你想要templates中指涉的嵌套从属类型名称,就必须在紧邻它的前一个位置放上"typename"关键字。
typename只被用来验明嵌套从属类型名称,其他的名称前不应该有它的存在!
template // "typename" "class"都能使用
void f(const C& container, // 不能使用"typename"
typename C::const_iterator iter); // 必须使用"typename"
当然这个规则还有一个唯一例外是,typename不能出现在base classes list内的嵌套从属名称之前,也不可以出现在初始化列表中作为base class的修饰符。例如:
template
class Derived :public Base::Nested // 不能出现base class list
{
public:
explicit Derived(int x)
:Base::Nested(x) // 不能出现在初始化列表中
{
typename::Base::Nested temp; // 从属嵌套名称,必须使用typename
}
};
这里的不一致性确实让人恼怒,但是我们作为语言的使用者,也只能勉强接受。
下面的一份例子是你会在真实的程序中看到的,假设我们正在撰写一个function template,它接受一个迭代器,而我们打算为该迭代器指涉的对象做一份local副本,我们这样写:
template
void workWithIterator(IterT iter)
{
typename std::iterator_traits::value_type temp(*iter);
...
}
"std::iterator_traits
如果你认为"std::iterator_traits
template
void workWithIterator(IterT iter)
{
typedef typename std::iterator_traits::value_type value_type;
value_type temp(*iter);
}
也许你会对 "typedef' + "typename"的组合感到别扭,但它的确乎是"嵌套从属类型名称"附带合理的结果。最后,typename的相关规则,在不同的编译器上有不同的实践。某些编译器接受的代码原本该有的"typename"被遗漏了,原本不该有的"typename"却出现了。甚至少数编译器根本拒绝使用typename。这意味着,typename和"从属嵌套名称"之间的互动,也许会在代码移植性方面让你感到头疼。
请记住:
◆ 声明templates参数时,前缀关键字class、typename可以进行互换。
◆ 请使用关键字typename表示嵌套从属类型名称,但是不包含在base class lists(基类列) 或member initialization list(成员初始化列表)内以它作为base class的修饰符。
假设我们撰写一个程序,该程序能够将信息发送到不同的公司去。这些信息要么是需要被加工的,要么就是传输原始文字。如果在编译期间我们有足够的信息来决定这一个信息,要被传至哪一家公司,就可以基于template的解法:
class CompanyA
{
public:
void sendCleartext(const std::string& msg);
void sendEncrypted(const std::string& msg);
};
class CompanyB
{
public:
void sendCleartext(const std::string& msg);
void sendEncrypted(const std::string& msg);
}; // 其他公司设计的接口
class MsgInfo {}; // 用来存储信息
template
class MsgSender
{
public:
// 构造析构....
void sendClear(const MsgInfo& info)
{
std::string msg;
Company c;
c.sendCleartext(msg);
}
void sendSecret(const MsgInfo& info) // 加密发送 会调用sendEncrypted
{
...
}
};
不仅如此,我们还想每次做出信息发送时,都会产生对应的日志信息。derived class可以轻易加上这样的生产力,似乎是一个合情合理的解法:
template
class LoggingMsgSender :public MsgSender
{
public:
// 构造析构..
void sendClearMsg(const MsgInfo& info)
{
// 传送前的信息 推送至log
sendClear(info); // 调用base class函数:但这段无法通过编译
// 传送后的信息 推送至log
}
};
注意,derived class中的信息传输函数(sendClearMsg)同base class中的名称(sendClear)是不同的。这是一个好设计,因为这可以避免它 掩盖 “继承而来的名称”,同时也避免重新定义一个继承而得的non-virtual函数。上述代码之所以不能通过编译,是因为在derived class中根本看不到SendClear()这个函数,可是这个函数难道不就是在base class里呢?为什么编译器找不到?
问题在于,当编译器遇到class template LoggingMsgSender的定义式时,并不知道它继承的是一个什么样的class。当然,从使用者的角度来说,我们知道它继承的是MsgSender
为了让问题更具体化,我们设计一个class CompanyZ,但它只使用加密传输:
class CompanyZ
{
public:
void sendEncrypted(const std::string& msg);
...
}
此时,一般性的MsgSender template对现在这个CompanyZ是不适用的。因为template class会提供sendClear()这个函数(针对类型参数Company调用其中的sendCleartext()),这反而对CompanyZ不合理了。为解决这个问题,满足CompanyZ的特殊需求,我们需要对产生一个模板的特化版本:
class CompanyZ;
template<>
class MsgSender
{
public:
void sendSecret(const MsgInfo& info); // 加密发送 会调用sendEncrypted
};
注意class定义式的前头为 "template<>",这既不是模板也不是class的象征,而是一个特化版本的template MsgSender,只有在实参为CompanZ的情况时,才会被使用。这也就是所谓的模板全特化(total template sepcialization)。其特化的全面性体现在,一旦类型参数被定义为CompanZ,再也没有其他template参数可供变化。
现在,我们再将目光考虑到 derived class LoggingMsgSender上来:
template
class LoggingMsgSender :public MsgSender
{
public:
// 构造析构..
void sendClearMsg(const MsgInfo& info)
{
// 传送前的信息 推送至log
sendClear(info); // Company == CompanyZ
// 传送后的信息 推送至log
}
};
正如sendClear()那行的注释所言,当base class被指定为MsgSender
"当我们从Object Oriented C++ 跨进 Template C++,继承就不像以前那般畅行无阻了。"
为此,我们必须有某种办法,令C++"不进入 templatized base classes观察"的行为失效
● 方法一: 在base class函数调用动作之前加上 "this->":
template
class LoggingMsgSender :public MsgSender
{
public:
// 构造析构..
void sendClearMsg(const MsgInfo& info)
{
// 传送前的信息 推送至log
this->sendClear(info); // 成立,sendClear被成功继承
// 传送后的信息 推送至log
}
};
● 方法二: 使用using声明式(将base class被掩盖的名称,带入derived class作用域内)
template
class LoggingMsgSender :public MsgSender
{
public:
using MsgSender::sendClear; // 告诉编译器 假设sendClear在 base class内
// 构造析构..
void sendClearMsg(const MsgInfo& info)
{
// 传送前的信息 推送至log
sendClear(info); // 成立,sendClear被成功继承
// 传送后的信息 推送至log
}
};
注意: 这里同 "base class名称被derived class名称掩盖" 是两种完全不一样的情况。造成无法找到sendClear()的原因是,编译器不进入base class作用域里查找,而我们只是通过using告诉它,你不该这么做。
● 方法三: 明白指出被调用的函数位于base class内:
template
class LoggingMsgSender :public MsgSender
{
public:
using MsgSender::sendClear; // 告诉编译器 假设sendClear在 base class内
// 构造析构..
void sendClearMsg(const MsgInfo& info)
{
// 传送前的信息 推送至log
MsgSender::sendClear(info); // 成立,sendClear被成功继承
// 传送后的信息 推送至log
}
};
但是这样的做法不是那么好的,因为如果被调用的函数是一个virtual函数,上述的做法明确会关闭 "virtual绑定行为"。
上述的三种做法,无一例外都在做着相同的事: "对编译器承诺 "base class template"的任何特化版本都有将支持一般版本所提供的接口 "。这样一个承诺是在解析像 LoggingMsgSender 这样的derived class template时需要的。但是这个承诺,最终是没有被实践出来的。我们再举个例子:
int main()
{
LoggingMsgSender zMsgSender;
MsgInfo info;
zMsgSender.sendClearMsg(info);
return 0;
}
其中sendClearMsg的调用动作无法通过编译。编译器知道base class是个特化版本的MsgSender
根本而言,对于 "base class members"无效引用,编译器的诊断时间可能发生在早期(derived class 定义式时),也可能发生在晚期(那些templates被特定的template实参具现化时)。这也是为什么C++,"当base classes 从templates中具现化时",会对它们的内容毫无干系的缘故。
请记住:
◆ 可在derived class templates内通过"this->"指涉 base class templates内的成员名称,或藉由一个明白写出的 "base class 资格修饰符"完成。
Template是节省时间和避免代码重复的一个奇方妙药。你不再需要20个类似的classes而没有个classes带有老多的成员变量、函数。你只需要一个class template,留给编译器去帮你具现化那重复而枯燥的工作(class templates中的成员函数,只有在使用的时候才会被暗中具现化)。模板函数也有类似的诉求,利用function template替换写许多函数,让编译器去做剩余的事情。
可是,如果你一不小心使用templates导致代码膨胀(code bloat):二进制码带着重复(或几乎重复)的代码、数据,或者两者皆有。其结果反而适得其反,所以你有必要知道如何避免这样的二进制浮夸。
不过再次之前,我们得理解两个概念: "共性" 和 "变性"。我们可以这样简单理解:假如你正在编写某个函数,而明确地明白这个函数里的一些实现代码同另一个函数里的一些实现码完全相同,你一定会抽出这两个函数的共同部分,而不是一昧地重复编写这份相同的代码。编写class也同样如此,一个class 与 另外一个class有一部分共同特性,使用继承或者组合来令原先的classes取用这份共性,而原classes中互异部分仍然保留在各自的classes之中。
由此,在编写templates的时候,你也需要做相同的分析,避免重复。对于non-template 代码中,找寻重复的意图十分明确,然而在templates代码中确实隐晦的,因为templates源码只有一份,你得去感受templates具现化时,可能发生的重复。
举个例子,你设想为固定尺寸的正方矩阵编写一个template,该矩阵的性质之一是支持逆矩阵运算(matrix inversion)。
// 类型为T 支持 n * n矩阵
template
class SquareMartrix
{
public:
void invert(); // 求逆矩阵
};
这个template class除了接收类型参数T,还接收一个类型为size_t的参数,这是一个非类型模板参数(你可以理解为一个常量)。
现在,你该考虑这些代码:
int main()
{
SquareMartrix smd1; // 调用 SquareMartrix::invert
smd1.invert();
SquareMartrix smd2; // 调用 SquareMartrix::invert
smd2.invert();
return 0;
}
没错,程序一旦运行起来,就会具象化两份invert。这些函数并非完完全全相同的,因为它们一个是操纵5*5矩阵而另外一个则操纵10*10矩阵。可是除了这两个常数不同,其他部分又是完全相同的。这就是template一个典型的代码膨胀的例子。
如果仅仅是参数5、10的不同,我想你一定会想方设法建立一个带数值参的函数,然后以5、10来调用这个函数,这样就不会出现重复的代码。
template
class SquareMartrixBase // 与尺寸无关型
{
protected:
void invert(std::size_t matrixsize); // 逆矩阵尺寸
};
template
class SquareMartrix:private SquareMartrixBase
{
public:
using SquareMartrixBase::invert; // 避免遮掩base 不解释
void invert() { this->invert(N); } // 调用base class invert
};
带参数的invert位于base class SquareMartrixBase当中,同原先的SquareMartrix一样。不同的是SquareMartrixBase只是对 "矩阵元素类型"的参数化,不对矩阵尺寸的参数化。因此,当创建任意一个SquareMartrix时,这些对象只能共享唯一一个SquareMartrixBase::invert()。
同样,这里有很多地方值得注意。SquareMartrixBase::invert()只是企图成为 " 避免derived class代码重复"一种方法。而derived class的invert调用base class版本时用的是inline(这里是隐晦的)。这些函数需要使用"this->"记号,是因为如果不那样做,模板化基类的函数名会被掩盖掉。同时,SquareMartrixBase 与 SquareMartrix完完全全不是一个is-a的关系,所以它们的继承关系是private,SquareMartrixBase 只是为了帮助实现SquareMartrix。
唔,目前为止,一切都很好,我们解决了矩阵尺寸可能导致的代码膨胀问题,可是我们还没有认识到,SquareMartrixBase::invert()该如何实现呢?我们虽然知道矩阵的尺寸了,可是数据呢?我们怎么知道该操纵哪里的数据?当然,矩阵的只有derived class知道。由此,我们给invert新增一个参数,定义一个指向数据存储位置的指针。但,我们可以将尺寸无关函数移至SquareMartrixBase 内,也就意味着一定还有其他类似的函数可能存在,难道我们都得一个一个告诉这些函数derived class的数据存储在哪里嘛?这种额外参数的是重复的,这样似乎是不好的。
我们干脆设计在 SquareMartrixBase 贮存一个指针,指向矩阵数据所在的内存位置,当然也可以贮存尺寸大小。
template
class SquareMartrixBase // 与尺寸无关型
{
protected:
SquareMartrixBase(T* pMem,size_t n) // 存储矩阵尺寸和数据内存位置
: pdata(pMem),size(n)
{}
void invert(std::size_t matrixsize); // 逆矩阵尺寸
void SetDataPtr(T* ptr) { pdata = ptr; } // 设置pdata
private:
T* pdata; // 数据内存位置
size_t size; // 矩阵尺寸
};
这样,就允许derived class决定数据内存分配的方式。
template
class SquareMartrix:private SquareMartrixBase
{
public:
SquareMartrix()
:SquareMartrixBase(data, N) // 送出矩阵尺寸 和 数据存储位置
{}
private:
T data[N * N];
};
这种做法是将矩阵数据存储在SquareMartrix内部,导致对象自身十分大。另一种做法就是把矩阵的数据开辟到heap(堆)上:
template
class SquareMartrix :private SquareMartrixBase
{
public:
SquareMartrix()
:SquareMartrixBase(nullptr, N), // 为base data初始化nullptr
_pData(new T[N * N]) // 为矩阵分配内存
{
this->SetDataPtr(_pData.get()); // 把它的副本交给 base class
}
private:
std::shared_ptr _pData;
};
现在,无论存储于何处,从代码膨胀的角度来看,SquareMartrix成员函数可以单纯地以inline方式调用base class版本,后者持有 "同型元素"与所有矩阵共享。
是的,这很棒!可是,也付出了部分代价。如果是使用绑定矩阵尺寸的invert版本,有可能会比生成的共享版本(与尺寸无关)更加的代码。因为在尺寸专属的版本中,尺寸是一个编译期常量,因此可以藉由常量的广传达到最优化,包括把它们折进被生成指令成为直接操作数。这反而在"共享版本"是无法做到的。
从另一个角度来看,共享版本可减少执行文件大小,也就因此降低了程序的working set大小,并强化了指令高速缓存的引用集中化(locality of reference)。这些都可能使程序执行更加快速,超越尺寸专属版本的invert,达到最优效果。
working set: 是指"虚内存环境"也就是进程地址空间!对于process而言,共享版本的代码只需要加载一份即可,就尽可能减少付出内存页换入换出的代价。
所以,答案是什么呢?实践出真知。唯一的办法就是两者都尝试并观察你的平台行为以及面对代表性数据组时的行为。
当然效能评比所关心的主题不仅仅只有这个。例如每一个SquareMartrix 对象都有一个指针指向SquareMartrixBase class内的数据。虽然每个derived class已经有一种取得数据的办法,可是这会给每一个对象增加至少一个指针大小的空间。当然也可以设计将这些指针拿掉。例如: 在base class中贮存一个protected指针指向矩阵数据,这将导致丧失封装性。如果base class上的指针是一个通过动态分配获得的,也许存储于derived class内,你何时应该判断这个指针该删除呢?这样导致资源管理上的复杂、混乱,你愈发想将一些设计做得精密,反而愈发容易出岔子。从这个角度来看,简简单单的代码重复问题,似乎还有点幸运。
上述条款仅仅是讲述了 non-type parameters(非类型模板参数)可能带来的膨胀,这并不意味着type parameters(类型参数)不会导致膨胀!例如,在许多平台上int、long类型有相同的二进制表述,所以诸如vector
在大多数平台上,指针类型都有着相同的二进制表述,因此,凡是templates持有指针(例如: list
请记住:
◆ Templates生成多个classes和多个函数,所以任何template代码都不应该与某个造成膨胀的template参数产生依赖关系.
◆ 因非类型模板参数(non-type parameters)而造成的代码膨胀,往往可以消除,做法是以函数参数或class成员变量替换template的参数.
◆ 因类型参数(type parameters)而造成的代码膨胀,往往可降低,做法是让带有完全相同二进制表述(binary representation)的具现类型(instantiation types)共享实现码.
我们应该如何理解"智能指针"?它应该是行为像指针(*,->),并提供指针没有的功能(管理动态内存空间)。诸如auto_ptr、shared_ptr,都能够在正确的时机,自动删除base-heaped的资源。STL容器中几乎总是智能指针,你不会奢望使用“++”将一个内置指针(int*,double*),从link list的某个节点移动到下一个节点(因为这些节点并非连续),但是这是在list::iterator身上办得到的。
真实指针(内置指针)做得很好的一件事是,支持隐式类型转换("implicit conversions")。例如:dervied class可以隐式转换为base class 指针,指向"non-const"对象的指针可以转换为指向"const"对象等等。下面的例子,可以让我们看看发生了什么转换:
class Top{};
class Middle :public Top {};
class Bottom:public Middle {};
Top* pt1 = new Middle; // 发生切片 Middle* 转换为 Top*
Top* pt2 = new Bottom; // 发生切片 Bottom* 转换为 Top*
const Top* pt3 = pt2; // non-const指针 转换为 const指针
但如果,我们使用智能指针完成这一项转换会显得有些麻烦。
template
class SmartPtr
{
public:
explicit SmartPtr(T* realPtr);
};
SmartPtr pt1 = SmartPtr(new Middle); // 将SmartPtr 转换为
SmartPtr pt2 = SmartPtr(new Bottom); // 将SmartPtr 转换为
SmartPtr pt3 = pt1; // 将SmartPtr 转换为 SmartPtr
但是同一个template的不同具现体(SmartPtr
所以,现在我们应该关注的是如何编写智能指针的构造函数,支持我们获得SmartPtr classes之间互相转换的能力。但是我们可以根据 "SmartPtr
class BelowBottom:public Botom {...}
显然,我们需要的构造函数数量是没有尽头的,因为template可以被无限具现化,生成无数的函数。所以,我们不是为SmartPtr写一个构造函数,而是写一个构造函数模板。这种模板被称为成员函数模板(member function template),其作用是为class生成函数:
template
class SmartPtr
{
public:
template // member template
SmartPtr(const SmartPtr& other); // 生成copy函数
};
以上代码的意思是,SmartPtr
而上述的泛化copy构造函数前并没有声明"explicit",这也就意味着允许隐式类型的转换(毕竟原始指针中,derived class 指针就会隐式类型转换为 base class指针)。所以,让智能指针仿效这种行径也是合理的。
当然上述的泛化copy构造函数并非总是如我们所料,它提供具现化的函数其实比我们需要的还要多。比如,我们实则需要用SmartPtr
我们也可以效仿auto_ptr和shared_ptr,为我们的class函数新增一个成员函数get,它意指返回智能指针所持有的原始指针。由此,我们就可以在"构造模板"中,约束转换行为:
template
class SmartPtr
{
public:
template // member template
SmartPtr(const SmartPtr& other) // 初始化this中的_heldptr
:_heldptr(other.get())
{}
T* get() { return _heldptr; }
private:
T* _heldptr;
};
现如今,这个行为只有当 "U*的指针能够转换为T*的指针",才能通过编译,而那就是我们想要的。最终SmartPtr
当然成员模板函数并非仅仅限于构造函数,它们也会扮演着支持赋值操作。TR1的shared_ptr支持来自 " 兼容之内的内置指针、tr1::auto_ptr、tr1::weak_ptr "的构造行为以及赋值操作。下面是一份TR1规范关于这个的摘录:
template
class shared_ptr
{
public:
template
explicit shared_ptr(Y* ptr); // 构造来自任何兼容的内置指针
template
shared_ptr(const shared_ptr& other); // "泛化copy构造函数" shared_ptr
template
explicit shared_ptr(const weak_ptr& other); // weak_ptr
template
explicit shared_ptr(auto_ptr* other); // auto_ptr
template
shared_ptr& operator=(shared_ptr* other); // shared_ptr赋值
template
shared_ptr& operator=(auto_ptr* other); // auto_ptr赋值
};
上述的所有构造函数,除开"泛化copy构造函数"都是explicit的。那意味着从某个shared_ptr类型隐式转换为另一个shared_ptr类型是被允许的,但从某个内置指针或者其他智能指针类型隐式转换(显示转换cast强制转型动作也是可以的)是不被认可的!并且,这里可以注意到,只有auto_ptr的构造和赋值操作,其实参没有声明为const,因为当你企图赋值一个auto_ptr时,它已经被改动过了(这是它的特性)。
举个上述例子,如果tr1::shared_ptr 声明了一个泛化的copy构造函数,而一旦T和Y类型是相同的,所谓的泛化copy构造,就会被具现化为“正常的”copy构造函数。所以摆给编译器就提出了两个问题:
● 为tr1::shared_ptr暗自生成一个copy构造函数。
● 当某个tr1::shared_ptr对象根据另一个同型的tr1::shared_ptr对象展开构造行为时,编译器会将"泛化copy构造函数"具现化?
成员模板函数是一个神奇的东西,但它并不会改变语言规则!如果程序需要一个copy构造函数,并且你没有声明它,编译器会为你自动生成一个。在class内声明泛化copy构造函数并不组织编译器生成它们自己的copy构造函数(non-template)。由此,你想控制copy构造的方方面面,不管是泛化还是非模板,你就得同时声明 "泛化copy构造函数"和“正常的”copy构造函数,这也同样适用于赋值操作。下面的一份摘要,正可以应证如上所言:
template
class shared_ptr
{
public:
shared_ptr(shared_ptr const& r); // 正常的copy构造
template
shared_ptr(shared_ptr const& other); // 泛化copy构造
shared_ptr& operator=(shared_ptr const& other); // 正常的赋值操作
template
shared_ptr& operator=(shared_ptr const& other); // 泛化copy assignment
};
请记住:
◆ 请使用member function template生成 “可接受所有兼容类型"的函数.
◆ 如果你声明member template 用于 "泛化copy构造"或者"泛化assignment操作",你还是需要同时声明正常的copy构造函数和正常的copy assignment操作符.
我们先来看看如下的模板类:
template
class Rational
{
public:
Rational(const T& numerator = 0, const T& denominator = 1);
const T numerator() const;
const T denominator() const;
};
template
const Rational operator* (const Rational& lhs,const Rational& rhs)
{}
我们希望这个类支持混合运算,所以,我们希望如下的代码通过编译,但却是事与愿违。
在这编译器是不知道我们想要调用哪个函数的。取而代之的是,编译器会试图寻找函数名为"operator*"的template具现化出来。它们找得到这个函数,并且这个函数会接收两个Rational
本例中的两个类型分别是Rational
所以,问题的根本还是在于," 在调用一个函数之前,这个函数是必须存在的 "。也就必须先为相关的template function 推导出参数类型(函数得以具现化)。然而template实参推导的过程中,并不会考虑 "通过构造函数而发生的"隐式类型转换。
如何应对template在实参推导方面面临的挑战?我们可以让template class内的freind声明式指涉某个类外特定函数。class template并不依赖template实参推导(这只是作用于function template),所以编译器总是能够在 class Rational
template
class Rational
{
public:
Rational(const T& numerator = 0, const T& denominator = 1);
friend const Rational operator*(const Rational& lhs, const Rational& rhs);
};
template
const Rational operator* (const Rational& lhs, const Rational& rhs)
{
}
现在当对象oneHalf被声明为Rational
虽然我们能够让代码通过编译,然而任何程序不仅仅是通过编译即可。上述代码却会出现连接的报错警告。我们先把这个问题搁置一边。
也许你会对类内本该携程Rational
template
class Rational
{
public:
Rational(const T& numerator = 0, const T& denominator = 1);
friend const Rational operator*(const Rational& lhs, const Rational& rhs);
};
我们继续回到混合式调用,现在编译器知道了我们要调用哪一个函数(该函数在对象实例化的时候已经被声明出来了),但这个函数只是被声明在了Rational内,并没有被定义出来!你会说,我们不是在Rational外部定义了operator*嘛?答案是这是行不通的,如果我们声明了一个函数(在模板类内),我们就有责任去定义那个函数。既然我们没有提供任何定义式,自然而然编译器就不应该找得到它。
简单有效的方法是将外部"operator*"的本体合并到声明式中:
template
class Rational
{
public:
Rational(const T& numerator = 0, const T& denominator = 1);
const T numerator() const;
const T denominator() const;
friend const Rational operator*(const Rational& lhs, const Rational& rhs)
{
return Rational(lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator());
}
};
有趣的一点是,我们虽然使用了friend,却是与传统的"friend"用途(访问class内的non-public)大相径庭。为了让类型转换可能发生在所有实参身上,我们需要一个non-member函数,为了让这个non-member函数自动被具现化,我们把它声明在class内部,而在class内部声明non-member函数的唯一方法就是:friend。
当然,定义在class内部operator*这样的friend函数。你令operator*不做任何事,而是去调用class外部的辅助函数,也许本例中的operator*只有区区一行,但如果是在复杂的函数体内,这么做的价值确乎值得玩味儿。
template
class Rational
{
public:
Rational(const T& numerator = 0, const T& denominator = 1);
const T numerator() const;
const T denominator() const;
friend const Rational operator*(const Rational& lhs, const Rational& rhs)
{
return doMultiply(lhs, rhs); // 令friend调用外部函数
}
};
template
const Rational doMultiply(const Rational lhs, const Rational rhs)
{
return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator);
}
doMultiply当然不支持"混合式"乘法,但其实它也不需要。operator*支持了类型转换的所需的东西,支持了"混合式"操作,doMultiply只是完成了两个对象完成乘法操作而已。
请记住:
◆ 当我们编写一个class template,而为它提供之" 与此template相关的"函数支持"所有参数之隐式类型转换"时,请将那些函数定义在class template 内部,并以friend指涉。
STL六大组件: 容器、迭代器、算法、适配器、仿函数、空间配置器。其中"容器、迭代器、算法"主要以template构成。也覆盖若干工具性templates,其中一个名为advance,用来将某个迭代器移动给定距离:
观念上advance要实现迭代器的挪动,应该会这样 “iter += d”。但是,并非所有迭代器都支持+=操作。面对其他不那么强大的迭代器种类,似乎只能进行 ++ 或 --。
你是否还记得STL中的迭代器分类?我也不记得哈哈哈。STL迭代器共有5中分类:
● input迭代器:
一次一步,只能向前移动,客户只可以读取(不可以涂改)它们所指的东西,并且只能读取一次。它们模仿指向输入文件的阅读指针(read pointer),例如istream_iterators。
● output迭代器:
情况类似input迭代器。一次一步,只能向前移动。客户可以涂写它们所指的东西,但只能涂写一次。它们模仿指向输入文件的阅读指针(writepointer),例如ostream_iterators。
这两类迭代器只能或读或写一次所指之物,所以它们适合 "一次性操作算法"。
● forward迭代器:
这种迭代器可以做上述两种迭代器的所有事情,并且可以多次读或写所指之物。当然只能向前移动。比如一些版本实现的单向list。
● Bidirectional迭代器:
它除了可以向前移动,还可以向后移动。STL中的list就属于这一类,同样的还有set、multiset、map、multimap的迭代器。
● random acess迭代器:
随机放我那迭代器是为例最强大的,它既可以向前也可以向后,更强大的地方在于支持"迭代器算术"。这样的算术类似于指针算术。这并不惊讶,因为random acess迭代器底层封装的就是原始指针。例如vector、deque、string就是这一类迭代器。
对于上述的5种迭代器分类,C++标准库分别提供了专属的卷标结构(tag struct)加以区分确认:
从实现来看,structs之间关系是有效的is-a关系!也就是说所有的forward迭代器都是input迭代器。
现在我们返回到advence函数上,我们知道STL迭代有着不同的能力,所以我们首先可以从实现最低级最普及的迭代器能力,以循环的方式递增或递减迭代器。但这样做的耗费时间是线性的。而如果是random access迭代器这类支持迭代器算术运算的,则只需要耗费常数时间,所以面对这类迭代器,我们又得采用另一套方案。
template
void advance(IterT& iterT, DistT d)
{
if (iterT is a random access)
{
iter += d; // 针对access random
}
else
{ // 针对其他迭代器
if (d >= 0) { while (d--) ++iterT; } // 反复调用 ++ 或 --
else { while (d++) --iterT; }
}
}
上述只是一份伪代码,这种做法的就必须判断iter的迭代器分类,我们需要知道取得类型的某些信息。这也就是traits让你得以进行的事情: 它们允许你在编译期间取得某些类型信息。
Traits并不是C++关键字或一个预先定义好的构建:它是一种技术,也是一个C++程序员共同遵守的协议。这个技术的要求之一是,它对内置(build-in)类型和用户自定义类型(user-defined)类型的表现一样得好。比如,如果advance接收的参数是一个指针(const int*),和一个int类型,要让advance正常运作Traits技术也必须同样施行于内置类型诸如指针身上。
所以,所谓的 "类型内嵌套信息" 是根本行不通的,因为内置类型如指针是无法嵌套信息的。所以类型的Traits信息必须位于类型的自身之外。标准技术是把它放进一个template以及其一或多个特化版本当中。这样的templates在标准库中有若干个,其中的迭代器被命名为iterator traits:
template // 用来处理迭代器分类
struct iterator_traits;
正如你所见,iterator_traits是一个struct。iterator_traits的运作方式是,针对每一个类型,在struct iterator_traits
针对用户自定义迭代器类型必须嵌套typedef,名为iterator_category,用来确认适当的卷标结构。例如如下的 deque和list:
template
class deque
{
public:
class iterator
{
public:
typedef random_access_iterator_tag iteraor_category;
//...
};
};
template
class list
{
public:
class iterator
{
public:
typedef bidirectional_iterator_tag iteraor_category;
//...
};
};
至于 iterator_traits 仅仅只需要让IterT告诉它自己是什么:
template
struct iterator_traits
{
// 从属嵌套类型(从模板内取声明) 不解释
typedef typename IterT::iterator_category iterator_category;
// ...
};
这对用户自定义类型完完全全行得通,可是对于指针而言就不行了。因为指针不可能嵌套typedef。为此iterator_traits 需要支持一个只针对偏特化版本(partial template specialization)。由于指针行径和random access迭代器类似,所以iterator_traits 为指针指定的类型应该是这样的:
template // 这里不是全特化 template<>
struct iterator_traits // 针对内置指针
{
typedef random_access_iterator_tag iterator_category;
// ...
};
■ 确认若干你希望将来可取得的类型相关信息。例如迭代器而言,我们希望将来可取得其分类。
■ 为该类型信息选择一个名称(例如:iterator_category)。
■ 提供一个template和一组特化版本(例如 iterator_traits),内含你希望支持的类型相关信息。
ok,我们有了iterator_traits(实际是std::iterator_traits,这是C++标准库中的一部分)可以拿到类型相关信息,我们可以对之前的伪代码advance进一步实现:
template
void advance(IterT& iterT, DistT d)
{
if (typeid(typename std::iterator_traits::iterator_category) == typeid(std::random_access_iterator_tag)) { ... }
}
当然,我们暂且不谈这份代码是否会导致编译问题,因为这是最后条款48会去研究的问题。IterT类型在编译期间就会被获知,而“std::iterator_traits
我们真正想要的无非就是一个条件式,判断真假。如何让这个判定真假条件发生在编译期间呢?恰巧C++提供了一种方法那就是重载。
如何理解重载函数呢?当你调用一个重载函数时,你必须详细地叙述各个重要的参数类型。此时编译器会根据你所传入的实参,选择最合适的重载件供你调用。如果f1适合你,你会去调用f1,如果f2适合你,你会去调用,如果f3……以此类推,这难道不就是一种条件句嘛?并且发生在编译期间。为此,我们为这个函数重载三个新的函数,各自接收不同类型:
template
void doAdvance(IterT& iterT, DistT d, std::random_access_iterator_tag) // 实现random
{
iterT += d;
}
template
void doAdvance(IterT& iterT, DistT d, std::bidirectional_iterator_tag) // 实现bidirection
{
if (d >= 0) { while (d--) ++iterT; } // 反复调用 ++ 或 --
else { while (d++) --iterT; }
}
template
void doAdvance(IterT& iterT, DistT d, std::input_iterator_tag) // 实现input
{
if (d < 0) {
throw std::out_of_range("Negative distance");
}
while (d--) iterT++;
}
因为forward_iterator_tag继承自input,所以最后一个函数也可以处理forward迭代器。这当然是public继承带来的红利。编写在base class里的代码在derived class内也行得通。我们这里让random access和bidirectional迭代器接收正负距离。单向迭代器forward、input如果进行负距离移动会造成未定义行为!所以,这里的处理是抛异常取而代之。
有了这些doAdvance版本之后,advance所做的事情就变得少了,它只需要调用并额外传递一个对象,后者必须带有适当的迭代器分类,于是编译器运用重载解析机制,调用适当代码:
template
void advance(IterT& iter, DistT d)
{
doAdvance(iter, d
typename iterator_traits::iterator_category()
);
}
■ 建立一组重载函数(劳工)或函数模板(doAdvance),彼此之间的差异仅仅在于各自的traits参数。令每个函数实现码与其接收的traits信息对应。
■ 建立一个控制函数(包工头)或函数模板(advance),调用上述的重载函数,并且传递唯一不同的参数traits。
请记住:
◆ Traits class使得"类型相关信息"在编译期间可用。它们以templates 和 “templates 特化”完成实现。
◆ 整合重载技术(overloading),Traits class有可能在编译期间对类型执行if ... else测试
元编程(Metaprogramming)是指某类计算机程序的编写,这类计算机程序编写或者操纵其他程序(或者自身)作为它们的数据,或者在运行时完成部分本应在编译时完成的工作。
Template metaprograming(TMP,模板元编程)是编写template-based C++程序并执行于编译期的过程。简单来说,所谓的模板元编程是以C++协程的、执行于C++编译器内的程序!一旦TMP程序结束执行,其输出就像其他实例化完成的template对象一样,一如既往地进行编译。
C++并非是为了模板元编程而设计的,当tmplate加入C++后,TMP底层特性也就被引进了,所以对于我们而言需要注意的是,如何以熟练巧妙而意想不到的方式使用TMP。
TMP有两个伟大效力。第一,它让某些事情更容易。如果没有它,那些事情将是很困难的。第二,由于TMP执行与C++编译期,因此可以将工作运行期转移到编译期。这导致一个结果是,某些错误原本通常在运行期才能被侦测到的,现在在编译期就可以找出来。另一个结果是,使用TMP的C++程序可能在某一方面更高效: 较小的可执行文件、较短运行期、较少的内存需求。当然这并非不付出一定的代价,也就是将工作从运行期转移到了编译期的,编译的时间一定是延长的。
我们继续引入上一条款的伪代码advance:
template
void advance(IterT& iterT, DistT d)
{
if (typeid(std::random_access_iterator_tag) ==
typeid(typename std::iterator_traits::iterator_category) )
{
iter += d; // 针对access random
}
else
{ // 针对其他迭代器
if (d >= 0) { while (d--) ++iterT; } // 反复调用 ++ 或 --
else { while (d++) --iterT; }
}
}
我们可以使用typeid操作符让该伪代码成真,取得C++对这个问题的"正常"解决方案——所有工作都在运行期进行:
上一则条款指出,这个typeid的做法效率不及使用traits解法。
(1)类型测试发生与运行期而非编译期。(2) "运行期类型测试"代码会出现(或者说会连接于)在可执行文件当中。所以,这个例子可以彰显TMP如何能够比“正常的”C++程序更加高效,因为traits的解法就是TMP。
另外,上一条款也提到过,typeid-base的实现方式可能导致编译期问题。
std::list::iterator iter;
advance(iter,10); // 移动iter向前移动10
这一版调用advance,将template参数替换为iter和10的类型之后,我们可以得到这些:
void advance(std::list::iterator& iter,int d)
{
if(typeid(std::iterator_traits::iterator>::iterator_category) ==
typeid(std::random_access_iterator_tag))
{
iter += d;
}
else
{
...
}
}
问题就出现在 "+="的操作符上。list迭代器完完全全不支持 "+="操作。我们也知道程序绝对不会走到去执行那一行,因为测试typeid那么里永远是失败的。
但是编译器必须确保所有与源码有效,纵使那份代码根本不会执行起来!与此对比的是traits-based TMP解法,针对不同的类型信息而进行不同的代码,每个函数所使用的操作都可以施行于该函数对应的类型。
当然上述traits-base TMP解法这是TMP的一种应用。它十分强大,你可以使用TMP声明变量、执行循环、编写及调用其他函数……如何编写这些构件,这是和正常的C++程序很不一一的。正如编写TMP展示的if else语句,它藉由的是templates和其特化体表现出来的。
所以,那让我来看看“循环事务如何在TMP中运作”。TMP中并没有真正的循环构件,所以循环的效果会藉由递归(recursion)来完成。所以,这得要求你对递归十分熟悉。TMP主要是一个 "函数式语言"(functional language),所以递归同TMP是密不可分的。TMP 的递归甚至不是正常的种类,它循环并不涉及递归的函数调用,而是涉及 “函数模板具现化”(recusive template instantiation)。
也许到这里你仍然和我一样是一头雾水,这到底是要将一篇什么东西。入学递归的第一课就是计算阶乘。所以,TMP的起手程序也是这样,我们编写一个编译期计算阶乘的函数。这同每一个程序员学会的第一行代码一样 “Hello world”。
template
struct Factorial // 一般情况Factorial
{
enum { value = n * Factorial::value }; // n * Factorial的值
};
template<>
struct Factorial<0> // 特殊情况
{
enum { value = 1}; // Factorial<0> = 1
};
有了这个模板元编程,只要你指涉Factorial
循环发生template具现体,Factorial
每个Factorial内部有一个value,如果TMP就是一个拥有真正的循环构件,这里的value应该每次在循环里都会得到更新。由于TMP使用"递归模板具现化",所以每一个具现体都有自己的一份value,而每一个value有其循环内的是当值。
当然这里的TMP示范的用途,就像你敲下 “Hello World”一样没有任何让人兴奋 的地方,不过那却是你踏上道路的开始。为领悟TMP之所以值得学习,那我们不得不先理解使用TMP达成的目标是什么。这里会给出三个例子:
■ 确保量度单位正确。
科学与工程应用中的度量单位是很严谨的。讲一个质量赋值给变量是荒唐的,但是将一个距离变量 / 时间变量 赋值给一个速度变了则是成立的。使用TMP,就可以确保这些问题在程序编译期就会被侦测出来。TMP用来对早期错误进行侦测。
■ 优化矩阵运算。
Bigmatrix m1,m2,m3,m4,m5 // 创建矩阵
// 提供operator* 的重载
Bigmatrix result = m1 * m2 * m3 * m4 * m5; //计算乘积
我们想对一批矩阵进运算,以正常的函数调用动作,会创建4个临时对象用以保存两个对象运算的结果(例如m1*m2)。加之各自独立的乘法产生4个作用于矩阵元素身上的循环。如果使用更高级的、与TMP相关的template技术,就有可能消除那些临时对象并且合并循环。也不需要改动客户端的用法。于是乎TMP软件使用较小的内存,执行速度又有戏剧性的提升。
■ 生成客户定制之设计模式实现品(custom design pattern)。
设计模式诸如Strategy,Observer(观察者),Visitor(访问者)等等都可以多种方式实现出来。运用所谓policy-based design之TMP-based技术,有可能产生一些templates用来表述独立的设计选项。这项技术被拿来用为若干templates实现出智能指针的行为政策,用以在编译期间生成数以百计不同的智能指针类型,这项技术更广义地成为generative programming(殖生式编程)的一个基础。
不是每个人都喜欢TMP。其语法不直观,甚至晦涩难懂,支持工具不完善等等,但其能够将工作从运行期转移到编译期所带来的效率改善依然令人印象深刻,而TMP对 "难以或者甚至不可能于运行期实现出来的行为"的表现力也同意吸引人。
请记住:
◆ Template metaprogramming(TMP,模板元编程)可将工作由运行期移往编译期,因而得到以实现早期错误侦测和更高的执行效率。
◆ TMP可悲用来生成 "基于政策选择组合(based on combinations of policy)的客户定制代码",也可以用来避免生成对某些特殊类型并不适合的代码。
本篇到此结束,感谢你的阅读。
祝你好运,向阳而生~