很多初学者学到面向对象编程这一块最容易卡住的往往不是“类和对象”本身而是从“我知道了什么叫类”到“我能用面向对象去设计一个像样的程序”之间那一段路。这个系列的第3讲正好就是这段路里最关键的一环继承和多态。这篇文章不谈虚的就把这一讲的核心内容掰开来讲清楚包括继承到底解决了什么问题、语法上那些容易被忽略的细节点、以及我实际写代码过程中踩过的坑和总结出来的判断标准。1. 继承到底在解决什么问题1.1 代码重复继承出现之前的灾难要说清楚继承得先回到没有继承时我们是怎么写代码的。想象一个很常见的场景一个系统里既有老师也有学生他们都有姓名和年龄都需要自我介绍。如果只用类和对象你会发现这段“姓名年龄自我介绍”的逻辑要被复制粘贴两遍。public class Teacher { private String name; private int age; public void sayHello() { System.out.println(我叫 name 今年 age 岁); } } public class Student { private String name; private int age; public void sayHello() { System.out.println(我叫 name 今年 age 岁); } }代码一旦出现这种大面积重复灾难就开始了。今天老师说自我介绍时要加上工号你得去改Teacher明天学生要有学号你得去改Student后天两个类都要加个邮箱字段你又要改两处。改一处漏一处是这类代码最常见的故障源。这个问题的本质是两个类共享了一部分“通用特征”但各自又有“特殊特征”。继承要解决的核心问题正是把共性的部分抽出来让特殊的部分各自为政。1.2 继承的基本语义IS-A关系继承在逻辑上表达一种“是一个”的关系。老师是一个“人”学生也是一个“人”那么“人”就是父类基类老师和学生是子类派生类。子类自动获得父类所有的属性和方法然后再去扩展自己独有的东西。于是上面的代码可以重构成这样public class Person { protected String name; protected int age; public void sayHello() { System.out.println(我叫 name 今年 age 岁); } } public class Teacher extends Person { private String staffNo; } public class Student extends Person { private String studentNo; }这个重构的收益是立竿见影的公共逻辑只保留一份后续要加公共字段只需要改父类。而子类积累的是自己的特殊差异代码结构一下子清爽了。很多刚学的人会把继承当成“批量复制代码的偷懒工具”这种理解方向是错的。继承的价值不是让你少打字而是让共性和差异各归其位。1.3 该不该用继承先问是不是但这不意味着看到代码重复就立刻上继承。继承成立的前提是严格的“IS-A”关系而不是“我觉得它们挺像的”。我见过不少把继承用歪的例子最典型的鸟和鸵鸟。鸟会飞但鸵鸟不会飞如果你让鸵鸟继承“会飞的鸟”子类就得去重写或禁用继承来的行为这时候继承关系就变得很别扭。车和引擎。车有引擎但车“是”引擎吗不是。这是HAS-A关系应该用组合把引擎作为车的一个属性而不是让车去继承引擎。所以动手写extends之前先在心里问一句子类真的可以无条件替代父类吗如果答案有犹豫那继承可能不是最好的选择。这个问题我在第6节还会展开讲。2. 继承语法与核心细节2.1 不同语言里的继承声明继承的语法在不同语言里差别很大但底层逻辑是相通的。Java用extendsPython在类名后面加括号C用冒号加访问修饰符。下面分别看一眼样子// Java public class Student extends Person { private String studentNo; }# Python class Student(Person): def __init__(self, name, age, student_no): super().__init__(name, age) self.student_no student_no// C class Student : public Person { private: std::string student_no; };无论语法怎么变都逃不开三件事声明继承关系、调用父类构造方法、访问父类成员。如果你已经熟练掌握了其中一门语言再去看其他语言的继承基本上就是换了个书写形式而已。这里特别提一下单继承和多继承的区别。Java是单继承一个类只能有一个直接父类这样虽然有时候显得死板但避免了多继承带来的复杂性。C和Python允许多继承确实能实现一些灵活的设计但也引入了“菱形继承”这类让人头大的问题。对于初学者我更建议先老老实实把单继承吃透多继承的问题等到真正遇到再回来查资料都来得及。2.2 访问修饰符决定了“继承到了什么”很多人以为继承了父类父类的私有字段也能随便用这是最常见的第一坑。看这个例子public class Person { private String name; } public class Student extends Person { public void printName() { // 编译报错name 在 Person 中是 private 的 System.out.println(name); } }答案是子类确实继承了name这个字段的内存布局但直接访问不了。要访问要么通过公共的getter/setter要么把字段的访问权限改成protected。我用一张表来总结Java里常见的访问级别其他语言大同小异修饰符同类同包子类其他private可以不行不行不行默认可以可以不行不行protected可以可以可以不行public可以可以可以可以实际项目里字段通常都用private然后通过方法暴露操作入口这符合封装原则。protected用于那些你确实想让子类直接访问的成员但不要滥用因为权限太开放会破坏封装后续维护的时候你不知道谁在偷偷改这个字段。2.3 super关键字与构造方法调用链继承之后创建一个子类对象背后其实藏了一条完整的构造链。看这个例子public class Person { public Person() { System.out.println(Person 构造方法执行了); } } public class Student extends Person { public Student() { System.out.println(Student 构造方法执行了); } }执行new Student()时输出的顺序是“Person构造方法”先执行然后才是“Student构造方法”。原因其实不神秘子类对象的内存里天然包含一份父类的数据这部分数据当然得先初始化然后才能初始化子类自己新增的部分。Java编译器会在子类构造方法的第一行隐式调用父类的无参构造方法即使你一个字都没写。这就是为什么如果父类写了有参构造方法却没有显式写出无参构造方法子类会直接编译报错因为编译器想在子类构造第一行调super()却发现父类根本没有这个无参构造方法。这时候你有两个选择public class Person { private String name; public Person(String name) { this.name name; } } // 方案一父类补一个无参构造方法 public Person() { } // 方案二子类显式调用父类的有参构造方法 public Student(String name, String studentNo) { super(name); // 必须放在第一行 this.studentNo studentNo; }这里有个新手常踩的坑super关键字必须写在构造方法的第一行否则编译不通过。因为Java要求父类先完成初始化才能轮到子类。如果你在super()之前写了任何语句哪怕是打印一句话都是违规的。2.4 继承具有传递性继承是可以层层往下的C继承BB继承A那么C同时拥有A和B的成员。这种传递性在设计多层业务模型时会用到比如“动物 - 哺乳动物 - 狗”。但传递性也是一把双刃剑层级太深会让对象的行为变得难以追踪这点后面讲踩坑时会专门说。还有一点必须搞清楚Java中所有类都直接或间接继承Object这个类。也就是说你写一个不继承任何类的类它其实也有Object带来的方法比如toString()、equals()、hashCode()。很多人刚开始不理解为什么自己的类可以调用toString()其实就是因为这层隐式继承关系。3. 方法重写与多态3.1 重写不是重载多态是继承带来的最大收益而多态的核心机制之一就是方法重写。先强调一个基本区别重写(Override)是子类对父类方法的重新实现方法签名完全一致重载(Overload)是同一个类里方法名相同、参数列表不同。// 重写子类和父类的方法签名完全一致 Override public void sayHello() { System.out.println(我是学生我叫 name); } // 重载同一个类中参数列表不同 public void sayHello(String greeting) { System.out.println(greeting name); }重写时有三条硬性规则违反了直接编译报错方法名、参数列表必须和父类完全一致返回类型可以相同也可以是父类返回类型的子类型这在Java 5之后叫协变返回类型访问权限不能比父类更严格比如父类是public子类不能改成private。第3条可能有点反直觉但背后的逻辑很简单凡是父类能调用的地方子类对象必须也能被调用否则继承的“替代性”就被破坏了。另外推荐在重写的方法上加Override注解这不仅是给看代码的人看的更重要的是编译器会帮你检查方法签名是否真的有重写成功——如果你写错了参数列表编译器会立刻告诉你这里根本没在重写任何方法。3.2 动态绑定到底怎么发生的多态在运行时的核心机制叫动态绑定也叫后期绑定。它的含义是程序运行时JVM根据对象的真实类型来决定调用哪个方法而不是看引用变量的类型。Person p new Student(); p.sayHello(); // 执行的是Student重写后的版本编译期编译器看到引用类型是Person会检查Person里有没有sayHello这个方法没有就报错。运行期JVM看到堆里实际上创建的是一个Student对象就调用Student重写的那个sayHello。这就是“编译看左边运行看右边”的经验法则。理解这个过程对排查问题特别重要。比如你用父类引用存了一个子类对象调用重写方法时跑的是子类版本这是很多人指望的“正确行为”。但如果你不小心把方法重载了而不是重写比如父类是sayHello()子类写了个sayHello(int)却在里面改了逻辑那运行时根本不会走子类这个新方法因为这不是重写纯属子类自己的新增方法和父类的方法互相看不见。3.3 向上转型和向下转型向上转型就是把子类对象赋值给父类引用这是自动完成的也是多态的基础。向下转型则相反需要强制类型转换。Person p new Student(); // 向上转型自动 if (p instanceof Student) { Student s (Student) p; // 向下转型需要判断类型 }向下转型有风险如果对象的真实类型和要转的类型没有继承关系运行时会抛ClassCastException。所以向下转型之前最好用instanceof做一次类型检查。实际开发中频繁的向下转型往往意味着你的设计有些问题——如果老是需要“把父类引用转回子类”可能是你把不该放进父类的方法塞进了父类或者该用接口的地方用了继承。4. 实操演示一步步搭一个员工薪资系统4.1 从需求开始设计讲语法讲理论不如完整地走一遍设计过程。假设我们要做一个简单的员工薪资模块需求有几条普通员工有基础工资经理除了基础工资还有岗位津贴每个月计算所有人的总工资支出。这个场景天然适合继承因为经理“是一个”员工同时又多了一项津贴。而且后续如果要加“临时工按小时计薪”“销售按提成计薪”都能顺着这套体系扩展。4.2 定义父类Employee先定义父类把公共部分放进去。员工最基本的特征姓名、基础工资、以及一个算工资的方法。注意这个“算工资”的方法先给个默认实现反正子类大概率会重写它。public class Employee { protected String name; protected double baseSalary; public Employee(String name, double baseSalary) { this.name name; this.baseSalary baseSalary; } public double getSalary() { return baseSalary; } public void printInfo() { System.out.println(name 本月工资 getSalary()); } }字段用protected而不是private是为了让子类能直接访问baseSalary少写一堆getter。这里有个取舍从严格的封装角度看字段全私有通过方法访问更安全但在教学场景或者简单业务里protected能显著减少样板代码。我个人在实际项目里会倾向于字段私有getter因为后续维护时你能在getter里加逻辑而不是在几十个子类里散落地修改字段。4.3 子类Manager继承加扩展接着定义经理类。经理在员工的基础上多了一个岗位津贴字段同时重写了getSalary方法public class Manager extends Employee { private double bonus; public Manager(String name, double baseSalary, double bonus) { super(name, baseSalary); this.bonus bonus; } Override public double getSalary() { return baseSalary bonus; } }注意构造方法里super(name, baseSalary)必须放在第一行。如果不写这行编译器会尝试调用父类的无参构造方法但父类根本没有直接编译报错。子类新增的bonus字段在super执行完之后再初始化这个顺序千万不能乱。4.4 多态数组统计总工资现在有了两个类我们来写一段使用它们的代码。把不同员工统一放进一个Employee数组里循环计算总工资public class Main { public static void main(String[] args) { Employee[] employees new Employee[3]; employees[0] new Employee(张三, 5000); employees[1] new Manager(李四, 8000, 3000); employees[2] new Manager(王五, 7500, 2500); double totalSalary 0; for (Employee e : employees) { totalSalary e.getSalary(); } System.out.println(本月总工资支出 totalSalary); } }这个程序跑出来的结果总工资是5000110001000026000。关键在于e.getSalary()这一行虽然变量的编译类型都是Employee但运行时JVM会根据对象的真实类型自动选择调用Employee版本还是Manager版本李四和王五的那两条记录自动多算了津贴。这段代码完全不需要if-else判断某人是不是Manager多态替我们把逻辑分流了。这也是“对扩展开放”的直观体现。将来系统要加一个新的“销售”类你只需要新增类、重写getSalaryMain方法一行都不用改。这个好处在代码量小的时候不明显等系统长出上百个类之后你就明白当初留的这个口子有多值钱。4.5 一个容易被忽视的小设计上面代码里我把printInfo方法留在父类里面用了this.getName()或者直接访问name字段来打印。这个设计有一个微妙之处如果printInfo里调用getSalary()那打印经理的信息时哪怕外部引用是Employee类型也会自动调用到Manager重写后的getSalary。这就是多态在方法内部互相调用时的“连带效应”。写代码时可以利用这个特性让父类的公共方法去调用会被子类重写的方法实现一种“模板方法”的效果。但这也要小心如果父类构造方法里调用了被子类重写的方法而子类的字段还没初始化运行时可能读到null或者0这是一个非常隐蔽的bug来源。5. 常见问题与排查技巧实录5.1 子类构造器报错Implicit super constructor is undefined这个错误我几乎每年都会看到新手踩一遍。场景是父类定义了一个有参构造方法但没有定义无参构造方法子类构造方法里也没有显式调用super(...)编译器要求调用super()时找不到对应方法于是报错。解决方案很简单要么在父类补一个无参构造方法要么在子类构造方法第一行显式调用super并传参。我建议养成一个习惯只要写了构造方法就明确决定父类是否需要无参构造而不是依赖IDE自动生成。因为父类一旦加了有参构造原本能编译通过的子类可能一夜之间全部编译失败。5.2 重写时方法签名不一致养成了Override的好习惯有的同学写了这样一个子类方法public class Manager extends Employee { public void getSalary(int month) { // 想重写但加了个参数 return baseSalary * month bonus; } }这个方法是重载不是重写。调用Employee引用上的getSalary()时不会执行到这里。如果你心里以为“我重写了”实际却走了父类的默认逻辑结果就是数据算错但程序不报错这类bug特别阴险。对策就是无脑加Override。加了注解以后编译器发现父类没有对应签名的方法会第一时间报错提醒你。我看到有同学说“Override不加也能编译运行”确实没错但那是把编译器这个免费保镖给辞退了没必要。5.3 父类字段被子类“隐藏”来看一个容易误判的问题。父类有一个name字段子类也定义一个同名的name字段这时候子类里访问name到底访问的是谁public class Person { protected String name 父类的名字; } public class Student extends Person { protected String name 子类的名字; public void printName() { System.out.println(name); // 输出子类的名字 System.out.println(super.name); // 输出父类的名字 } }答案是子类的name会“遮蔽”父类的name这不是重写是字段隐藏。字段隐藏极容易引发混乱尤其是当父类的方法内部访问name时它用的其实是父类的name而子类方法访问的是子类的name同一段逻辑在不同层表现得完全不一样。我在实际开发中的建议是绝不在子类里声明和父类同名的字段。如果确实需要给同一个概念加更细的数据就换个名字或者干脆把父类字段设为private并通过方法操作这样至少不会出现两套同名变量互相干扰的问题。5.4 强制类型转换引发ClassCastException向下转型前不检查类型是最容易复现的运行期崩溃。来一个最典型的错误写法Person p new Teacher(); Student s (Student) p; // 运行期抛出 ClassCastException编译能过因为Person和Student之间有继承关系编译器认为强制转换“可能合法”。但运行期JVM发现堆里的对象本质上是Teacher和Student没有兼容关系直接抛异常。所以向下转型前务必先用instanceof检查if (p instanceof Student) { Student s (Student) p; }不过从设计层面说如果你发现自己大量用到instanceof向下转型要警惕这是不是滥用继承的信号。多态本来应该替你做类型分流的活现在需要你自己跑来判断类型说明继承体系可能建得不对或者该用接口的地方用了抽象继承。5.5 继承层级太深继承传递性是好事但层级过深就会变成灾难。三层以内的继承通常还比较好理解一旦到了五层六层一个类的方法可能来自任意一层祖先出问题时你要沿着整条继承链找是谁定义了某个方法、谁重写了某个方法排查成本非常高。我的个人硬性建议是写业务代码的时候继承层级尽量不要超过三层。如果发现需要第四层才能表达的业务差异先停下来想想能不能换一种组织方式比如用组合、用接口、或者把那一层差异单独抽出去。6. 关于继承的边界不是所有复用都用继承6.1 继承的脆弱性继承最大的问题在于子类和父类之间存在脆弱的耦合。父类的任何实现改动都可能波及到所有子类。比如父类原来把name初始化为空字符串某个子类一直依赖这个初始值某天父类改成初始化为null子类没适配就直接出bug。更麻烦的是这种影响是隐性的你没改动子类一行代码子类行为却变了。这就是为什么很多架构师会强调“组合优于继承”。组合的意思是一个类持有另一个类的引用通过调用被持有对象的方法来复用能力而不是通过继承去攫取父类的内部实现。组合关系清晰依赖是显式的替换起来也容易。6.2 哪些场景更适合组合判断的标准依然回到IS-A和HAS-A。汽车“拥有”引擎是HAS-A应该组合。图书馆“拥有”藏书也是HAS-A。反过来经理“是”员工研究生“是”学生才是IS-A适合继承。落实到代码层面比如刚才的员工系统如果为了复用计算逻辑而让Manager去继承一个SalaryCalculator那就是明显的错误。正确的做法是在Manager里持有SalaryCalculator的引用需要算工资时调它。类与类之间是不是“是一个”的关系这个问题用大白话就能判断不需要什么高超的架构设计能力。6.3 我总结的继承使用清单项目里加了那么多继承之后我总结了一套自己的使用清单每次动手写extends之前都会过一遍子类和父类是否确实构成IS-A关系用“子类可以替代父类而不产生奇怪行为”来验证子类是否真的有扩展父类的能力或行为如果只是单纯想要复用复制粘贴可能比继承更安全继承层级能否控制在三层以内父类是否足够稳定如果父类还在频繁变化现在引入继承会让所有子类一起跟着变是否可以用接口或组合替代如果答案模棱两可优先选组合或接口。这套清单救过我很多次。回头看它帮我避开的坑最典型的就是某个项目里把“数据源”做成了父类然后让MySQL数据源、Redis数据源、文件数据源都去继承它。后来发现三个子类对父类的依赖方式完全不同“继承”只在名字上好听代码里到处是重写和绕过最后重构成了组合加接口反而简洁得多。6.4 面向对象的基础也是今后设计模式的地基这一讲的内容虽然是“基础”但它其实是后面所有设计模式的地基。模板方法模式依赖的就是继承加重写策略模式的核心是组合加多态工厂模式大量使用向上转型。可以说如果这一讲没吃透后面学设计模式会感觉每个模式都似懂非懂。所以这一讲值得多花一点时间。不要光看一定要动手把员工薪资这个例子敲一遍然后自己试着加一个新的“临时工”类用多态方式接进去。只有在代码里真正感受到了“父类引用调用子类方法”这个神奇现象你才算真正掌握了面向对象的核心感觉。从我这些年带新人的经验来看面向对象最难的永远不是语法而是思维方式转变。从“我写代码就是从头到尾写步骤”到“我从抽象里提炼共性让程序结构替我管理复杂度”这一步跨过去之后学习曲线会突然平缓很多。这一讲就是跨这一步的垫脚石。