ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java抽象类深度解析:从语法到设计,一篇搞定抽象类与接口

Java抽象类深度解析:从语法到设计,一篇搞定抽象类与接口 作为一个写了快十年Java的老程序员我面试别人的时候经常问抽象类自己也踩过不少抽象类设计上的坑。很多初学者对抽象类的理解停留在有abstract关键字的就是抽象类这种表面层次一旦问到为什么要有抽象类抽象类和接口怎么选就答不上来了。这篇就把抽象类从头到尾捋一遍结合我实际项目里的经验把语法细节、设计思想、面试考点一次说清楚。1. 抽象类到底解决了什么问题1.1 从是什么说起抽象类与抽象方法的本质抽象类就是用abstract关键字修饰的类它最核心的特点是不能被实例化。也就是说你不能直接new一个抽象类的对象出来。那它存在的意义是什么一句话概括抽象类是用来被继承的它定义了一类事物的公共特征和行为规范但自身不完整需要子类去补充实现。先看一个最简单的例子public abstract class Animal { // 普通方法所有动物都会吃东西 public void eat() { System.out.println(动物在吃东西); } // 抽象方法不同动物的叫声不同父类无法确定交给子类实现 public abstract void makeSound(); } public class Dog extends Animal { Override public void makeSound() { System.out.println(汪汪汪); } }这段代码里eat()是一个普通方法子类可以继承直接使用也可以重写。makeSound()是一个抽象方法它只有方法签名没有方法体子类必须实现这个方法。这里有个关键点抽象方法就是用来强制子类实现的。如果Dog类没有实现makeSound()那Dog类要么也声明为抽象类要么编译直接报错。1.2 为什么Java要设计抽象类你用生活化的方式理解这件事就简单了。想象你在开发一个动物园管理系统里面有狗、猫、鸟等各种动物。如果你定义一个Animal类里面有个叫叫的方法你打算怎么写方法体狗叫是汪汪猫叫是喵喵鸟叫是叽叽你根本没法在Animal类里写出一个通用的叫来实现所有动物的叫声。这就是抽象类的价值所在把是什么和怎么做分离。Animal类定义了动物都应该有叫这个行为的规范但具体怎么叫由具体子类决定。父类管规范子类管实现。我一般在项目里用到抽象类主要是下面几个场景多个子类有公共的字段和方法但某些行为各有不同把公共部分上提到父类把差异部分做成抽象方法需要定义一套业务流程的骨架但流程中某个环节的具体实现由子类决定这就是设计模式里经典的模板方法模式作为框架的扩展点比如你写一个框架或者工具库允许使用者继承你的抽象类去定制某些行为1.3 一个关键疑问抽象类里可以没有抽象方法吗很多初学者会问抽象类必须包含抽象方法吗答案是不一定。Java允许你声明一个抽象类但里面一个抽象方法都没有。比如public abstract class BaseDao { protected Connection conn; public void setConnection(Connection conn) { this.conn conn; } }这个类没有抽象方法但它仍然是抽象类不能被实例化。这种写法在实际项目中很常见通常是你希望别人继承这个类去复用里面的方法但又不想让这个类被直接实例化。有时候就是单纯想当一个不能new的工具基类用。反过来说只要类里有一个抽象方法这个类就必须声明为抽象类。这是硬性规定不写abstract直接编译不通过。2. 抽象类与普通类的区别一张表看明白面试的时候面试官问抽象类和普通类的区别你如果只回答抽象类不能被实例化那基本就凉了。面试官想听到的是系统性的对比。我把区别整理成一张表你照着这个思路回答基本不会失分对比维度抽象类普通类实例化不能被new可以new抽象方法可以有也可以没有不能有构造器可以有但只能被子类调用可以有继承只能单继承子类必须实现所有抽象方法或子类也是抽象类可以被继承也可以不被继承实现接口可以实现多个接口可以实现多个接口final修饰不能被final修饰可以被final修饰访问修饰符可以是public或默认包可见同左成员变量可以是各种访问权限同左静态成员可以有可以有设计目的定义规范和骨架强制子类实现直接描述对象提供完整功能这里面有几个点要重点说第一抽象类不能被final修饰。final的含义是不可被继承而抽象类的存在意义就是让子类继承这俩在语义上直接冲突。你可以试试在抽象类前加final编译器直接报错Illegal combination of modifiers: abstract and final。第二抽象类可以有构造器。这是很多新手不理解的地方既然抽象类不能new那要构造器干嘛答案是构造器是给子类用的。子类在实例化的时候会先调用父类的构造器。所以抽象类里的构造器是用来初始化抽象类自己的成员变量的。public abstract class Shape { protected String color; // 抽象类的构造器子类实例化时会先调用这个 public Shape(String color) { this.color color; System.out.println(Shape构造器执行); } public abstract double area(); } public class Circle extends Shape { private double radius; public Circle(String color, double radius) { super(color); // 必须调用父类构造器 this.radius radius; } Override public double area() { return Math.PI * radius * radius; } }运行的时候你会看到new Circle(red, 2.0)会先打印Shape构造器执行再执行Circle自己的构造逻辑。这就是继承体系里构造器的调用链。第三抽象方法不能用private、static、final修饰。这个也容易理解private方法子类看不见没法实现static方法属于类本身跟继承无关final方法不允许重写跟抽象方法必须被实现的语义矛盾。3. 深入抽象类的核心语法与细节3.1 abstract关键字的使用规则搞清楚了抽象类是什么再来看abstract关键字本身它有非常严格的使用限制我来一条条说清楚abstract可以修饰什么只能修饰类和方法。abstract修饰方法时有什么要求方法不能有方法体大括号都不能写直接以分号结尾方法不能是private的因为子类要实现它私有了子类就访问不到方法不能是static的因为static方法不能被重写方法不能是final的因为final方法不能被重写方法不能是native的这个冷门一点native方法和abstract方法冲突因为native方法有实现只是用其他语言写的这里我觉得有点意思的是抽象方法和重写的关系。子类重写抽象方法时访问权限不能比父类更严格比如父类是protected的子类可以用public去实现但不能用private。public abstract class Parent { protected abstract void doWork(); } public class Child extends Parent { // 正确把protected扩大为public Override public void doWork() { System.out.println(Child working); } // 错误不能把protected缩小为private // private void doWork() { } }3.2 抽象类可以拥有哪些成员别以为抽象类只能有抽象方法它能有的成员其实非常丰富。我列一下普通方法可以有完整实现的方法子类可以直接用抽象方法必须有也可以没有成员变量各种访问权限的实例变量静态方法可以有并且Java 8之后接口也能有default和static方法了但抽象类的静态方法直接调用即可静态代码块和实例代码块可以有内部类可以有构造器必须有如果没有显式声明编译器会给你加一个无参构造器有一个细节你得注意抽象类的静态方法能不能被继承调用可以子类可以直接通过子类名.静态方法()调用父类的静态方法。但不能把静态方法声明为abstract原因上面说了abstract方法必须被overridestatic方法不能被override。3.3 子类继承抽象类的两种处理方式当一个没有实现全部抽象方法的子类碰到抽象父类时有两个选择选择一实现全部抽象方法变成普通类。这样这个类就可以被实例化了。选择二不实现或只实现部分抽象方法自己也声明为抽象类。这样子类依然不能被实例化它的具体子类要继续实现剩下抽象方法。public abstract class AbstractDbOperator { public abstract void connect(); public abstract void close(); } // 选择二只实现了一个自己还是抽象类 public abstract class AbstractJdbcOperator extends AbstractDbOperator { Override public void connect() { System.out.println(建立JDBC连接); } // close() 没实现所以类必须声明为abstract } // 选择一全部实现可以实例化 public class MysqlOperator extends AbstractJdbcOperator { Override public void close() { System.out.println(关闭MySQL连接); } }在大型项目里这种分层实现的设计很常见。顶层抽象类定义完整规范中间层抽象类处理一部分公共逻辑底层具体类才实现最终细节。这样每层的职责清晰代码复用率高。3.4 模板方法模式抽象类最经典的应用前面提到过模板方法模式这是抽象类在真实项目中最有价值的用法。我第一次在项目里意识到抽象类的威力就是因为用了模板方法模式。模板方法模式的思路是父类定义一套流程的骨架把流程中某些步骤的具体实现推迟到子类。比如我做过一个数据导入的功能从Excel导入和从接口导入的流程是一样的校验数据、处理数据、保存数据、记录日志。但校验规则、处理逻辑、保存方式完全不同。用抽象类设计起来就很优雅public abstract class AbstractDataImporter { // 模板方法定义流程骨架用final防止子类修改流程 public final void importData(DataInput input) { // 第一步校验 validate(input); // 第二步处理 Object processed process(input); // 第三步保存 save(processed); // 第四步打日志 log(input, processed); } // 校验必须由子类实现 protected abstract void validate(DataInput input); // 处理必须由子类实现 protected abstract Object process(DataInput input); // 保存子类可以用默认实现也可以重写 protected void save(Object data) { System.out.println(默认保存逻辑); } // 日志所有子类共用 private void log(DataInput input, Object processed) { System.out.println(导入完成 input - processed); } }子类只需要实现validate和process两个核心步骤其他逻辑父类全部包办了。这个设计把流程控制和具体实现解耦改流程只动父类改业务只动子类维护成本大幅下降。4. 抽象类与接口的区别面试高频问题4.1 语法层面的差异Java 8之后要重新捋这个对比是面试重灾区。Java 8之前抽象类和接口的区别很好说清楚接口只能有抽象方法和常量。但Java 8之后接口增加了default方法和static方法Java 9又增加了private方法很多人的知识还停留在旧版本说出来的答案就是错的。我把现在的情况梳理一下对比维度抽象类接口关键字abstract classinterface继承方式单继承只能extends一个多实现可以implements多个构造器有没有成员变量各种权限都行可以非final只能是public static final常量抽象方法可以有可以有Java 8之前接口里全是抽象方法普通方法可以有Java 8之后有default方法Java 9之后有private方法静态方法可以有Java 8之后有设计思想是什么的关系强调is-a能做什么的契约强调can-do接口里没有构造器不能有实例变量这个跟抽象类有本质区别。接口里你写一个变量public interface MyInterface { int count 10; // 这其实是 public static final int count 10 }它一定是public static final的不管你有没有写这些修饰符。所以接口中的变量本质上就是常量不是用来描述状态的而是用来定义一些固定值的。4.2 什么时候用抽象类什么时候用接口这是面试必考题也是实际开发里纠结最多的问题。我的判断标准很简单就三条第一看关系紧密程度。如果多个类之间是is-a关系比如狗是动物猫是动物它们有大量公共的属性和行为用抽象类。如果只是具备某种能力比如狗会跑、鸟会飞跑和飞是能力用接口。第二看是否需要共享状态。如果父类需要有一些成员变量来保存公共状态子类还要访问这些变量用抽象类。接口不能有实例变量做不到这种共享状态。第三看扩展方向。Java是单继承一个类只能extends一个父类但可以implements多个接口。如果你的一个类已经继承了别的类又想扩展某个能力只能用接口。我实际项目里有个准则能用接口就用接口除非确实需要共享代码和状态。因为接口更灵活class可以implements多个接口但只能extends一个抽象类。而且接口天然适合做系统间的契约比如定义一个支付接口对接方实现你的接口就行两边没有强制关系。4.3 抽象类和接口的配合使用实战在实际项目中抽象类和接口经常是配合使用的不是二选一。接口负责定义能力契约抽象类负责提供默认实现这就是Java类库中比比皆是的模式。比如Java的集合框架就是这样设计的List是一个接口定义了列表有哪些操作AbstractList是一个抽象类实现了List的大部分方法但把get(int index)等关键方法留成abstractArrayList和LinkedList再继承AbstractList核心操作各写各的。这种设计的好处是如果你实现一个新列表你可以选择直接implements List那所有方法都得自己写也可以选择extends AbstractList只需要实现少数几个核心方法其他方法白拿现成的。这就是接口定义标准抽象类提供便利。5. 用实例说话从需求到抽象类的完整设计过程这一节看一个完整案例感受下真实项目中怎么从需求里抽出抽象类。我拿一个报表导出的场景来演示。需求大概是这样系统要支持导出Excel报表和PDF报表。每种报表的导出流程都一样先从数据库查数据再填充到模板最后生成文件。但查数据的方式、模板的格式、文件生成的逻辑完全不同。第一步定义抽象类public abstract class AbstractReportExporter { // 模板方法定义导出流程子类不能修改流程 public File export(ReportQuery query) { // 1. 查询数据 ListMapString, Object data queryData(query); // 2. 填充模板 Object template fillTemplate(data); // 3. 生成文件 File file generateFile(template); // 4. 返回结果 return file; } // 查询数据每个报表的数据库查询逻辑完全不同 protected abstract ListMapString, Object queryData(ReportQuery query); // 填充模板各报表的模板结构不同 protected abstract Object fillTemplate(ListMapString, Object data); // 生成文件Excel和PDF的生成方式天然不同 protected abstract File generateFile(Object template); }第二步实现Excel报表导出public class ExcelReportExporter extends AbstractReportExporter { Override protected ListMapString, Object queryData(ReportQuery query) { // 查数据库返回ListMap return jdbcTemplate.queryForList(query.getSql()); } Override protected Object fillTemplate(ListMapString, Object data) { // 用POI创建Workbook把data填充进去 Workbook workbook new XSSFWorkbook(); Sheet sheet workbook.createSheet(报表); // ...填充数据... return workbook; } Override protected File generateFile(Object template) { Workbook workbook (Workbook) template; try { File file new File(report.xlsx); try (FileOutputStream fos new FileOutputStream(file)) { workbook.write(fos); } return file; } catch (IOException e) { throw new RuntimeException(生成Excel失败, e); } } }第三步实现PDF报表导出逻辑类似只是生成方式换成PDF库。这样设计完以后新增一种报表类型只需要继承AbstractReportExporter实现三个方法就够了其他所有流程完全不用动。我在项目里这样重构过一次原来那种每个报表写一坨重复代码的写法直接消灭了新增报表的开发时间从半天缩短到半小时。这里的细节是queryData、fillTemplate、generateFile每一步都塞了一些打印日志的代码实际项目里是打logger这样出了问题站在父类的export方法一打断点整个流程一目了然定位bug特别方便。6. 面试题与易错点把这些坑提前踩了6.1 八股文高频问答汇总结合我这个面试官视角把最常见的抽象类面试题整理一下你照着准备就行问抽象类能否被实例化答不能。但可以声明抽象类的引用指向子类对象这是多态的一部分。问抽象类是否必须有抽象方法答不必须。可以没有任何抽象方法但只要有抽象方法类就必须是抽象类。问抽象类能定义构造器吗能定义静态方法吗答都能。构造器用于子类实例化时初始化父类成员变量。抽象类的静态方法可以通过子类名或父类名直接调用。问普通方法能不能调用抽象方法答能而且这是模板方法模式的核心。父类普通方法里调用抽象方法实际执行的时候调用的是子类实现。挺多新手看到这种代码会愣一下但只要理解了多态就明白运行时find的永远是真实对象的方法。问抽象方法能不能同时是static、final、private的答都不能。static方法不能被重写final方法不能被重写private方法子类访问不到三者都与抽象方法必须由子类实现的语义冲突。问abstract和synchronized能同时修饰方法吗答能编译上可以但没意义。synchronized作用于方法实现抽象方法没有实现所以组合在一起没有实际价值写了也不报错但属于代码坏味道。问子类实现抽象方法时访问权限有什么要求答不能比父类的抽象方法更严格。比如父类是protected子类可以是public反过来不行。问接口和抽象类的区别是什么答从语法、设计思想、使用场景三个维度作答参考前面第4节的内容。6.2 我在代码评审中见到的真实错误当了这么多年程序员我在code review里见过不少抽象类相关的低级错误和设计问题。说几个典型的错误一抽象类命名不规范。有人把抽象类命名为Animal、Shape这种名词这没问题但有人命名为AnimalImpl这就不对。impl通常表示接口实现类而不是抽象类。业界一般规范是抽象类用Abstract开头比如AbstractAnimal、AbstractShape这样一眼就能看出它的定位。错误二滥用抽象类。有些同学一看到多个类有相同方法就立刻提取抽象类。但其实如果只是复用几个方法没有is-a关系用组合或者工具类更合适。抽象类本质上是建立了一种强关联的继承关系用多了会导致类层次过深后面极难维护。错误三抽象方法太多让子类实现喘不过气。如果一个抽象类里塞了十几个抽象方法子类每个都得实现新增子类的成本就高得吓人。解决办法是控制抽象方法的数量能用默认实现就先给默认实现或者再拆一层中间抽象类分担实现压力。错误四在抽象类的构造器里调用抽象方法。这个是个比较隐蔽的坑。父类构造器执行的时候子类对象还没有完全构建好此时调用抽象方法会触发子类的方法但子类的字段还没初始化很可能拿到null或者默认值直接NPE。public abstract class Base { public Base() { doInit(); // 危险在父类构造器里调抽象方法 } protected abstract void doInit(); } public class Sub extends Base { private String name hello; public Sub() { // 父类构造器先执行此时name还是null } Override protected void doInit() { System.out.println(name.length()); // NPEname还是null } }这个例子很经典我在某个老项目里真实碰到过。当时看到代码里的NPE排查了很久最后定位到是父类构造器调用了抽象方法。结论是尽量避免在抽象类构造器中调用抽象方法如果确实要调用要非常清楚子类字段的初始化时机和风险。6.3 实际代码里抽象类与多态的联动分析抽象类能不能作为方法参数和返回值肯定能而且这正好是多态的一种应用。public abstract class Message { public abstract String getContent(); } public class TextMessage extends Message { private String text; public TextMessage(String text) { this.text text; } Override public String getContent() { return text; } } public class ImageMessage extends Message { private String path; public ImageMessage(String path) { this.path path; } Override public String getContent() { return [图片] path; } } // 方法的参数类型是抽象类 public void sendMessage(Message msg) { System.out.println(发送消息 msg.getContent()); } // 调用时传入任意子类对象 sendMessage(new TextMessage(你好)); sendMessage(new ImageMessage(http://example.com/a.png));这里sendMessage方法根本不关心传入的是具体什么类型只管调用getContent()具体返回什么由子类决定。这就是面向抽象编程整个系统的扩展性就是这样一点一点积累起来的。7. 抽象类设计的经验总结与扩展建议最后分享一些我在代码里摸爬滚打得出来的设计经验不搞那些空话套话全是干活时真正有用的东西。关于抽象类和接口的选择我的判断流程是这样的先看是不是is-a再看要不要共享成员变量最后考虑单继承的限制。如果是is-a关系且需要共享状态选抽象类如果只是能力契约选接口。拿不准的时候优先接口。关于抽象类的粒度我的建议是保持精简。抽象类应该是刚好够用的不要把所有公共方法都塞进去也不要贪图代码复用而硬造父类。我见过那种三层五层的继承结构改一个底层方法上面全受到牵连真正出了问题排查起来非常痛苦。关于抽象方法的数量控制在一个到三个最佳。一个抽象方法基本能撑起模板模式两三个抽象方法还可以接受超过五个我建议你停下来想想是不是抽象类的职责没有拆清楚是不是应该拆成多个接口或者调整继承体系。我在写库的时候还有一个习惯把抽象类设计为abstract class加protected方法而把对外暴露的API做成接口。这样外部只依赖接口内部用抽象类收拢公共逻辑两边独立的扩展性都很好。类库里的Collection框架就是这么干的实践检验过无数遍了。抽象类本质上是一种部分设计的类它把自己的骨架立好把不确定的部分留给子类来填空。掌握它不只是学会一个关键字而是理解Java面向对象设计中的一种核心思路什么时候该抽象抽象到什么程度。写代码经验越丰富越能体会这种设计力量的精妙之处。
返回列表