Java编程笔记14:泛型(续)
图源:PHP中文网
问题
在前文已经说过,Java因为历史原因,实现的泛型机制是不完整的,因此存在一些问题(限制),下面来详细讨论这些问题以及解决方案。
无法使用基本类型
Java的泛型存在很多限制,无法使用基本类型就是其中之一,幸运的是因为包装类的存在,在大多数情况下“自动解包”和“自动打包”都可以帮我们解决将基础类型应用到泛型的相关问题。
package ch14.base_type;
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<Integer> numbers = new ArrayList<>();
for (int i = 1; i <= 10; i++) {
numbers.add(i);
}
for (int num : numbers) {
System.out.print(num + " ");
}
}
}
// 1 2 3 4 5 6 7 8 9 10
在上面这个示例中,numbers
是一个使用Integer
作为泛型类型参数的List
,但是在使用其add
方法添加元素时,可以使用int
而非一定要使用Integer
,这就是包装类的自动打包机制在帮我们。
示例中的foreach
显示了自动解包可以和foreach
结合的很好,从List<Integer>
类型中自动以int
类型将元素取出。
大多数情况下自动打包和解包都能很好的执行,但某些情况下也会遇到问题:
package ch14.base_type2;
import java.util.Arrays;
import java.util.Random;
interface Generator<T> {
T next();
}
class IntGenerator implements Generator<Integer> {
private static Random random = new Random();
private int bound;
private static final int DEFAULT_BOUND = 100;
public IntGenerator() {
this(DEFAULT_BOUND);
}
public IntGenerator(int bound) {
if (bound > 0) {
this.bound = bound;
}
}
@Override
public Integer next() {
return random.nextInt(bound);
}
}
public class Main {
public static void main(String[] args) {
final int SIZE = 10;
Integer[] numbers = fillArray(new Integer[SIZE], new IntGenerator());
System.out.println(Arrays.toString(numbers));
// Integer[] numbers2 = fillArray(new int[SIZE], new IntGenerator());
// The method fillArray(T[], Generator<T>) in the type Main is not applicable
// for the arguments (int[], IntGenerator)Java(67108979)
}
private static <T> T[] fillArray(T[] array, Generator<T> generator) {
for (int i = 0; i < array.length; i++) {
array[i] = generator.next();
}
return array;
}
}
注释部分的代码中,fillArray
的参数array
是int[]
,而用于填充的generator
是Generator<Integer>
,你可能会期望自动打包能帮你将int[]
转换为Integer[]
,但实际上包装类是无法作用于数组的,所以这种事并不会发生,这段代码也就无法通过编译。
实现参数化接口
因为类型擦除的存在,是无法同时实现同一个泛型接口的不同类型参数的:
package ch14.gen_interface;
interface Playable<T> {
void play(T obj);
}
// class Person implements Playable<String>, Playable<Integer> {
// The interface Playable cannot be implemented more than once with different
// arguments: Playable<Integer> and Playable<String>
// }
public class Main {
public static void main(String[] args) {
}
}
被注释的部分中,Person
类分别实现了使用String
和Integer
两种类型参数的Playable
接口,在真实运行时,这两种接口都会被擦除为Playable<Object>
,也就是说在一个类中同时实现了一个接口两次,这是不被允许的。
package ch14.gen_interface2;
interface Playable<T> {
void play(T obj);
}
class Person implements Playable<String> {
@Override
public void play(String obj) {
System.out.println("Person play " + obj);
}
}
// class Student extends Person implements Playable<Integer> {
// The interface Playable cannot be implemented more than once with different
// arguments: Playable<String> and Playable<Integer>Java(16777755)
// }
public class Main {
public static void main(String[] args) {
}
}
上边的示例说明了就算不是同一个类,子类也是无法重复实现父类实现过的泛型接口的不同参数类型版本的。
有意思的是,如果使用非泛型的原始版本就不会有问题:
package ch14.gen_interface3;
interface Playable<T> {
void play(T obj);
}
class Person implements Playable {
@Override
public void play(Object obj) {
System.out.println("Person play " + obj);
}
}
class Student extends Person implements Playable {
}
public class Main {
public static void main(String[] args) {
Student s = new Student();
s.play("toy");
}
}
// Person play toy
转型和警告
看一段典型的泛型类代码:
package ch14.cast;
class SimpleStack<T> {
private Object[] items;
private int index = 0;
public SimpleStack(int limit) {
this.items = new Object[limit];
}
public void push(T item) {
if (index < items.length) {
items[index] = item;
index++;
}
}
@SuppressWarnings("unchecked")
public T pop() {
if (index > 0) {
index--;
return (T) items[index];
}
return null;
}
}
public class Main {
public static void main(String[] args) {
SimpleStack<String> ss = new SimpleStack<>(10);
for (String string : "a b c d e f".split(" ")) {
ss.push(string);
}
do {
String item = ss.pop();
if (item == null) {
break;
}
System.out.print(item + " ");
} while (true);
}
}
如果pop
方法没有@SuppressWarnings
标签,就会产生一个warning
,原因是items
是Object
类型的数组,而要将其转换为T
类型,无法确保一定能转换成功。虽然从道理上讲,items
只能由push
方法写入,而其参数T item
由泛型的静态检查确保了传入类型必然与pop
需要转型的类型一致,但编译器并不清楚这一点,所以这种warning
是无法避免的,只能用@SuppressWraning
标签压制。
重载
和实现泛型方法时可能遇到的问题类似,如果重载方法时涉及泛型,就可能会因为擦除的原因产生问题:
package ch14.overload;
interface Generator<T> {
T next();
}
class OverloadTest<T> {
// public void f(Generator<String> gen) {
// // Erasure of method f(Generator<String>) is the same as another method in
// type
// // OverloadTest<T>Java(16777743)
// }
// public void f(Generator<Integer> gen) {
// }
}
public class Main {
public static void main(String[] args) {
}
}
被注释的部分试图重载方法f
,并且只包含一个使用了不同类型参数的参数gen
,被擦除后是无法用于区分重载方法的,因此这样的重载无法通过编译。
类似的问题可以通过使用不同的方法名而非重载来规避:
...
class OverloadTest<T> {
public void f1(Generator<String> gen) {
}
public void f2(Generator<Integer> gen) {
}
}
...
基类劫持了接口
先看这么一个示例:
package ch14.hijack_interface;
class Animal implements Comparable<Animal> {
@Override
public int compareTo(Animal o) {
return 0;
}
}
// class Cat extends Animal implements Comparable<Cat> {
// The interface Comparable cannot be implemented more than once with different
// arguments: Comparable<Animal> and Comparable<Cat>Java(16777755)
// }
public class Main {
public static void main(String[] args) {
}
}
示例中有一个实现了Comparable<Animal>
接口的Animal
类,其子类Cat
也同样需要实现一个Comparable<Cat>
接口,因为Cat
显然也只能与Cat
比较。
但因为之前所说的原因,Java中是不允许子类这么做的,看上去就好像父类Animal
“劫持”了Comparable
接口一样。
当然,如果子类实现具备相同类型参数的泛型接口,是被允许的:
...
class Cat extends Animal implements Comparable<Animal> {
}
...
自限定类型
在介绍自限定(self bounded)类型前,先来看一个比较奇怪的示例:
package ch14.self_bounded;
class BaseClass<T> {
private T content;
public void set(T content) {
this.content = content;
}
public T get() {
return content;
}
}
class SubClass extends BaseClass<SubClass> {
}
public class Main {
public static void main(String[] args) {
SubClass sc = new SubClass();
sc.set(new SubClass());
System.out.println(sc.get());
}
}
在这个示例中,SubClass
继承了泛型类BaseClass
,并且将自己作为其类型参数。乍一看相当古怪,这样的做法相当于用子类来指定父类的类型参数。
之前谈论OOP的时候提到过,如果涉及到继承关系,都是像洋葱生长那样,从内到外依次创建,但是上边说的这种情况就很奇怪,但因为泛型的类型参数实际上会被擦除,所以这样的方式并不会实际违反继承关系下对象的创建规则。
这样做会产生一个实际效果,即子类SubClass
通过继承,获取父类Base
的操作,且同时Base
操作中使用的类型参数,都被替换为子类SubClass
。最终的效果就像是通过继承利用父类创建了一个关于子类的“模版类”。
如果更激进一点,在基类中将类型参数进行限定,将其范围仅限于继承自身的类型,就是我们所说的自限定类型了。
自限定类型,英文原文为(self bounded type),字面意思就是用自己作为边界的类型。
package ch14.self_bounded2;
class Person<T extends Person<T>>{
public void play(T person){
System.out.println("Person play with Person.");
}
}
class Student extends Person<Student>{
}
public class Main {
public static void main(String[] args) {
Student s = new Student();
s.play(new Student());
}
}
上边示例中的Person<T extends Person<T>>
实际上就是自限定类型。通过这种方式,将Person
的类型参数限定在继承自Person<T>
的子类。因此下面这样的代码是无法通过编译的:
...
class Teacher extends Person<String> {
// Bound mismatch: The type String is not a valid substitute for the bounded
// parameter <T extends Person<T>> of the type Person<T>Java(16777742)
}
...
但同样的,虽然一般来说子类将自己作为父类的类型参数才有意义,但是从语法的角度,自限定类型是允许子类使用不相干的其它子类作为类型参数的:
...
class Coder extends Person<Student> {
}
...
自限定类型的用途
说了这么多,这东西有什么用?
来看一个上边示例稍微修改后的例子:
package ch14.self_bounded4;
class Person<T extends Person<T>>{
public void play(T person){
System.out.println("Person play with Person.");
}
}
class Student extends Person<Student>{
@Override
public void play(Student student) {
System.out.println("Student play with Student.");
}
}
public class Main {
public static void main(String[] args) {
Student s = new Student();
s.play(new Student());
// s.play(new Person());
// The method play(Student) in the type Student is not applicable for the arguments (Person)
}
}
和之前代码唯一的区别是,在Student
中重写了play
方法。可能有人觉得不以为然,就这?
需要注意的是,上边的代码客观上实现了参数协变,也就是Student
在重写play
方法的同时,将其参数类型从T
(或者说Object
)变为了Student
。main
中的测试代码也说明了这一点,s.play
只会接受Student
类型的参数。
我们都知道,返回值协变在Java中很普遍,这也很好理解。但正常情况下,是无法实现“参数协变”的:
package ch14.self_bounded5;
class Plane {
}
class FighterPlane extends Plane {
}
class Pilot {
public void play(Plane p) {
System.out.println("Pilot play Plane.");
}
}
class FighterPilot extends Pilot {
public void play(FighterPlane fp) {
System.out.println("FighterPilot play FighterPlane.");
}
}
public class Main {
public static void main(String[] args) {
FighterPilot fp = new FighterPilot();
fp.play(new Plane());
fp.play(new FighterPlane());
}
}
// Pilot play Plane.
// FighterPilot play FighterPlane
上边示例中,Plane
代表一般化的飞机,FighterPlane
代表战斗机,相应的,Pilot
代表飞行员,而FighterPilot
代表战斗机飞行员。这两组类分别具备继承关系,很明显,Pilot
和FighterPilot
的play
方法看上去很像是“参数协变”,这从含义上也说的通,普通飞行员开普通飞机,战机飞行员开战斗机。
但实际上这并非“参数协变”,因为Java的语法决定了FighterPilot
中的play
方法实际上是方法重载(overload),而非覆盖(override),在main
函数中的测试结果也说明了这一点。
不允许“参数协变”是有意义的,因为这显然会违背“李氏替换原则”:如果子类型对父类型的方法进行覆盖,并对参数协变,那就无法将子类型当做父类型那样调用该方法了。
但通过泛型和自限定类型可以在某种程度上实现“参数协变”,就像我们之前展示的那样。这是由泛型的特殊实现方式决定的,我不清楚这是Java官方的有意为之还是无意之举,但这种特性依然具有相当的局限性:无法实现多层次的“参数协变”,且协变参数必须限制为基类的子类。
动态类型安全
利用泛型,可以很好的解决向容器中添加非法数据的问题,但有时候你可能会在工作中使用JavaSE 5之前的没有使用泛型的老代码,此时依然会遇到一些头疼的问题:
package ch14.checked_colloection;
import java.util.ArrayList;
import java.util.List;
class Pet {
};
class Cat extends Pet {
}
class Dog extends Pet {
}
public class Main {
public static void main(String[] args) {
List<Cat> cats = new ArrayList<>();
addDog(cats, new Dog());
Cat c = cats.get(0);
// Exception in thread "main" java.lang.ClassCastException: class
// ch14.checked_colloection.Dog cannot be cast to class
// ch14.checked_colloection.Cat (ch14.checked_colloection.Dog and
// ch14.checked_colloection.Cat are in unnamed module of loader 'app')
// at ch14.checked_colloection.Main.main(Main.java:19)
}
@SuppressWarnings("unchecked")
public static void addDog(List dogs, Dog dog) {
dogs.add(dog);
}
}
假设上边示例中的main
方法之外的代码都是老的库代码,使用的是容器的非泛型版本,我们无法彻底迁移到泛型版本。main
方法中对addDog
进行调用,但传入的类型实际上是错误的,本应该传入List<Dog>
,但我们传入了List<Cat>
,当然,在添加的时候并不会触发任何警告或错误,但如果试图从cats
中取出一个Cat
对象,就会报错,这无疑会给这类使用老代码的程序debug带来一些额外的困难。
为此,Java提供了一些“受检查的容器”来解决此类问题:
package ch14.checked_colloection2;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
class Pet {
};
class Cat extends Pet {
}
class Dog extends Pet {
}
public class Main {
public static void main(String[] args) {
List<Cat> cats = Collections.checkedList(new ArrayList<Cat>(), Cat.class);
addDog(cats, new Dog());
// Exception in thread "main" java.lang.ClassCastException: Attempt to insert
// class ch14.checked_colloection2.Dog element into collection with element type
// class ch14.checked_colloection2.Cat
// at
// java.base/java.util.Collections$CheckedCollection.typeCheck(Collections.java:3097)
// at
// java.base/java.util.Collections$CheckedCollection.add(Collections.java:3145)
// at ch14.checked_colloection2.Main.addDog(Main.java:25)
// at ch14.checked_colloection2.Main.main(Main.java:19)
Cat c = cats.get(0);
}
@SuppressWarnings("unchecked")
public static void addDog(List dogs, Dog dog) {
dogs.add(dog);
}
}
就像示例中展示的那样,通过类方法Collections.checkedList
就可以获取一个“受检查的List
”,需要传入两个参数:真实的List
对象以及元素的Class
对象,其它代码都无需改动。
可以看到,报错信息被提前到dogs.add(dog)
执行时,也就是说这种方式获取的List
,即使是老式代码中使用非泛型句柄承接,并调用add
添加元素,也会进行类型检查,如果类型不符合预期,就会报错。通过这种方式,可以在不修改老代码的同时,提供向容器添加元素时确保类型检查能够执行的能力。
除了checkedList
方法,还有CheckedMap
、CheckedSet
等。
异常
异常是一种特殊的类,但因为擦除的关系,在异常类中使用类型参数表示异常类同样是相当受限的,比如你无法用类似throw new T();
这样的写法去抛出异常,除非你传入一个异常的Class
对象。
但就像泛型类中返回一个类型参数时,编译器会自动完成转换那样,如果异常捕获声明中使用类型参数,编译器同样会确保抛出的异常是相应的类型,以便客户端代码用同样的异常类型去捕获。
下面用一个简单的示例来进行说明:
package ch14.exp;
import java.lang.reflect.Array;
import java.util.Arrays;
interface ArrayCreator<T, E extends Exception> {
T[] creat(Class<T> cls, int num) throws E;
}
class ParamError extends Exception {
}
class StrArrayCreator implements ArrayCreator<String, ParamError> {
@Override
public String[] creat(Class<String> cls, int num) throws ParamError {
if (num <= 0) {
throw new ParamError();
}
return (String[]) Array.newInstance(cls, num);
}
}
public class Main {
public static void main(String[] args) {
ArrayCreator<String, ParamError> ac = new StrArrayCreator();
try {
String[] arr = ac.creat(String.class, 10);
System.out.println(Arrays.toString(arr));
arr = ac.creat(String.class, 0);
System.out.println(Arrays.toString(arr));
} catch (ParamError e) {
e.printStackTrace();
}
}
}
// [null, null, null, null, null, null, null, null, null, null]
// ch14.exp.ParamError
// at ch14.exp.StrArrayCreator.creat(Main.java:18)
// at ch14.exp.StrArrayCreator.creat(Main.java:1)
// at ch14.exp.Main.main(Main.java:31)
这个例子相当简单,就不过多解释。要说明的是,你可能希望将ArrayCreator
扩展为一个抽象类,或者使用接口的默认实现,以便将creat
方法中的一些通用做法提供一个默认实现版本,这样就可以很容易通过继承来实现StrArrayCreator
或IntegerArrayCreator
等。但就像前边说的,这样是有困难的,因为你不能直接在泛型抽象类的creat
方法中编写throw new E();
这样的代码,除非额外传入一个异常的Class
对象。
混型
所谓的混型或混入(Mix in),其实就是让一个类同时具备多个其它类的特点。
这很容易让你联想到多继承,实际上实现混型最简单的方式就是多继承,然而Java并不支持多继承。
Python是支持多继承的,这里先使用Python做一个简单说明:
class Base:
def __init__(self, obj: object = None) -> None:
super().__init__()
self.obj = obj
def getObj(self) -> object:
return self.obj
def setObj(self, obj: object) -> None:
self.obj = obj
class Counter:
def __init__(self) -> None:
super().__init__()
num: int = getattr(self.__class__, "num", 0)
num += 1
self.id: int = num
setattr(self.__class__, "num", num)
def getId(self) -> int:
return self.id
class Mixin(Base, Counter):
def __init__(self, obj: object = None) -> None:
super().__init__(obj)
print(Mixin.__mro__)
mix: Mixin = Mixin()
print(mix.getId())
mix.setObj("hello")
print(mix.getObj())
# (<class '__main__.Mixin'>, <class '__main__.Base'>, <class '__main__.Counter'>, <class 'object'>)
# 1
# hello
再上边这个示例中,Counter
类具备给每个实例分配id
的功能(从1到N),而Base
是一个简单的存放对象和获取对象的类,我们可以通过让Mixin
同时继承Base
和Counter
来让其同时具备两个类的功能,这种情况下,就可以称呼Mixin
是Base
和Counter
的“混入”。
更多有关多继承的讨论可以阅读Python学习笔记29:继承的优缺点 - 魔芋红茶’s blog (icexmoon.cn)。
使用泛型实现混入
之所以会在这里提到混入,是因为在C++中,可以很容易地通过让泛型类继承泛型来实现混入:
//: generics/Mixins.cpp
#include <string>
#include <ctime>
#include <iostream>
using namespace std;
template<class T> class TimeStamped : public T {
long timeStamp;
public:
TimeStamped() { timeStamp = time(0); }
long getStamp() { return timeStamp; }
};
template<class T> class SerialNumbered : public T {
long serialNumber;
static long counter;
public:
SerialNumbered() { serialNumber = counter++; }
long getSerialNumber() { return serialNumber; }
};
// Define and initialize the static storage:
template<class T> long SerialNumbered<T>::counter = 1;
class Basic {
string value;
public:
void set(string val) { value = val; }
string get() { return value; }
};
int main() {
TimeStamped<SerialNumbered<Basic> > mixin1, mixin2;
mixin1.set("test string 1");
mixin2.set("test string 2");
cout << mixin1.get() << " " << mixin1.getStamp() <<
" " << mixin1.getSerialNumber() << endl;
cout << mixin2.get() << " " << mixin2.getStamp() <<
" " << mixin2.getSerialNumber() << endl;
} /* Output: (Sample)
test string 1 1129840250 1
test string 2 1129840250 2
*///:~
这样做的好处一目了然——具备相当的灵活性。但因为Java泛型实现的关系,代码实际执行时会将类型擦除,所以在Java中并不能让泛型类继承类型参数。
上边这个示例代码摘抄自《Java编程思想》。
接口
虽然Java不支持多继承,但依然可以用接口的方式来实现混入:
package ch14.mixin2;
class Base {
private Object obj;
public void setObj(Object obj) {
this.obj = obj;
}
public Object getObj() {
return this.obj;
}
}
interface Counterable {
public void count();
public int getId();
}
class Counter implements Counterable {
private int id = 0;
private static int count = 0;
@Override
public void count() {
id = ++count;
}
@Override
public int getId() {
return id;
}
}
class Mixin extends Base implements Counterable {
private Counter counter = new Counter();
@Override
public void count() {
counter.count();
}
@Override
public int getId() {
return counter.getId();
}
}
public class Main {
public static void main(String[] args) {
Mixin mix1,mix2;
mix1 = new Mixin();
mix1.count();
mix2 = new Mixin();
mix2.count();
System.out.println(mix1.getId());
System.out.println(mix2.getId());
mix1.setObj("hello");
System.out.println(mix1.getObj());
}
}
其缺点显而易见——需要构建大量的接口和承担“代理”功能的代码来实现混入。
装饰器模式
《Java编程思想》还介绍了如何用装饰器模式来实现混入,但老实说,我并不认为那是真正的装饰器模式。因为装饰器模式的关键在于,无论你装饰了一个东西多少个层级,那个东西还是原来的东西。
比如说,无论你是给蛋糕涂抹上奶油,加上水果还是说巧克力,你得到的依然是一个蛋糕。
但是混入的目的明显不同,如果你打算将蛋糕、水果和巧克力进行混入,得到的很可能是一个带点蛋糕的巧克力,或者是内部填入巧克力的苹果。换言之,最终的东西将同时具备蛋糕、巧克力和水果这三种功能,而并非单纯的一个蛋糕。
因此这里我不打算编写示例或者展示原书的示例,感兴趣的童鞋可以自行查找代码或者阅读原文。
动态代理
在Java中,可以使用反射和动态代理来实现混入:
package ch14.mixin3;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.HashMap;
import java.util.Map;
interface Baseable {
public void setObj(Object obj);
public Object getObj();
}
interface Countable {
public void count();
public int getId();
}
class Base implements Baseable {
private Object obj;
@Override
public void setObj(Object obj) {
this.obj = obj;
}
@Override
public Object getObj() {
return this.obj;
}
}
class Counter implements Countable {
private static int count;
private int id;
@Override
public void count() {
id = ++count;
}
@Override
public int getId() {
return id;
}
}
class TwoTuple<A, B> {
public final A a;
public final B b;
public TwoTuple(A a, B b) {
this.a = a;
this.b = b;
}
}
class Mixin {
Map<String, Object> callables = new HashMap<>();
private Mixin(TwoTuple<Object, Class<?>>... tuples) {
for (TwoTuple<Object, Class<?>> tuple : tuples) {
addCallable(tuple.a, tuple.b);
}
}
private void addCallable(Object obj, Class<?> cls) {
for (Method method : cls.getMethods()) {
if (!callables.containsKey(method.getName())) {
callables.put(method.getName(), obj);
}
}
}
public static Object newInstance(TwoTuple<Object, Class<?>>... tuples) {
Class<?>[] interfaces = new Class<?>[tuples.length];
int i = 0;
Mixin mixin = new Mixin(tuples);
for (TwoTuple<Object, Class<?>> tuple : tuples) {
interfaces[i] = tuple.b;
i++;
}
return Proxy.newProxyInstance(interfaces[0].getClassLoader(), interfaces, mixin.getInvocationHandler());
}
private InvocationHandler getInvocationHandler() {
return new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (callables.containsKey(method.getName())) {
return method.invoke(callables.get(method.getName()), args);
}
return null;
}
};
}
}
public class Main {
public static void main(String[] args) {
Object proxy = Mixin.newInstance(new TwoTuple<>(new Base(), Baseable.class),
new TwoTuple<>(new Counter(), Countable.class));
Baseable baseable = (Baseable) proxy;
baseable.setObj("hello");
System.out.println(baseable.getObj());
Countable countable = (Countable) proxy;
countable.count();
System.out.println(countable.getId());
}
}
这种方式实现混入的优点在于Mixin
类具备相当的通用性,可以利用它实现任意多个类的混入。缺点在于依然存在局限,比如因为使用多态代理的缘故,必须为要混入的类准备相应的接口。此外,Mixin.newInstance
方法返回的其实是一个动态代理,是Object
,我们要手动将其转换为相应的接口才能调用相应的方法,相对于其它语言中通过多继承实现来说,使用起来要麻烦一些。
关于动态代理的更多内容,可以阅读Java编程笔记12:类型信息 - 魔芋红茶’s blog (icexmoon.cn)。
潜在类型
潜在类型有一个更广为人知的称呼——“鸭子类型”。关于鸭子类型,通常会用下面一段初看匪夷所思,后来会觉得非常贴切的话来描述:
//如果一个东西看起来像鸭子,会像鸭子那样叫,也会像鸭子那样走,那么它就是鸭子。
这种思想广泛存在于各种编程语言中,不过它们的称呼可能会不一样,比如在Python中会被称作“协议”:
from typing import Any
class Handler:
def __init__(self) -> None:
self.content = None
def add(self, obj: Any) -> None:
self.content = obj
def clear(self) -> None:
self.content = None
def __str__(self) -> str:
return "Handler({})".format(self.content)
class Query:
def __init__(self) -> None:
self.list = list()
def add(self, obj: Any) -> None:
self.list.append(obj)
def clear(self) -> None:
self.list.clear()
def __iter__(self):
return self.list.__iter__()
def __str__(self) -> str:
return "Query({})".format(self.list)
def test(addable):
addable.clear()
numbers: list = range(10)
for i in numbers:
addable.add(i)
print(addable)
test(Handler())
test(Query())
# Handler(9)
# Query([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
在Python中,协议指那些并非由代码中以某种形式强制约束,而是只宽泛地由文档或口头约束形成的方法集合。比如上面示例中的Handler
和Query
,本质上是两个完全不相干的类,只不过它们都恰巧具备两个相同签名的方法add
和clear
(方法__str__
是特殊的“魔术方法”,所有类都具备,类似于Java中的toString
方法)。而我们可以将这两个方法看作是某种协议,只要同时具备这两种方法,就可以进行某种操作。事实上test
函数正是这么做的。
对于test
函数来说,它并不关心参数addable
具体类型是什么,只要它具备add
和clear
方法就可以正常操作,反之则会报错。
可以看到,这种方式具备相当的灵活性,而类似的代码在Java中就必须使用接口这种强制的形式来约束:
package ch14.duck2;
import java.util.ArrayList;
import java.util.List;
import util.Fmt;
interface Addable<T> {
void add(T item);
void clear();
}
class Handler<T> implements Addable<T> {
private T content;
@Override
public void add(T item) {
content = item;
}
@Override
public void clear() {
content = null;
}
@Override
public String toString() {
return Fmt.sprintf("Handler(%s)", content);
}
}
class Query<T> implements Addable<T> {
private List<T> list = new ArrayList<>();
@Override
public void add(T item) {
list.add(item);
}
@Override
public void clear() {
list.clear();
}
@Override
public String toString() {
return Fmt.sprintf("Query(%s)", list);
}
}
public class Main {
public static void main(String[] args) {
test(new Handler());
test(new Query());
}
private static void test(Addable<Integer> addable) {
addable.clear();
for (int i = 0; i < 10; i++) {
addable.add(i);
}
System.out.println(addable);
}
}
// Handler(9)
// Query([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
相较而言,Java这种方式的限制就很多了,比如有时候明明你要使用的目标类已经具备你需要使用的方法,但其没有实现相应的接口,此时你只能通过继承或者适配器模式来进行某种方式的转换后才可以使用,这需要付出额外的努力。
但也不是完全没有优点,学习Python的时候我就发现,协议这种东西虽然灵活度很高,但你需要付出额外的成本去阅读相关文档才能知晓某个协议包含哪些方法,这些东西往往通过纯代码很难确定。
有意思的是Go同样支持潜在类型,但同时不像Python那样存在没法通过代码确认潜在类型包含哪些方法的困扰。Go采取的方式是以代码的形式定义接口,但并不需要相应的类来显式实现该接口,所有具备接口中的方法的类会“自动”被视作实现该接口。
当然Go是不存在类的,只有结构体,这里这样说只是为了方便类比。
package main
import "fmt"
type addable interface {
add(int)
clear()
toString() string
}
type handler struct {
obj int
}
func (h *handler) add(item int) {
h.obj = item
}
func (h *handler) clear() {
h.obj = 0
}
func (h *handler) toString() string {
return fmt.Sprintf("handler(%d)", h.obj)
}
type query struct {
list []int
}
func (q *query) add(item int) {
q.list = append(q.list, item)
}
func (q *query) clear() {
q.list = nil
}
func (q *query) toString() string {
return fmt.Sprintf("query(%v)", q.list)
}
func main() {
test(&handler{})
test(&query{})
}
func test(add addable) {
add.clear()
for i := 0; i < 10; i++ {
add.add(i)
}
print(add.toString() + "\n")
}
// handler(9)
// query([0 1 2 3 4 5 6 7 8 9])
上面用Go编写的示例说明了前边的观点。
因为Go语言的类型与Java或Python有很大区别,为了简单起见这里直接指定数据类型为
int
,而非更宽泛的interface{}
,否则就需要使用Go的反射机制来编写相应的代码。
适配器模式
前面我们比较了Go、Python和Java实现和使用“鸭子类型”的优缺点,并提到Java通过接口方式实现的困难在于,某些时候无法确保已存在的类已经实现了我们需要的接口,这时候就可以用适配器模式来解决这一点:
...
class OldHandler<T> {
private T content;
public void set(T content) {
this.content = content;
}
public void clear() {
this.content = null;
}
@Override
public String toString() {
return Fmt.sprintf("OldHandler(%s)", content);
}
}
class OldHandlerAdapter<T> implements Addable<T>{
private OldHandler<T> oldHandler;
public OldHandlerAdapter(OldHandler<T> oldHandler){
this.oldHandler = oldHandler;
}
@Override
public void add(T item) {
this.oldHandler.set(item);
}
@Override
public void clear() {
this.oldHandler.clear();
}
@Override
public String toString() {
return oldHandler.toString();
}
}
public class Main {
public static void main(String[] args) {
test(new Handler());
test(new Query());
test(new OldHandlerAdapter(new OldHandler()));
}
private static void test(Addable<Integer> addable) {
addable.clear();
for (int i = 0; i < 10; i++) {
addable.add(i);
}
System.out.println(addable);
}
}
// Handler(9)
// Query([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
// OldHandler(9)
当然你也可以通过继承来实现这一点,但那样会造成类继承关系的复杂化,所以设计模式中更推荐用适配器模式解决此类问题。
虽然适配器模式的确可以解决Java本身不支持“鸭子类型”的问题,但是你可以很明显地发现,这种解决方案需要付出额外努力,不仅要编写大量额外代码,还需要能正确理解和应用设计模式。
反射
除此以外,你还可以使用反射机制来解决:
package ch14.duck5;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import util.Fmt;
class Handler<T> {
private T content;
public void add(T item) {
content = item;
}
public void clear() {
content = null;
}
@Override
public String toString() {
return Fmt.sprintf("Handler(%s)", content);
}
}
class Query<T> {
private List<T> list = new ArrayList<>();
public void add(T item) {
list.add(item);
}
public void clear() {
list.clear();
}
@Override
public String toString() {
return Fmt.sprintf("Query(%s)", list);
}
}
public class Main {
public static void main(String[] args) {
test(new Handler<Integer>());
test(new Query<Integer>());
}
private static void test(Object obj) {
try {
Method clearMethod = obj.getClass().getDeclaredMethod("clear");
clearMethod.invoke(obj);
} catch (NoSuchMethodException e) {
;
} catch (Exception e) {
throw new RuntimeException(e);
}
try {
Method addMethod = obj.getClass().getDeclaredMethod("add", Object.class);
for (int i = 0; i < 10; i++) {
addMethod.invoke(obj, i);
}
} catch (NoSuchMethodException e) {
;
} catch (Exception e) {
throw new RuntimeException(e);
}
System.out.println(obj);
}
}
// Handler(9)
// Query([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
上边示例中,Query
和Handler
都没有实现某个相同的接口,而是在test
方法中通过反射的方式检测传入的参数是否具备某个方法,如果具备,再用反射的方式进行调用。
这样做的好处在于具备和“鸭子类型”同等的灵活性,虽然代码更复杂一些,但也有额外的好处:即使某个对象只有add
方法,缺少clear
方法,依然可以完成调用。
缺点在于:
- 反射机制是发生在运行时的,会降低程序的性能。
- 基于同样的原因,无法进行静态类型检查,所有相应的错误都只能在运行时以异常的形式报告。
总结
《Java编程思想》中泛型这个部分是最为庞杂和难懂的,这都是源于历史原因,这也说明了为什么Go和Python的学习成本远低于Java,但这并不意味着前者比后者“低级”,相反的是,越是深入学习和比较这几门语言,就越是认同Go和Python的理念——应当在设计语言时就考虑到降低语言的学习门槛。而在计算机和数学界,简洁和优美往往是永恒的追求。
但不管怎么说,有泛型总好过没有泛型,而如果你在编写Java中需要用到泛型,就不得不了解泛型的用法以及相应的东西,尽管这些都是Java开发团队的锅。
最后是原书作者关于为什么这部分内容会塞入过多看似和泛型无关的东西的解释——他认为泛型不应该仅仅局限于对容器的改进,而是一种可以让代码更为泛化的技术(这也是为什么会有“潜在类型”那部分内容)。
最后的最后,谢谢阅读。