业务逻辑中,很多逻辑上不同类型的东西,到了编程语言中,时常会退化成一种类型。一个最简单的例子就是货币。通常在我们编程时,采用一种类型,如double(有些系统中有专门的Currency类型,为了简便起见,这里使用double),来表示货币。 但是,随之而来的就是币种的问题。不同的币种间存在换算,也就是汇率的问题。比如我们有RMB和USD两种货币,分别表示人民币和美元。尽管都是货币(在代码中有相同的类型),我们却不能对他们直接赋值: double rmb_; double usd_=100; rmb_=usd_; //绝对不行,100美元可相当于768元人民币,尽管人民币在升值 必须进行汇率换算: rmb_=usd_*e_rate; e_rate就是汇率。这个谁都清楚。在逻辑上,100美元和768元人民币是等价的(假设今天的汇率是7.68),是可以兑换的。但在软件中,我们不能简单的赋值了事,必须做换算。 现在我们希望用代码直接表现逻辑上的意义,也就是用赋值操作:=,实现货币间的换算,该怎么做呢?啊对,没错,操作符重载。 我们可以重载operator=操作符,使其具备汇率换算的功能。(或许有人会提出异议,改变一个操作符已有的语义,是否违背大师们的教诲。但我个人认为,语义应当遵从业务逻辑,既然按照逻辑含义进行重载,不应该引发什么纠纷。否则还需要重载干吗?)但问题是,重载依赖于不同的类型,double operator=(double)的操作符定义是默认的,已经存在,无法以相同形式重载。再说,即便是可以,复制对象和被赋值对象的类型相同,如何区分两种类型的转换呢? 很明显,我们需要新的类型。typedef肯定是没指望的,因为它仅仅为一个类型起别名,并没有产生新的类型。所以,我们只能求助于类。我们可以以如下方式定义各种不同的货币类: class RMB { public: double _val; }; class USD { public: double _val; }; … 这样,便可以针对不同货币重载operator=: class RMB { public: RMB operator=(const RMB& v) { _val=v._val; } RMB operator=(const USD& v) { _val=v._val*e_rate; //货币换算 } public: double _val; }; class USD { public: USD operator=(const USD& v) { _val=v._val; } USD operator=(const RMB & v) { _val=v._val/e_rate; //货币换算 } public: double _val; }; 这样,我们便可以对两种货币赋值了: RMB rmb_; USD usd_; rmb_=usd_; //带货币换算的赋值操作 根据这个方法,我们一直往下推,可以构造出各种各样的货币,并且定义它们之间的转换: class UKP //英镑 {…} class JPD //日元 {…} … 不过有个问题,如果有10中货币,我们必须定义100个operator=的重载,而且都是些重复代码。这样太蠢了。得采用更好的方法才能实现我们的理想。 注意观察,每个货币类的代码都符合同一种模式,有很强的规律性。看出来了吧,这种情况非常适合使用C++的超级武器——模板。没错,说做就做: template<int CurrType> class Currency { public: double _val; }; 注意看,这里非常规地使用了模板的一个特性:非类型模板参数,就是那个int CurrType。模板参数通常都是一个类型,比如int什么的。但也可以是一个非类型的模板参数,就象这里的CurrType。传统上,非类型模板参数用于传递一个静态的值,用来构造模板类。但在这里,这个模板参数并没有被模板使用,也永远不会被使用。这个模板参数的作用就是“制造类型”: typedef Currency<0> RMB; //人民币 typedef Currency<1> USD; //美元 typedef Currency<2> UKP; //英镑 typedef Currency<3> JPD; //日元 … typedef本身不会产生新的类型,但是这里Currency<n>已经是完全不同的类型了。当一个模板被实例化成一个类的时候,只要模板参数的实参有所不同,便是一个不同的类型。我们利用了模板的这个特性,凭空制造出任意多个结构完全相同,但却是完全独立的类型。 好,下一步,便是重载operator=操作符。当然不能再做为每一对货币类型重载operator=的蠢事了。用一个成员函数模板就可以解决问题: double e_rate[10][10]; //汇率表
template<int CurrType> class Currency { public: template<int ct2> Currency<CurrType>& operator=(count Currency<ct2>& v) { _val=v._val * e_rate[ct2][CurrType]; //找出汇率表中相应的汇率, // 计算并赋值 } public: double _val; }; 操作符operator=的代码中,赋值对象v的值乘上一个汇率,这个汇率存放在汇率表中,通过模板参数CurrType和ct2检索(当然汇率表得足够大)。 这样,我们便可以随意地赋值,而无须关心货币转换的问题了: ///初始化汇率表 e_rate[0][0]=1; e_rate[1][0]=7.68; … //使用货币 USD usd_; UKP ukp_; JPD jpd_;
jpd_=usd_=ukp=rmb_; //成功!一切顺心。 需要说明的是,汇率表并没有在声明时就初始化,是考虑到汇率经常变动,不应当作为常量写死在代码中。更进一步可以使用一个类封装成可变大小的汇率表,甚至可以用某个文件或数据库对其初始化。 问题当然还有,货币是要参与运算的,否则没有什么用处。所以,我们还得使这些货币具备基本的计算能力。货币的计算,根据业务逻辑大致应具备以下能力: 1. +、-:两种货币的加法和减法,允许不同种货币参与计算,必须考虑转换操作,返回做操作数类型; 2. *、/:货币乘上或除以一个标量值,这里设定为double。但两种货币不能相乘或相除。 3. ==、!=:比较两种货币,允许不同种货币参与比较,但必须考虑转换操作。 还有其他的操作,暂不做考虑,毕竟这里的目的不是开发一个完整的货币系统。为了编码上的方便,这里同时还定义了四则运算的赋值版本:+=、-=、*=、/=。为了节省篇幅,这里只展示+、*和==的代码,其他代码类推: template<int ty, int tp> inline bool operator==(currency<ty>& c1, const currency<tp>& c2) { return c1._val==c2._val*curr_rate[tp][ty]; }
template<int ty, int tp> inline currency<ty>& operator+=(currency<ty>& c1, const currency<tp>& c2) { c1._val+=c2._val*curr_rate[tp][ty]; return c1; } template<int ty, int tp> inline currency<ty> operator+(currency<ty>& c1, const currency<tp>& c2) { currency<ty> t(c1); t+=c2; return t; } 请注意==和+操作符中的的货币转换运算,每次都是将第二操作数货币转换成第一操作数货币后再进行运算操作。第一参数和第二参数的类型不同,因此允许不同货币进行计算。这可以进一步简化代码,完全以逻辑的方式编程。 template<int ty> inline currency<ty>& operator*=(currency<ty>& c1, const double q) { c1._val*=q; return c1; } template<int ty> inline currency<ty> operator*(currency<ty>& c1, const double q) { currency<T, ty> t(c1); t*=q; return t; }
template<int ty> inline currency<ty>& operator*=(const double q,currency<ty>& c1) { return operator*=(c1, q); } template<int ty> inline currency<ty> operator*(const double q,currency<ty>& c1) { return operator*(c1, q); } … *操作符的参数只有一个是货币类型,另一个是double类型,表示数量。只有货币乘上数量才有意义,不是吗?*操作符包括两个版本,一个货币在前,数量在后;另一个数量在前,货币在后。为的是适应rmb_*1.4和1.4*rmb_两种不同的写法,算法是完全一样的。 现在,货币可以运算了: usd_=usd_*3; //同种货币运算 ukp_=rmb_*2.5; ///计算後直接赋值给另一种货币 jpd_=ukp_=rmb_+usd_; ///同上,但有四种货币参与运算 现在,货币运算非常方便了,不需要考虑货币种类,货币的转换是自动的,无需额外代码。 在简化代码的同时,也提供了操作上的约束,比如: ukp_=rmb_*usd_; ///编译错误。货币乘上另一种货币无意义!!! 这句代码会引发编译错误,因为我们没有为两种货币相乘提供*的重载。很明显,一种货币与另一种货币相乘是根本没有意义的。这里通过静态的重载类型检查,对施加在货币上的运算做出约束。促使违背逻辑的代码在第一时间被拦截,避免出现运行时错误。要知道,两种货币相乘,赋给另一个货币的错误是非常隐蔽的,只有盘库或结账的时候才会发现。 很好,这里我们利用了C++模板的一些特殊机制,以及操作符模板、操作符重载等技术,开发一个货币系统。这个系统可以用最简洁的语句实现各种货币的计算和转换功能。同时,还利用重载机制的强类型特性,提供了符合业务逻辑的操作约束。 货币运算只是一个简单的案例,但相关的技术可以进一步推广到更复杂的领域中。而且业务越复杂,所得到的收益越多。因此,充分理解并运用C++所带来的泛型编程功能,可以大大简化软件的开发、减少代码的错误,降低开发的成本。 这种技术适合用在一些逻辑上存在差异,但在物理上具备相同特征的实体上。一方面使这些实体在代码中强类型化,以获得重载和类型检测能力。由于代码中逻辑实体的对应类型强类型化,是我们可以通过重载和静态类型检测等技术手段,实现仅使用语言提供的要素,在代码中直接构造业务模型的能力。但手工对每一个逻辑实体进行强类型化,是费力的和琐碎的,并且存在着大量的重复劳动。此时,我们可以利用模板的抽象能力,反过来利用逻辑实体在物理上的共同特性,一次性构建抽象的模板,并利用模板实例化的一些特性,很方便地构造新的类型(仅仅一个typedef)。 这种技术进一步扩展后,可以有更高级的应用。一个经典的范例就是实现编译期的量纲分析。在Template Meta-programming一书中,对此有详细的讲解。
作为一个好事者,我希望能够给我周边的人讲解这种技术。他们对C++很不熟悉,但熟悉C#。于是,我打算把这种技术移植到C#中,以便於讲解。说做就做。 我建了一个C#项目,把代码拷贝过去,然后着手修改,这样可以省些事。我立刻便遇到了问题。C#有泛型,相当于模板,但不支持非类型泛型参数,即int CurrType,只允许用一个类型作为泛型参数。这样我们就不能使用C++中耍的手法了(typedef currency<n>)。退而求其次,直接用类实现货币类型: class RMB { public double _val; } class USD { public double _val; } … 这样太繁琐了,很多重复。我们可以用一个基类封装_val,货币类从基类上继承获得: class CurrBase { public double _val; } class RMB : CurrBase { } class USD : CurrBase { } … 货币类都是空的,它们的存在只是为了创建一个新的类型。 现在处理货币转换问题。C#不能重载operator=,所以只能使用一个helper函数泛型asign代替: class utility { public static void asign<T1, T2>(T1 c1, T2 c2) where T1 : CurrBase where T2 : CurrBase { c1._val = c2._val * utility.e_rate[c2.CurID(),c1.CurID()]; } } 这个asign函数是个泛型,泛型参数分别代表了两个操作数,函数中执行了货币转换。为了能够在汇率表中检索到相应的汇率,我们必须为基类和货币类定义抽象函数: public abstract class CurrBase { public double _val=0; public abstract int CurID(); } public class RMB : CurrBase { public override int CurID() { return 0; } } … 基类中声明了CurID()抽象方法,并在货币类中定义。这样,便可以用统一的方式进行货币转换了: asign(rmb_, usd_); 还行,尽管不那么漂亮,但也还算实用。不过,当我多看了几眼代码后,便发现这里根本没有必要使用泛型。完全可以利用OOP的多态实现同样的功能: public static void asign(CurrBase c1, CurrBase c2) { c1._val = c2._val * utility.e_rate[c2.CurID(),c1.CurID()]; } 不过也没关系,使用方式还没有变,代码反而更简单了。使用泛型毕竟不是我们的根本目的,对吧? 现在轮到运算符了。不过我不知该把泛型运算符定义在哪里。按C#文档里的要求,运算符必须是类的static成员。但我的泛型运算符是针对许多个货币类的,定义在任何一个中,对其他类似乎不太公平。于是,我决定尝试将其定义在基类里: public abstract class CurrBase { … public static CurrBase operator+<T1, T2>(T1 c1, T2 c2) where T1 : CurrBase where T2 : CurrBase { … } } 编译器立刻还我以颜色:操作符根本不能是泛型!好吧,不能就不能吧,继续退而求其次,用OOP: public abstract class CurrBase { … public static CurrBase operator+(CurrBase c1, CurrBase c2) { … } } 不过,这次让步让得有点离谱。当我写下这样的代码时,编译器居然不认账: rmb_=rmb_+usd_; 错误信息是:错误 CS0266: 无法将类型“st_in_cs.CurrBase”隐式转换为“st_in_cs.RMB”。存在一个显式转换(是否缺少强制转换?)。 我非得采用强制类型转换,才能过关: rmb_=(RMB)(rmb_+usd_); 太夸张了,这样肯定不行。于是,我被迫在每个货币类中定义operator+: class RMB : CurrBase { … public RMB operator+(RMB c1, USD c2) { … } public RMB operator+(RMB c1, UKP c2) { … } … } 这可不得了,我必须为每对货币类定义一个+操作符,+操作符的总数将会是货币类数量的平方!其他的操作符每个都是货币类数的平方。我可受不了! 好在,可爱的OOP为我们提供了一根稻草,使得每个货币类的每个操作符只需定义一个: class RMB : CurrBase { … public RMB operator+(RMB c1, CurrBase c2) { … } … } 这样,任何货币类都可以作为第二操作数参与运算,而操作符只需定义一个。这样的工作量,对于一个逆来顺受的程序员而言,还是可以接受的。很好,代码不出错了: rmb_=rmb_+usd_; 但当我写下如下代码时,编译器又开始抱怨了: ukp_ = rmb_ + usd_; 还是要求显示转换,除非我们为UKP定义隐式类型转换操作符: class UKP { … public static implicit operator UKP(RMB v) { … } … } 光有RMB的不行啊,还得有USD的、JPD…。不过这样的话,我们必须为每一个货币类定义所有其它货币类的类型转换操作符。又是一个组合爆炸。到这里,我已经黔驴技穷了。谁让C#不支持=操作符重载和操作符模板化呢。没办法,只能忍着点了。 不过,如果我们能够降低点要求,事情还是有转机的。如果我们不通过操作符,而是采用static成员方法,进行货币的运算的话,就可以省去很多代码了: public class utility { public static T1 asign<T1, T2>(T1 c1, T2 c2) where T1 : CurrBase, new() where T2 : CurrBase { c1._val = c2._val * utility.curr_rate[c2.CurID(),c1.CurID()]; return c1; } public static T1 add<T1, T2>(T1 c1, T2 c2) where T1 : CurrBase, new() where T2 : CurrBase { T1 t=new T1(); t._val=c1._val + c2._val * utility.curr_rate[c2.CurID(),c1.CurID()]; return t; } … } 这里,我还是使用了泛型,因为这些函数需要返回一个值,只有使用泛型,才能返回一个明确的类型,以避免强制转换的要求。于是,赋值和计算的代码就成了: asign(jpd_, asign(ukp_, add(rmb_, usd_)));//也就是jpd_=ukp_=rmb_+usd_ 的确是难看了点,但是为了能够少写点代码,这也只能将就了。 好了,我尽了最大的努力,试图在C#中实现强类型、可计算的货币系统。尽管最终我可以在C#中开发出一组与C++具有相同效果的货币类(除了赋值操作以外),但需要我编写大量的代码,实现各种计算操作,以及货币类之间的类型转换操作(组合爆炸)。相比C++中总共200来行代码,的确复杂、臃肿得多。 我并不希望把这篇文章写成“C++ vs C#”,(尽管我很高兴看到C++比C#强)。我希望通过对这样一个代码优化任务,显示不同技术运用产生的结果。同时,也可以通过这两种实现尝试的对比,了解泛型编程的作用,以及泛型编程对语言提出的要求。 毋庸置疑,C++采用了纯粹的泛型编程,因此可以对问题进行高度抽象。并利用问题所提供的每一点有助于抽象的信息,以最简的形式对问题建模。而作为以OOP为核心的语言C#,对泛型的支持很弱。更重要的是,C#的泛型对泛型参数的运用严重依赖於泛型参数的约束(where)。如果没有where,C#将泛型参数作为Object类型处理,此时泛型参数没有意义(我无法访问该类型的成员)。如果有了where,C#要求泛型参数必须同where中指定的类型有继承关系(如asign中的T1必须是CurrBase的继承类)。而泛型函数中对泛型参数的使用也局限在约束类型(即CurrBase)上。于是,我们可以直接用以基类(CurrBase)为参数的asign函数代替泛型版本的asign。由于C#对泛型参数的继承性要求,使得泛型被困住了手脚,无法发挥应用的作用。正由于这些问题,C++才采用了现在模板的形式,而没有采用同C#一样的泛型模式。 或许有人会说,既然OOP能解决问题(asign最初不需要泛型也行,但最终还需要泛型来控制返回值的类型),为什么还要GP呢? 对于这个问题,前面也给出了答案。由于C#的泛型不支持非类型泛型参数,因此迫使我们使用传统OOP的手段:利用基类实现货币类的实现,定义货币类来创建新类型,使货币强类型化,利用虚函数提供货币独有信息。仅这一层面,OOP方式已经大大不如GP方式了,GP仅定义了一个模板,所有的货币类型都是通过typedef一句语句产生,无需更多的代码。而OOP方式要求必须为每一个货币编写一个类,代码量明显多于GP方式。 此后,C++通过重载一组操作符模板,实现货币的运算。而货币模板以及生成货币类型的typedef都无须任何改变。而在C#中,由于不支持泛型操作符,被迫定义大量的特定于类型的操作符。所有运算操作符,在每个货币类中都必须重载一次。而转型操作符,则必须在每个货币类中重载n-1次。 换句话说,有n种货币,有m个操作符(包括转型操作符),那么就需要定义n+1个类(包括基类),n×m+n×(n-1)个操作符。假设n=10,m=10,那么总共需要11个类定义,190个操作符重载!如果每个类定义需要20行代码,而每个操作符重载需要5行代码,那么总共需要1170行代码。如果货币数量增加,总的代码数将以几何级数的方式增长。 上面的计算表明,尽管OOP可以解决问题,实现我们的目标,但所带来的开发量和维护量却是难以承受的。而且,OOP的方式扩展非常困难,随着系统规模的扩大,扩展将越来越困难。所有这些都表明一点,尽管OOP是软件工程的明星,但在实际情况下,很多地方存在着OOP无法解决或难以解决的问题。这也就是为什么业界的先锋人物不断拓展和强化泛型编程的原因。
|