泛型和面向接口
再说起鸭子类型,其实C++和java已经考虑到了,这就是泛型。泛型主要是用来做代码复用的,因为前述它的优点:使得程序员能将注意力从底层细节解放出来,在C++和java体现不是那么明显,因为某些后面会说的原因,细节反而会更多,写起来还麻烦
静态类型语言中每个变量的类型都是固定的,如上一篇末尾所说,要想实现鸭子类型就得结合代码。鸭子类型只需要关注“能做什么”,不关注“是什么”,静态类型语言借助接口,就可以实现
比方说,对一个int数组排序,我们可能写这样的:
void sort(int[] a)
然后用特定算法来实现,如果我们需要一个long数组排序的,就得再写一个:
void sort(long[] a)
如果是其他类型,那还得再来一个。。。事实上,java的Arrays类里面有很多很多sort接口,代码差不多都重复的
对于这个问题,其实只要一个接口就能解决,假设采用比较排序 ```void sort(IComparable[] a)
则只要输入的数组的元素能比较(实现IComparable接口),就能正常工作,具体是什么不关心,这就实现了鸭子类型,或者,用泛型方式写(java形式):
void sort(T[] a)
接口定义变量可以实现鸭子类型,不过有时候我们不但要知道一个变量可以怎样用,还需要知道它具体是什么,换句话说我们需要知道当时确切的类型,这就得用到泛型,泛型就是说我们可以把类型当成一个变量一样传给一个特殊的类或者函数,先看看java泛型的实现,不过java的泛型实现并没有解决上面说的问题,没法得到传入的具体类型
代码:
public static void f(T t)
{
t.f();
}
f(new A());
结果是编译失败,而且是在编译这个f的时候,原因在于java的泛型实际上是用Object来实现的,Object是一切类的基类,上面的代码相当于:
public static void f(Object t)
{
t.f();
}
f(new A());
这当然会编译错误,不过把t.f()改成t.toString(),就ok了,因为toString是Object的方法,如果要用t.f(),就得:
public static void f(T t)
{
t.f();
}
f(new A());
指定T必须是InterfaceHasFuncF的子类或一个实现,InterfaceHasFuncF有f方法,这样就没问题了,当然这段代码实际相当于
public static void f(InterfaceHasFuncF t)
{
t.f();
}
f(new A());
由于java的泛型是这种实现,所以有些编译期需要确定的就没有办法做到了,比如:
public static void f(T t)
{
new T();
}
这是不行的,因为f可能在使用之前已经编译了,这时候还不知道传什么,而如果这时候把T当Object来new,又跟用户的期望不符,所以无法实现
可以看出,java的泛型实际就是用指定的基类(或接口,不指定就默认Object)引用来实现,可以传入其派生类的对象引用,这实际上就是利用多态来实现泛型,既然如此,为何不直接用多态,就像上面的两个非泛型的实现呢?
实际上,在Java5引入泛型之前,类似的事情就是这么做的,只不过这么一来,程序里面就得到处做类型转换了,对象的类型转换很多是运行时检查,被认为是不安全的,为了减少运行期的损耗和风险,就引入了泛型,这样一来,编译期就可以做更多的检查
所以Java5之前的情况,跟前述动态类型有相似的地方,一个函数可能只接收Object或某基类类型参数,然后通过基类接口来执行子类的实现,容器也统一存放Object,往容器放数据的时候可以自动转类型,拿出来的时候强制转换为需要的类型。和动态类型不同的原因在于,一个类型的引用只能调用这个类型支持的方法,比如Object有hashCode方法,我怀疑就是给hash表特殊照顾的,至于TreeMap需要实现比大小的方法,但是Object没有,所以TreeMap的实现就只能在代码里面强转类型了,至于动态类型语言为何没有这个问题,后面再说了
和java不同,C++的泛型使用模板实现,本质是代码替换,有更高的灵活性,相当于编译期校验的鸭子类型。如果说java是利用多态实现泛型,C++反过来用泛型来实现了另一种形式的多态,举个例子,这是一般的多态:
class B : public A;
void f(A *a)
{
a->func();
}
A a;
B b;
f(&a);
f(&b);
如果func是个虚函数,那么f就会根据指针指向的对象的实际类型来调用func实现,假设没有虚函数,则这段代码不得不写成:
void f_A(A a)
{
a->func();
}
void f_B(B a)
{
a->func();
}
A a;
B b;
f_A(&a);
f_B(&b);
这样一来,也实现了同样的功能,问题是重复代码太多了,维护不方便,试想假如f输入参数有10个,每个都可能是A*或B*,那我们岂不是要把f重复1024次,那太多了,改成虚函数,就可以避免这个问题,但解决这个问题还有一条路走,就是代码的自动生成,于是模板出现了:
template
void f(T *a)
{
a->func();
}
A a;
B b;
f(&a);
f(&b);
C++模板是一个动态性静态化的好例子,上面这段代码本质是动态的,编译器帮我们把它静态化了,这样也省了运行时的开销,而且,它比多态更灵活,多态要求传入的对象类型必须是参数类型或其派生类,模板就没这个限制,只要传入的a指针能调用->func()即可,这跟鸭子类型一样,而且这个T也可以用来定义新的变量,传入的类型也可以是基础类型int,double等,而不像java必须搞个Integer和Double这堆东东
不过,C++这种实现方式带来了过度的灵活性,典型的就是代码的二义性,比如说:
template
void f(T t)
{
int a;
{
T::x a; //1
}
{
T::y a; //2
}
}
位置1处的问题是很明显的,看上去是定义一个指针,但如果T::x不是类型,则会做一次乘法,位置2处稍微隐蔽些,从写法上直觉是定义一个对象,但如果T::y和T::z不是类型,则尖括号会被解释为小于和大于,成为一个表达式,虽然看起来有些怪,但是在C的语法里是合法的
为解决这个问题,C++模板引入了typename关键字,有人可能会争辩,说上述两个例子虽然有二义性,但是具体到一个T,是没有歧义的,这样还更灵活,但这种说法是不恰当的,因为当一个模板被实现的时候,一行代码的具体意义一般是确定的,就好比当我们调用基类方法的时候,不知道会实际执行到哪个子类的实现,但基类方法本身有它的意义,各子类的实现也应该相同(虽然实际可以不同),原则上如果重载operator==,那么应该进行全等比较运算,如果这时候故意实现为operator!=的含义,虽然语法上没有什么错误,但一般来说没有意义,因此像乘法和指针定义这种基本不相干的含义,就需要进行区分了,过分灵活反而会造成麻烦,比如C的#define宏
不过有的时候,一个鸭子类型的代码虽然含义比较清晰,但仍然要考虑细节,比如:
def rate(a, b):
return a / b
``` 这个函数本意是计算a在b中占的比率,代码看上去也很清楚,但如果传入的a和b都是整数,比如3和5,就会因为整数除法运算法则而返回0,不是期望的0.6,可能有人说改成float(a) / b,但如果a和b是大整数,那是不是要用Decimal,然后返回时再转,或者通过判断来拒绝非期望的类型输入等,这个矛盾是比较麻烦的,或许就因为这样,python3修改了整数除法的含义