ARTICLE DETAIL

资讯详情

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

Java面向对象编程:从封装、继承、多态到设计原则

Java面向对象编程:从封装、继承、多态到设计原则 1. 从“过程”到“对象”编程思维的范式跃迁如果你刚开始接触Java或者从C语言这类面向过程的语言转过来可能会对“面向对象”这四个字感到既熟悉又陌生。教科书上总是说“万物皆对象”但到底什么是“对象”它和之前写的一行行函数调用有什么区别为什么Java要如此强调这个概念让我从一个最直接的感受说起。在面向过程的编程中我们的核心是“动词”——我们关注的是“做什么”。比如我们要处理一个“学生注册选课”的业务代码可能会是这样先定义一个struct Student来存数据然后写一个registerCourse(Student s, Course c)函数再写一个printStudentInfo(Student s)函数。数据和操作数据的函数是分离的函数是主动的数据是被动的。程序就像一份烹饪食谱我们程序员是厨师按照步骤函数调用处理食材数据。而面向对象编程核心是“名词”——我们关注的是“谁”来做。同样是学生选课我们会想这个世界里有一个叫“学生”的实体他自己拥有姓名、学号等属性也自己拥有“选课”、“打印信息”这些能力。于是我们不再从外部定义一个函数去操作学生数据而是定义一个Student类这个类像一张蓝图既规定了学生有什么属性数据也规定了学生能做什么方法。当我们根据这张蓝图创建一个具体的“学生张三”时这个“张三”就是一个对象。程序的世界变成了由一个个具备自主行为能力的对象互动组成的。这种思维的转变就是编程的范式跃迁。它不是为了增加学习难度而是为了解决软件日益复杂带来的根本性问题如何管理复杂度如何提高代码的复用性、扩展性和维护性。当系统有成千上万个实体和交互时面向过程那种“上帝视角”的、线性的控制流会变得极其脆弱和难以维护。而面向对象通过将数据和操作封装在一起形成高内聚、低耦合的模块类让代码结构更贴近现实世界的模型从而更易于理解和演化。理解了这一点我们再来看Java。Java是一门纯粹的面向对象语言尽管它保留了基本数据类型。这意味着在Java中几乎所有的代码都围绕着类和对象来组织。学习Java面向对象就是学习如何用Java的语法去构建和驱动这个由对象组成的虚拟世界。接下来我们就深入这个世界的三大基石封装、继承和多态。2. 类的蓝图与对象的生命封装的艺术封装是面向对象最基础、也最重要的特性。它有两个核心目的第一将数据属性和对数据的操作方法捆绑在一起形成一个独立的单元第二隐藏对象的内部实现细节仅对外暴露必要的接口。2.1 定义类从现实到代码的抽象类的定义就是一个抽象的过程。我们忽略学生“张三”或“李四”的具体细节提取所有学生的共性。public class Student { // 属性成员变量描述对象的状态通常私有以保护数据 private String name; // 学生姓名 private String studentId; // 学号 private int age; // 年龄 private ListString courses; // 选修课程列表 // 构造方法用于创建对象时进行初始化 // 无参构造 public Student() { this.courses new ArrayList(); } // 带参构造 public Student(String name, String studentId, int age) { this(); // 调用无参构造初始化courses this.name name; this.studentId studentId; this.age age; } // 方法成员方法定义对象的行为 // Getter 方法提供对私有属性的安全访问 public String getName() { return name; } // Setter 方法提供对私有属性的安全修改 public void setName(String name) { if (name ! null !name.trim().isEmpty()) { this.name name; } else { System.out.println(姓名不能为空); } } public String getStudentId() { return studentId; } // 学号通常设计为不可变所以不提供setter // ... 其他属性的getter/setter // 业务方法 public void registerCourse(String courseName) { if (courseName ! null !courses.contains(courseName)) { courses.add(courseName); System.out.println(name 成功选修了: courseName); } } public void printInfo() { System.out.println(学生信息); System.out.println( 姓名 name); System.out.println( 学号 studentId); System.out.println( 年龄 age); System.out.println( 课程 courses); } }关键点解析private修饰符这是实现封装的关键。将属性声明为private意味着它们只能在Student类的内部被直接访问。外部代码无法直接student.name “张三”必须通过公共的getName()和setName()方法。这就像你的银行账户余额银行不会让你直接修改数据库里的数字而是必须通过存款、取款这些受控的接口方法来操作。this关键字代表当前对象的引用。在构造方法或实例方法中用于区分成员变量和局部变量参数。this.name name表示将参数name的值赋给当前对象的成员变量name。构造方法与类同名没有返回类型。在通过new关键字创建对象时自动调用用于初始化对象的状态。一个类可以有多个构造方法重载根据传入参数的不同进行不同的初始化。Getter/Setter这是面向对象设计中经典的访问器模式。它并非简单的“脱裤子放屁”而是提供了控制点。例如在setName中我们可以加入数据校验非空检查在getCourses中我们可以返回一个不可修改的列表副本return Collections.unmodifiableList(courses);以防止外部代码意外修改内部数据。这是封装“隐藏细节暴露接口”理念的直接体现。2.2 创建与使用对象蓝图的实例化定义了类就像有了汽车的设计图纸。要真正开车我们需要根据图纸造出一辆辆具体的车。public class Main { public static void main(String[] args) { // 使用new关键字和构造方法创建对象实例化 Student student1 new Student(张三, S001, 20); Student student2 new Student(); // 使用无参构造 student2.setName(李四); student2.setAge(21); // student2.studentId 未设置因为它是private且没有setter这里需要其他方式初始化比如通过构造方法 // 调用对象的方法 student1.registerCourse(Java程序设计); student1.registerCourse(数据结构); student2.registerCourse(大学英语); // 打印信息 student1.printInfo(); System.out.println(---); student2.printInfo(); // 通过Getter访问属性 System.out.println(student1.getName() 的年龄是 student1.getAge()); } }输出结果张三 成功选修了: Java程序设计 张三 成功选修了: 数据结构 李四 成功选修了: 大学英语 学生信息 姓名张三 学号S001 年龄20 课程[Java程序设计, 数据结构] --- 学生信息 姓名李四 学号null 年龄21 课程[大学英语] 张三 的年龄是 20实操心得与避坑指南null值问题注意student2的studentId为null因为无参构造没有初始化它。在后续使用中比如打印或参与运算可能引发NullPointerException。最佳实践是在构造方法或Setter中对引用类型属性进行合理的默认值初始化如空字符串、空集合。可变对象的封装泄漏如果getCourses()直接返回courses这个List外部获得这个引用后就可以直接调用list.add()或list.clear()破坏了Student对象对自身课程列表的控制。返回防御性拷贝是解决此问题的常用手段。static的误用static修饰的成员属于类而非对象。如果在Student中定义private static int count;来统计学生人数是可以的。但若将name也定义为static那就意味着所有学生对象共享同一个name这显然是错误的。牢记描述对象个体状态的属性绝不应该用static。封装的意义在于它建立了对象的“边界”。对象内部如何存储数据、如何实现逻辑是它的隐私外部只需要知道“它能做什么”公共方法。这极大地降低了模块间的耦合度使得修改一个类的内部实现时只要对外接口不变就不会影响其他依赖它的类。3. 继承与多态构建层次与实现灵活如果说封装是让单个对象自立自强那么继承和多态则是让对象之间产生联系、协同工作的强大机制。3.1 继承代码复用的层次结构继承表达的是“is-a”的关系。比如研究生是学生老师也是学校的一员。我们可以建立一个类层次结构。// 父类基类、超类 public class Person { private String name; private int age; public Person(String name, int age) { this.name name; this.age age; } public void introduce() { System.out.println(大家好我是 name 今年 age 岁。); } // Getter/Setter 省略... } // 子类派生类继承自Person public class Student extends Person { private String studentId; public Student(String name, int age, String studentId) { super(name, age); // 调用父类构造方法必须放在第一行 this.studentId studentId; } // 可以添加子类特有的方法 public void study() { System.out.println(getName() 正在努力学习。); } // 方法重写覆盖父类的方法提供更具体的行为 Override public void introduce() { super.introduce(); // 可以选择性调用父类方法 System.out.println(我的学号是 studentId); } } // 另一个子类 public class Teacher extends Person { private String teacherId; public Teacher(String name, int age, String teacherId) { super(name, age); this.teacherId teacherId; } public void teach() { System.out.println(getName() 老师正在授课。); } Override public void introduce() { System.out.println(我是教师 getName() 工号 teacherId); } }关键点解析extends关键字用于声明继承关系。Student extends Person意味着Student类拥有Person类的所有非私有属性和方法注意私有private成员被继承但不可直接访问。super关键字指代父类对象。在子类构造方法中super(...)用于调用父类构造方法必须是子类构造方法的第一条语句。在子类实例方法中super.methodName()用于调用被重写的父类方法。方法重写子类可以提供父类方法的特定实现。重写时方法签名名称、参数列表必须与父类方法完全相同返回类型可以是父类返回类型的子类协变返回类型访问权限不能比父类更严格。Override注解是强烈推荐使用的它让编译器帮你检查重写是否正确。单继承Java只支持类的单继承即一个类只能有一个直接父类。这是为了避免“菱形继承”等多重继承带来的复杂性。但可以通过接口实现多继承见下文。3.2 多态同一接口不同表现多态是继承的必然结果也是面向对象最精妙的部分。它允许父类引用指向子类对象并在运行时根据实际对象类型来调用相应的方法。public class PolymorphismDemo { public static void main(String[] args) { // 多态的典型体现父类引用指向子类对象 Person p1 new Student(王五, 22, S003); Person p2 new Teacher(赵老师, 40, T001); // 编译时看左边Person类型运行时看右边实际对象类型 p1.introduce(); // 输出大家好我是王五... 我的学号是S003 p2.introduce(); // 输出我是教师赵老师工号T001 // p1.study(); // 编译错误Person类没有study方法 // 需要向下转型 if (p1 instanceof Student) { Student s (Student) p1; // 向下转型 s.study(); } // 多态在集合中的应用 ListPerson schoolMembers new ArrayList(); schoolMembers.add(new Student(钱六, 21, S004)); schoolMembers.add(new Teacher(孙教授, 55, T002)); schoolMembers.add(new Student(周七, 20, S005)); System.out.println(\n--- 遍历学校成员 ---); for (Person person : schoolMembers) { person.introduce(); // 同一个方法调用产生不同行为 } } }多态的运行机制核心理解当执行p1.introduce()时发生了以下过程编译阶段编译器检查p1的声明类型Person。它发现Person类中有introduce()方法因此编译通过。编译器不关心p1实际指向什么。运行阶段JVM找到p1实际指向的堆内存中的对象一个Student对象。然后JVM调用这个Student对象的introduce()方法。如果Student重写了该方法则执行重写后的版本否则沿继承链向上查找执行。为什么需要多态它极大地提高了程序的扩展性和可维护性。在上面的for循环中我们不需要知道List里具体是Student还是Teacher只需要调用introduce()。未来如果新增一个Staff类并继承Person我们只需要创建Staff对象并加入列表循环代码一行都不用改Staff自己的introduce()方法就会被正确调用。这就是著名的“开闭原则”对扩展开放对修改关闭的体现。避坑指南类转换异常向下转型(Student) p1是不安全的如果p1实际不是Student类型会抛出ClassCastException。务必先使用instanceof进行类型检查。哪些方法可以多态只有实例方法才存在多态。静态方法、属性字段不存在多态。对于属性引用类型是什么访问的就是该类型的属性。对于静态方法编译时就已经绑定与对象无关。构造方法调用顺序创建子类对象时构造方法调用链是子类构造器 - 父类构造器通过super- ... -Object构造器。属性初始化则在对应构造器执行前完成。4. 抽象类与接口定义契约与实现规范继承体系中的类越来越具体但有时我们需要在顶层定义一些“不完整”的类只规定必须有什么能力而不实现具体细节。这就是抽象类和接口的用武之地。4.1 抽象类不完全的蓝图抽象类用abstract修饰它不能被实例化不能new。它可以包含抽象方法没有方法体的方法也可以包含普通方法和属性。// 抽象类图形 public abstract class Shape { // 抽象方法计算面积没有实现体分号结束 public abstract double calculateArea(); // 普通方法 public void printInfo() { System.out.println(这是一个图形面积是 calculateArea()); // 可以调用抽象方法 } // 可以有构造方法虽然不能直接new但子类构造时会调用 public Shape() { System.out.println(Shape 构造器被调用); } } // 具体子类必须实现所有抽象方法 public class Circle extends Shape { private double radius; public Circle(double radius) { super(); // 调用父类Shape的构造器 this.radius radius; } Override public double calculateArea() { return Math.PI * radius * radius; } } public class Rectangle extends Shape { private double width; private double height; public Rectangle(double width, double height) { this.width width; this.height height; } Override public double calculateArea() { return width * height; } }使用场景抽象类用于表示一种“is-a”关系并且这个父类本身有实际意义只是不够具体。比如Shape它就是一个图形的抽象概念拥有“计算面积”这个所有图形都应具备的行为但无法给出通用实现。Circle和Rectangle是具体的图形。4.2 接口纯粹的能力契约接口用interface定义在Java 8之前它只能包含抽象方法和常量。Java 8之后允许包含default方法默认方法和static方法。接口定义了一组能力或契约实现类必须履行。// 接口可飞行的 public interface Flyable { // 常量 (默认 public static final) int MAX_SPEED 1000; // 抽象方法 (默认 public abstract) void fly(); // Java 8 默认方法提供默认实现 default void takeOff() { System.out.println(准备起飞...); } // Java 8 静态方法 static void showMaxSpeed() { System.out.println(最大飞行速度 MAX_SPEED); } } // 接口可攻击的 public interface Attackable { void attack(); } // 类可以实现多个接口 public class Bird implements Flyable { Override public void fly() { System.out.println(鸟儿扇动翅膀飞行。); } } public class Airplane implements Flyable { Override public void fly() { System.out.println(飞机依靠引擎飞行。); } // 可以重写默认方法 Override public void takeOff() { System.out.println(飞机在跑道上加速起飞...); } } // 实现多个接口 public class FighterJet implements Flyable, Attackable { Override public void fly() { System.out.println(战斗机超音速飞行。); } Override public void attack() { System.out.println(战斗机发射导弹。); } }接口 vs 抽象类核心区别与选择这是一个经典面试题。它们的根本区别在于设计目的抽象类是对类的抽象是一种“是不是”的关系。它定义了是什么is-a并可能提供部分实现。比如Shape它首先是一个“图形”。接口是对行为的抽象是一种“有没有”的关系。它定义了能做什么has-a。比如Flyable它不关心你是什么只关心你能不能飞。选择依据关注点如果你要定义多个类共同的类型并且这些类共享一些基础状态或行为用抽象类。如果你只是定义一组合同或能力不关心实现者的具体类型用接口。单继承 vs 多实现Java类只能单继承但可以实现多个接口。如果需要多重行为组合如FighterJet既能飞又能攻击接口是唯一选择。状态抽象类可以有实例变量状态接口在Java 8之前只能有常量。默认方法Java 8引入接口默认方法后接口也能提供方法实现这缩小了与抽象类的差距但其核心设计哲学契约 vs 不完全实现未变。现代Java开发中有一个趋势是“面向接口编程”。优先定义接口来声明组件之间的契约然后提供实现。这降低了耦合更利于测试如Mock和框架集成如Spring的依赖注入。5. 面向对象设计原则初窥与实战避坑理解了三大特性我们还需要一些设计原则来指导我们如何用好它们避免写出僵化、难以维护的代码。这里介绍两个最基础、最重要的原则。5.1 组合优于继承继承虽然强大但滥用会导致类层次结构僵化“脆弱的基类”问题。组合Composition通过在一个类中持有其他类的实例来实现功能复用通常更灵活。场景我们需要一个Car类它有“引擎”和“音响”的功能。不推荐的继承方式public class Engine { /* 启动、停止 */ } public class SoundSystem { /* 播放、暂停 */ } // 通过多重继承Java不支持 // public class Car extends Engine, SoundSystem { } // 错误 // 只能选一个继承另一个功能需要重新实现或通过其他蹩脚方式引入。推荐的组合方式public class Car { // 持有其他对象的引用 private Engine engine; private SoundSystem soundSystem; public Car() { this.engine new Engine(); this.soundSystem new SoundSystem(); } public void start() { engine.start(); System.out.println(汽车启动); } public void playMusic() { soundSystem.play(); } }为什么组合更好灵活性Car的引擎和音响可以轻松替换成其他品牌或型号只需注入不同的Engine或SoundSystem对象即可。而继承关系在编译时就固定了。解耦Car不再与Engine/SoundSystem的父类紧密绑定。Engine类的修改不会直接影响到Car类除非接口变化。避免继承层次爆炸如果使用继承要为“柴油引擎汽车”、“电动引擎汽车”、“高级音响汽车”等每一种组合都创建一个子类会导致类数量急剧膨胀。原则除非确实是严格的“is-a”关系如Circleis aShape否则应优先考虑使用组合来复用功能。5.2 面向接口编程而非实现编程这是降低耦合度的黄金法则。客户端代码应该依赖接口或抽象类而不是具体的实现类。不好的做法public class OrderService { // 直接依赖具体的MySQL数据库操作类 private MySQLOrderDao orderDao new MySQLOrderDao(); public void saveOrder(Order order) { orderDao.insert(order); } }如果哪天要换用Oracle数据库必须修改OrderService的源代码。好的做法// 1. 定义接口 public interface OrderDao { void insert(Order order); Order findById(String id); } // 2. 提供不同实现 public class MySQLOrderDao implements OrderDao { /* ... */ } public class OracleOrderDao implements OrderDao { /* ... */ } // 3. 服务类依赖接口 public class OrderService { private OrderDao orderDao; // 依赖抽象 // 通过构造器或Setter注入具体实现这就是依赖注入的思想 public OrderService(OrderDao orderDao) { this.orderDao orderDao; } public void saveOrder(Order order) { orderDao.insert(order); } } // 4. 使用时组装 public class Main { public static void main(String[] args) { OrderDao dao new MySQLOrderDao(); // 或 new OracleOrderDao() OrderService service new OrderService(dao); // ... 使用service } }好处OrderService的代码完全与具体的数据库实现解耦。切换数据库只需在程序入口处更换OrderDao的实现实例OrderService本身无需任何改动。这大大提高了代码的可测试性可以轻松注入一个Mock的OrderDao进行单元测试和可维护性。5.3 常见“坑点”与调试技巧equals和hashCode的重写当我们需要根据对象内容而非内存地址判断相等时必须重写Object类的equals方法。同时必须重写hashCode方法以保证相等的对象具有相等的哈希码这是HashMap、HashSet等集合正确工作的约定。可以使用IDE如IntelliJ IDEA自动生成这两个方法。toString方法的重写默认的Object.toString()输出类名和哈希码可读性差。重写toString()方法返回对象主要属性的字符串表示对于调试和日志记录非常有用。慎用继承尤其是深层次继承继承层次过深会使代码难以理解且父类的任何改动都可能“牵一发而动全身”。考虑使用组合或接口替代。多态中的类型判断虽然instanceof和向下转型有时不可避免但过度使用它们通常是设计不佳的信号可能意味着你没有充分利用多态。思考是否可以通过在父类/接口中定义方法由子类各自实现来消除类型判断。静态上下文陷阱在静态方法如main方法中不能直接访问类的非静态成员属性和方法因为非静态成员属于对象而静态方法调用时可能还没有对象。需要先创建对象实例。面向对象不是记住几个概念和语法那么简单它是一种组织代码的思维方式。初学时可以从模仿和遵循上述原则开始。随着项目经验积累你会越来越深刻地体会到良好的面向对象设计是如何让复杂系统变得清晰、灵活和健壮的。这需要持续的练习、反思和重构。
返回列表