尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java泛型深度解析:从类型擦除到通配符,彻底搞懂尖括号

Java泛型深度解析:从类型擦除到通配符,彻底搞懂尖括号 写Java代码写久了很多朋友都会有这样一种感觉集合框架、匿名内部类、Lambda、Stream用得飞起但一旦碰上泛型哪怕只是写个T T这样的方法签名脑子里也要卡顿几秒。我早年就是这样查过无数次官方文档和博客每次看完都觉得“懂了”真到自己在框架里定义一个带泛型的方法时又开始犯迷糊。后来把泛型的原理、类型擦除、通配符、继承关系连起来过了一遍才算彻底通透了。这篇文章我想用一套完整的思路把这些东西串起来讲从泛型解决了什么问题开始一直讲到泛型类、泛型方法、T T与T的差异、public E ListE get()这类签名怎么读再到类型擦除的本质、通配符的用法、泛型之间的继承关系。适合那些已经会写 Java、但对泛型始终缺一层“底层认知”的开发者也适合准备面试、想一次性把泛型知识补全的朋友。跟着我的节奏走完你以后再看到满屏的尖括号就不会慌了。1. 泛型核心它到底解决了开发中的什么问题1.1 从没有泛型的年代说起在 Java 5 之前集合类是没有泛型概念的。那时候往 List 里塞对象全靠强制类型转换代码里遍布着(String) list.get(0)这种写法。问题在于这个强转是编译器帮不上忙的你有没有转对只有运行到了那一行才知道。举一个我当年踩过的例子List list new ArrayList(); list.add(hello); list.add(123); String str (String) list.get(1);这段代码编译完全不会报错运行时却会直接抛ClassCastException。根本原因是集合里既有字符串又有整数取出的123会被硬转成String不炸才怪。要是项目里集合被传来传去谁往里 add 了什么根本不可控排查这种错误经常要花上大半天。泛型出现以后限制被放到了编译期ListString list new ArrayList(); list.add(hello); // list.add(123); // 编译直接报错 String str list.get(0); // 不用再强转加了泛型之后编译器能在代码编译阶段就拦截掉类型不匹配的操作这就是泛型最核心的价值把运行期的类型错误提前到编译期暴露同时帮我们省掉一大堆强转代码。1.2 泛型带来的是一种“约定”能力泛型其实是在类和方法的层面定义一种“可变的类型约定”让使用者在使用时再确定具体类型。它解决的不是某一个具体类型的存取问题而是让一套代码能服务于所有类型同时还保留类型安全性。打个比方如果写代码像是做模具泛型就是一套可换型号的模具模具本身规定了“外壳样子”但里面到底装的是螺丝还是钉子由使用者按下开关的那一刻来决定。代码层面ListString里的String就是按下开关那一刻决定的“具体型号”而ListE里的E是模具槽位的占位符。占位符在代码里一般命名为 TType、EElement、KKey、VValue这些只是惯例没有强制你也可以写BoxA、BoxB。但从业者的习惯是元素用 E类型的通用占位用 T键值对里的键用 K、值用 V。这样别人一读你的代码语义就清楚了。2. 泛型类和泛型接口先把地基打好2.1 泛型类的基本定义泛型类是在类声明时用尖括号声明了一个或多个类型参数类内部的所有方法、字段、构造器都可以引用这些参数。下面是一个很经典的 Box 例子public class BoxT { private T data; public Box(T data) { this.data data; } public T getData() { return data; } public void setData(T data) { this.data data; } }使用的时候可以在创建对象时指定具体类型BoxString stringBox new Box(hello); BoxInteger intBox new Box(100);JDK 7 以后支持菱形语法new Box(...)右边的尖括号里可以省略类型参数编译器会根据左边的声明自动推断省一点是我们日常写码的习惯。2.2 泛型接口以及实现类的两种情况泛型接口和泛型类的规则几乎一致最常见的就是ListE、MapK, V这种 JDK 自带的接口。自己在业务代码里定义泛型接口也很常见比如一个最简单的仓储接口public interface RepositoryT, ID { T findById(ID id); void save(T entity); void deleteById(ID id); }实现泛型接口时有两条路可选。一条路是在实现类里明确具体的类型参数这样实现类本身就不再是泛型类public class UserRepository implements RepositoryUser, Long { Override public User findById(Long id) { return new User(id); } Override public void save(User entity) { // 保存逻辑 } Override public void deleteById(Long id) { // 删除逻辑 } }另一条路是实现类继续保持泛型参数不指定那么实现类也需要声明同样或更多的类型参数public class BaseRepositoryT, ID implements RepositoryT, ID { Override public T findById(ID id) { return null; } Override public void save(T entity) { // 通用保存逻辑 } Override public void deleteById(ID id) { // 通用删除逻辑 } }从代码量上看第一种写法更具体适合业务明确的场景第二种写法通常出现在框架封装、基础组件里给上层业务一个可扩展的底座。2.3 泛型类继承时最容易搞错的一个点泛型类的继承有个细节非常容易踩坑子类继承父类时如果父类的泛型参数已经被确定那么子类的继承关系是明确且具体的如果父类泛型参数仍然写的是类型变量子类必须把这个类型变量也写上。我见过有人这么写然后编译报错// 编译报错Parent 是泛型类需要指定类型参数 // public class Child extends Parent { // } // 正确写法一指定具体类型 public class StringChild extends ParentString {} // 正确写法二子类继续用泛型 public class GenericChildT extends ParentT {}这跟普通类的继承不大一样普通继承不存在“类型参数延续”的问题泛型类的继承则多了一层“类型参数的传递”初学阶段多写一遍就会形成肌肉记忆。泛型接口实现、泛型类继承本质上是把**“类型参数”沿着类层级一路传递下去**直到有人明确指定它是什么。把这句话想清楚上面的规则就不用背了。3. 泛型方法详解分清T T与T的关键差异3.1 写得不对的老代码让我们从一次重构说起泛型方法是最容易让人头晕的地方。用一个小场景来引入我有个老工具类里面所有方法都要把一个对象转换成 JSON 字符串然后又要把 JSON 字符串转回对象。最早没有泛型时反序列化方法是这样写的public static Object fromJson(String json, Class clazz) { // 反射创建对象并赋值 return ... }调用处拿到的是 Object得自己强转还容易踩 unchecked 警告。后来我改成泛型方法签名立刻舒服了很多public static T T fromJson(String json, ClassT clazz) { // 反射创建 clazz 的实例并赋值 return clazz.cast(...); } // 调用方不用强转 User user JsonUtil.fromJson(jsonStr, User.class);这里第一个T是类型参数的声明第二个T是返回值类型组合起来T T的意思是我声明了一个类型参数 T方法的返回值就是 T。这是泛型方法最完整、最具识别度的形态。3.2 泛型方法的长相从签名到语义泛型方法的完整签名规则是修饰符后面、返回类型前面必须有一个单独的尖括号列表用来声明方法自己的类型参数。这个位置就是泛型方法和其他方法在外形上最本质的区别。// 普通方法返回类型 T 是类级别定义的 public T getData() { ... } // 泛型方法T 声明了方法自己的类型参数 public T T getValue(T value) { ... }上面两段代码第一眼看过去都带 T但含义完全不同。第一段的 T 是类声明时就定义好的比如BoxT里的 T类外面的人无法改变它第二段的T是方法自己“现场”声明的在使用时才确定和类的类型参数互不干扰。public E ListE get()这种签名的读法也是这个套路public E ListE get() { return new ArrayList(); }它的意思是方法声明了一个类型参数 E返回值的类型是ListE也就是说它返回的是一个“元素类型为 E”的列表。这个 E 由调用方在调用时指定ListString strings creator.get(); // 编译器推断 E 为 String ListInteger integers creator.get(); // 编译器推断 E 为 Integer如果把这个方法单独定义成一个函数式接口它还能直接配合方法引用来使用这在写框架代码、工具类时非常常见。3.3T T与返回类型直接写 T到底差在哪里我总结了一张表把T T和T的差异列清楚你看完基本不用再去搜别的资料对比项类中的普通方法T getData()泛型方法T T getData(T t)T 的来源由类声明class BoxT定义由方法自己声明T定义类型何时确定创建对象时就确定每次调用方法时确定静态方法能否使用不能静态方法拿不到类级别 T可以静态泛型方法不受类泛型影响典型场景存取对象的成员变量工具类、类型转换、参数透传最大的坑点是静态方法不能使用类上的类型参数 T。初学者总想在一个泛型类里写静态方法直接引用 T编译直接报错non-static type variable T cannot be referenced from a static context。原因很直接静态方法属于类本身不依赖具体对象而 T 是对象级别确定的类型参数静态上下文里根本没有它的实例。解决办法就是把静态方法也声明成泛型方法自己声明一套类型参数。这也是为什么很多工具类里的静态方法都是泛型方法不是设计者喜欢花哨是真的只能用这种方式。3.4 泛型方法在框架代码里的经典应用泛型方法最值得看的是类型安全的类型转换工具。JDK 里Collections.emptyList()、Arrays.asList()都是静态泛型方法。用一个例子演示它的推导机制public static T ListT asList(T... elements) { ListT list new ArrayList(); for (T element : elements) { list.add(element); } return list; } // 调用 ListString list asList(a, b, c); ListInteger list2 asList(1, 2, 3);编译器会根据实参自动推断 T 的类型不需要显式写出尖括号。在某些复杂场景推断不出来的情况下也可以显式指定类型参数// 显式指定 T 为 String ListString list ClassName.StringasList();泛型方法还能在返回类型上做多重限制。比如我要写一个方法返回两个参数中较大的那个同时要求参数必须是 Comparable 的实现类型public static T extends Comparable? super T T max(T a, T b) { return a.compareTo(b) 0 ? a : b; }这就是泛型方法边界通配符的组合拳。别看到这种签名就发怵拆开看T extends Comparable? super T声明 T 是 Comparable 的子类型而且 Comparable 的泛型参数是 T 的父类型。常见于各种需要比较、排序的工具方法理解了这一层的组合方式泛型方法这个板块基本就通关了。4. 类型擦除泛型的底层原理与限制4.1 运行时真的知道类型吗学泛型绕不开一句话Java 的泛型是编译期的概念运行时会进行类型擦除。很多初学者以为ListString和ListInteger在运行时是两种不同的类型实际上它们的 Class 对象是同一个ListString stringList new ArrayList(); ListInteger integerList new ArrayList(); System.out.println(stringList.getClass() integerList.getClass()); // true两个列表的运行时类型都是ArrayList.class因为泛型参数 String 和 Integer 在编译后被擦除了。编译后的字节码里ListString和ListInteger对应的都是原始类型List元素的存取处会自动插入强转和检查逻辑。无界类型参数 T 会被擦除成它的上界没有指定上界时默认擦除成 Object如果指定了上界比如T extends NumberT 就会被擦除成 Number。这也是为什么泛型无法用于new T()这样的代码编译器在擦除后不知道 T 的运行时类型反射只能靠传入 Class 参数来弥补public static T T create(ClassT clazz) throws Exception { return clazz.getDeclaredConstructor().newInstance(); }4.2 桥方法泛型继承会产生的隐晦问题类型擦除还会带来一个很隐蔽的“桥方法”现象。当一个子类重写泛型父类的方法、并把类型参数固定为具体类型时编译器为了保持多态会额外合成一个桥方法。class ParentT { public T get() { return null; } } class StringParent extends ParentString { Override public String get() { return str; } }由于擦除父类的get()方法在字节码里返回的是 Object而子类的get()返回 String。在 JVM 看来方法签名不同多态会失效。编译器此时的解决方案是在子类里悄悄生成一个返回 Object 的桥方法Object get()它转发给返回 String 的那个方法从而保证父类调用的多态性。你用javap -c StringParent就能在字节码里看到这个桥方法。它对我们日常开发没有太多直接影响但在面试中属于典型的加分点能说明你对泛型底层的理解确实到位。4.3 类型擦除带来的几条硬约束理解擦除之后泛型的“做不到”清单就很好背了不能实例化类型参数new T()不行因为擦除后 T 的位置没有具体类型。不能创建泛型数组new T[10]不行new ListString[10]也不行。擦除后 T 和 List 都只是对象引用数组要保证运行时的协变安全而泛型在运行时根本没有类型信息两者冲突。不能用基本类型做类型参数Listint不行只能ListInteger因为类型擦除后 JVM 统一用引用对象基本类型没有对应的 Object 层次。不能通过 instanceof 判断泛型类型list instanceof ListString是编译错误。运行时 List 就是 List没有 String 信息可供判断。静态上下文不能引用类类型参数上一节已经说过静态属性和静态方法都不能用类级别的 T。这些限制不是设计者偷懒而是类型擦除这种方案必然带来的代价。理解这个因果链条面对泛型“哪里不灵活”的问题时就能自己推出来。5. 通配符的实战运用extends、super 与无界类型5.1 无界通配符?的读写规则通配符?表示“某种未知类型”。List?就是任意元素类型的列表但这里的“任意”是未知而不是自由。这个区别很关键你可以把ListString赋给List?但通过List?这个引用不能随意往里加元素因为编译器不知道列表的泛型参数到底是什么。List? list new ArrayListString(); list.add(hello); // 编译报错 list.add(null); // 唯一能加的是 null Object obj list.get(0); // 读出来只能是 Object为什么 null 能加因为 null 是所有引用类型的子类型无论?到底是什么类型null 都不会冲突。而读取的时候编译器只知道元素是 Object 的子类所以读出来赋给 Object 完全没问题。无界通配符主要用在只关心“容器的大小”“判断是否为空”这类不需要操作具体元素的场景或者用来表达“接受任意类型集合”的宽松入参。比如printSize(List? list)你传ListString或ListInteger都行方法体不需要知道元素具体类型只需要 list.size()。5.2 上界通配符? extends T与下界通配符? super T上界通配符把未知类型限制在“某个类型及其子类型”范围内public static double sum(List? extends Number numbers) { double total 0; for (Number n : numbers) { total n.doubleValue(); } return total; } sum(new ArrayListInteger()); sum(new ArrayListDouble());因为传入的元素一定是 Number 的某个子类型所以读出来统一转成 Number 处理是安全的。但也正因为“具体是哪个子类型未知”编译器不允许往这个 List 里添加非 null 元素否则可能塞进一个与真实类型不一致的对象。下界通配符则把未知类型限制在“某个类型及其父类型”范围内public static void addNumbers(List? super Integer list) { list.add(1); list.add(2); } addNumbers(new ArrayListNumber()); addNumbers(new ArrayListObject());这里可以安全地 add Integer因为无论? super Integer具体是 Integer 还是 Integer 的父类Integer 都是它的子类型。但读取时你能确定的最具体类型只有 Object所以读出来的元素一般直接当 Object 处理。5.3 PECS 原则Producer Extends, Consumer Super通配符用得多了自然衍生出一条避坑原则英文缩写叫 PECSProducer Extends, Consumer Super翻译过来就是生产者用 extends消费者用 super。如果方法只是从集合里读取数据提供给外部那这个集合是生产者应该用? extends T如果方法只是往集合里塞数据、把外部数据收集起来那这个集合是消费者应该用? super T。既读又写的场景比较少见通常直接用具体类型参数ListT就行。举一个 JDK 里的标准例子Collections.copy(List? super T dest, List? extends T src)dest 需要往里写所以声明为? super Tsrc 只需要提供数据所以声明为? extends T。你把两个位置换过来写编译器立刻就不让你编译。做工具方法设计时照着这个模板套就能省去大量试错。通配符和泛型方法还能互相替代。很多场景两种都写得出但选择的标准是如果类型参数只在方法签名里出现一次可以考虑用通配符代码更短如果类型参数在多个位置出现、且需要保持方法内部的类型一致就必须用泛型方法。6. 泛型之间的继承关系与常见坑位6.1 泛型类型的不变性泛型类型之间有一个重要特性叫不变性ListString不是ListObject的子类型BoxInteger也不是BoxNumber的子类型。这和数组完全不一样数组是协变的String[]可以赋给Object[]。String[] strArray new String[2]; Object[] objArray strArray; // 编译可以语法上允许 ListString strList new ArrayList(); ListObject objList strList; // 编译报错数组允许协变是因为数组在运行时保留元素类型插入不匹配的元素会抛ArrayStoreException算是 JVM 层面的兜底。泛型因为类型擦除运行时没有检测能力如果允许协变插入错误类型就会产生极难排查的隐患。所以 Java 设计泛型时选择了不变体——宁可编译期直接拒绝也不把炸弹留到运行期。6.2 泛型类型的继承链怎么走虽然BoxInteger和BoxNumber没有继承关系但盒子之间的继承链照样可以存在。假设class ParentT {} class Child extends ParentString {}那么Child确实继承了ParentString这是建立在“具体类之间的继承”上的和泛型类型参数的不变性并不冲突。我们需要分清楚两个维度一个是类的继承关系一个是泛型参数的父子关系。Child是ParentString的子类型但BoxChild不是BoxParent的子类型。这种区别在日常设计里影响很大。比如方法入参是BoxParent你无法直接传入BoxChild只能改成Box? extends Parent来接收。泛型限定符在做参数传递时扮演的角色就是这么重要。6.3 泛型继承应用从一个泛型父类派生多个业务类实践中最常见的场景是在项目里定义一个统一的泛型父类比如BaseResultT然后多个业务接口返回不同的具体类型public class BaseResultT { private int code; private String message; private T data; // getter/setter... } public class UserResult extends BaseResultUser {} public class OrderResult extends BaseResultOrder {}这样返回结构统一接口一遍就用各业务类只关心自己的具体数据类型。如果某个子类还想继续维持泛型也可以写成public class PageResultT extends BaseResultListT {}继承之后PageResultUser本质上就是一个BaseResultListUser子类型。这种组合式的泛型继承在实际项目中非常实用写分页接口、列表接口时能省掉大量重复 DTO。6.4 泛型继承里常见的编译报错速查我整理了一份自己经常用到的排查表遇到编译报错先对照看一眼通常能立刻定位方向错误现象根本原因解决方案Type argument cannot be of primitive type用基本类型做类型参数改成包装类型如Integernon-static type variable T cannot be referenced from a static context静态上下文引用了类级 T把静态方法改成泛型方法自己声明Tincompatible types: ListString cannot be converted to ListObject试图把具体泛型列表赋给父类列表改用? extends Object或泛型方法unchecked cast warning擦除后强转无法编译期检查尽量通过泛型方法或反射 API 避免裸强转? extends T的集合无法 add 非 null上界通配符不允许写改成? super T或者ListT这些报错背后都是同一个根源编译器坚守“运行时无类型信息所以编译期必须拦截一切可能引发类型混乱的操作”。想明白这一点报错信息就不再是乱码而是安全提示。6.5 实战泛型 通配符的落地场景串讲把前面所有知识点组装起来看一个真实工具方法的演化过程。需求写一个方法把 List 里的元素批量放入一个 Map按某个维度分组。传统写法很容易出现 unchecked 警告而完全用具体类型写死又没法通用。用泛型方法加通配符可以这么写public static K, V MapK, ListV groupBy( List? extends V values, Function? super V, ? extends K keyExtractor) { MapK, ListV map new HashMap(); for (V value : values) { K key keyExtractor.apply(value); map.computeIfAbsent(key, k - new ArrayList()).add(value); } return map; }这里的细节值得逐层拆解List? extends V说明入参只要是 V 子类型的集合就可以接收体现“生产者 extends”。Function? super V, ? extends K说明这个函数可以接收 V 的任意父类型作为入参严谨一点说是能处理 V返回的类型是 K 的子类型都能作为 map 的键。返回值MapK, ListV保持类型参数精确一致不让调用方做任何强转。调用时泛型推断会自动工作ListString words Arrays.asList(apple, banana, avocado); MapCharacter, ListString grouped groupBy(words, s - s.charAt(0));这种工具方法刚写出来时总觉得签名太复杂但用顺手之后就再也回不去了类型安全、调用简洁、职责清晰泛型的所有高级技巧都落到了实处。7. 完整示例把今天的内容用一个小案例落地7.1 需求与整体设计光讲概念不够我把今天涉及的内容揉进一个小项目里。假设要写一个通用的缓存工具类支持任意类型的缓存存取并且带过期时间。这个需求用泛型类来做极其合适。整体设计如下public class CacheK, V { private final MapK, CacheEntryV store new HashMap(); private static class CacheEntryV { private final V value; private final long expireAt; CacheEntry(V value, long expireAt) { this.value value; this.expireAt expireAt; } } public void put(K key, V value, long ttlMillis) { store.put(key, new CacheEntry(value, System.currentTimeMillis() ttlMillis)); } public V get(K key) { CacheEntryV entry store.get(key); if (entry null) { return null; } if (System.currentTimeMillis() entry.expireAt) { store.remove(key); return null; } return entry.value; } }这里CacheEntryV的内部类继续保持了泛型参数实际上就是泛型继承、泛型嵌套的体现。调用端拿到的是一个完全类型安全的缓存CacheString, Integer cache new Cache(); cache.put(counter, 1, 1000L); Integer count cache.get(counter);全流程没有一次强转。7.2 给工具加一个泛型静态方法这个缓存类如果想提供一个静态方法用于把非空值包装成 Optional 类似的容器返回就必须用静态泛型方法而不是类级泛型public static T OptionalT tryGet(T value, boolean valid) { return valid value ! null ? Optional.of(value) : Optional.empty(); }因为静态方法不能引用类级别的 K、V所以这里重新声明了一个T和类上的泛型参数完全不冲突也不依赖 Cache 实例。把它和泛型类放在一起正好能直观对比“类泛型参数”和“方法泛型参数”的使用边界。7.3 通配符在这个案例里的自然出现需求扩展需要一个方法接受任意键值类型都继承自某个基础类型的缓存集合做一些统一的统计操作。此时通配符就该登场了public static int countEntries(Cache?, ? extends Number cache) { int count 0; for (K ignored : cache.store.keySet()) { // 这里只是演示实际要加 getter 访问 store count; } return count; }由于上界通配符? extends Number这里可以把所有 value 是 Number 子类型的缓存都传进来统计而不会被 Cache 的不变性卡住。如果你在这里试图往里 put 一个具体 Double编译器会直接拒绝这就是通配符的读限制在真实业务中的体现。当然Cache里的store是 private真实项目中需要补充合适的可见性方法。这个小案例完全是为了一镜到底地演示泛型类、泛型方法、泛型通配符如何自然协作。8. 常见问题排查与经验小结8.1 泛型类型引发的“乱码”警告要分清楚等级泛型相关的编译警告大多是 unchecked 警告。很多初学朋友一看到 unchecked 就慌张其实要分场景如果是老代码和框架交互导致的且你能保证运行时类型的正确性加SuppressWarnings(unchecked)并写好注释是合理的选择如果是在自己新写的核心接口上出现 unchecked通常说明泛型设计不够严谨不建议直接压制警告而是回头检查类型参数的位置和通配符选择。我自己的习惯是警告可以留但必须知道它为什么存在。压制一个自己都不理解的警告等于把问题藏起来留给未来的接手人。8.2 泛型方法调用时的类型推断偶尔会“翻车”类型推断在大多数场景下是自动完成的但在链式调用、类型嵌套较深时偶尔会推断成 Object 或 Object 的父类型导致后续强转或方法调用失败。解决办法有两种一种是显式类型见证也就是在方法名前面加String另一种是先把中间结果赋值给一个显示类型变量再参与后续运算。从我实际经验看显式类型见证更常用也更容易读。8.3 泛型并不影响方法重载的区分有人以为void print(ListString list)和void print(ListInteger list)能构成重载实际上编译会报错因为它们都擦除成了void print(List list)方法签名完全一样。泛型类型参数不参与方法签名这是面试里一道高频易错题也是实际开发中设计工具方法时容易忽略的边角。如果确实需要对不同元素类型做不同处理可以通过List?配合 instanceof 对元素类型做运行时判断或者把方法名区分开来。8.4 泛型相关的编码规范建议泛型参数命名保持惯例T、E、K、V、U不新造符号。公有的泛型 API 尽量不暴露裸类型例如List应该写成List?或ListString。优先使用泛型方法而不是 Object 传参这样能减少调用方的强转。对类型参数做上限约束时能用? extends就用? extends能给调用方留出更多的入参空间。尽量不要在泛型类里同时用 class 级别 T 和方法级别T混淆读代码的人会疯掉的。这套内容写下来其实就一个核心心法泛型是编译期的类型约束系统所有的高级写法都是在平衡“类型安全”和“通用性”这两端。我在实际项目里踩过最多的坑不是泛型语法不会写而是忘了擦除的存在以为运行时还能拿到类型信息。所以当你遇到泛型相关的问题想不通时先问一句编译完之后这段代码在运行时到底还剩什么答案往往一下就浮出来了。泛型这个技术点前期学得痛苦是正常的写几个工具方法、封装几层 Base 类坚持用上两三个月它就会像循环和条件判断一样自然。
返回列表