万字详解Java中的面向对象(一)——设计原则

概览

面向对象简称OO(object-oriented)是相对面向过程(procedure-oriented)来说的,是一种编程思想,Java就是一门面向对象的语言,包括三大特性和六大原则。其中,三大特性指的是封装、继承和多态。六大原则指的是单一职责原则、开放封闭原则、迪米特原则、里氏替换原则、依赖倒置原则以及接口隔离原则。单一职责原则是指一个类应该是一组相关性很高的函数和数据的封装,这是为了提高程序的内聚性,而其他五个原则是通过抽象来实现的,目的是为了降低程序的耦合性以及提高可扩展性。

面向对象编程简称OOP(Object-oriented programming),是将事务高度抽象化的编程模式。面向对象编程是以功能来划分问题的,将问题分解成一个一个步骤,对每个步骤进行相应的抽象,形成对应对象,通过不同对象之间的调用,组合成某个功能解决问题。

面向对象是对比面向过程来说的,面向过程编程简称POP(Procedural oriented programming)。面向过程编程是一种以过程为中心的编程方式,主要通过一系列步骤来处理数据。程序通过调用函数一步步完成任务,每个函数都执行特定的操作。

java 复制代码
// 面向过程编程示例:计算矩形面积

public class ProceduralExample {
    // 定义计算面积的函数
    public static int calculateArea(int width, int height) {
        return width * height;
    }

    public static void main(String[] args) {
        int width = 5;
        int height = 10;
        int area = calculateArea(width, height);
        System.out.println("矩形的面积是: " + area);
    }
}

面向对象编程是一种以对象为中心的编程方式,将数据和操作封装在一起,通过对象的交互来完成程序的功能。对象是类的实例,类定义了对象的属性和方法。

java 复制代码
// 面向对象编程示例:计算矩形面积

// 定义矩形类
class Rectangle {
    private int width;
    private int height;

    // 构造函数
    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    // 计算面积的方法
    public int calculateArea() {
        return width * height;
    }
}

public class ObjectOrientedExample {
    public static void main(String[] args) {
        // 创建矩形对象
        Rectangle rectangle = new Rectangle(5, 10);
        // 调用对象的方法计算面积
        int area = rectangle.calculateArea();
        System.out.println("矩形的面积是: " + area);
    }
}

通过例子可以看出面向对象更重视不重复造轮子,即创建一次重复使用。简而言之,用面向过程的方法写出来的程序是一份蛋炒饭,而用面向对象写出来的程序是一份盖浇饭,就是在一碗白米饭上面浇上一份盖菜,你喜欢什么菜,你就浇上什么菜。

面向对象是模型化的,你只需抽象出几个类,进行封装成各个功能,通过不同对象之间的调用来解决问题,而面向过程需要把问题分解为几个步骤,每个步骤用对应的函数调用即可。面向过程是具体化的、流程化的,解决一个问题需要你一步一步的分析,一步一步的实现。面向对象的底层其实还是面向过程,把面向过程抽象成类,然后进行封装,方便我们我们使用就是面向对象了。

抽象会使复杂的问题更加简单化,面向对象更符合人类的思维,而面向过程则是机器的思想。选择哪种编程范式取决于具体的应用场景和开发需求。对于复杂系统的开发,面向对象更有优势,因为它可以更好地组织和管理代码;而对于一些简单的任务,面向过程可能更加直观和高效。

软件设计原则

软件设计原则是指导软件开发过程中设计和构建软件系统的一组规则。这些规则的目的是为了让程序达到高内聚、低耦合以及提高扩展性,其实现手段是面向对象的三大特性:封装、继承以及多态。

设计原则名称 核心思想
单一职责原则 一个类只负责一个特定的职责
开放封闭原则 软件实体应该可以扩展,但不应该修改其已有代码
依赖倒转原则 高层模块不应该依赖低层模块,两者都应该依赖抽象;抽象不应该依赖细节,细节应该依赖抽象
里氏替换原则 任何基类可以出现的地方,子类也可以出现
接口隔离原则 使用多个专门的接口,而不是一个通用的接口
合成复用原则 优先使用组合而不是继承来实现代码复用
迪米特法则 一个对象应尽量少地了解其他对象,从而降低耦合度

单一职责原则

其核心思想为,一个类最好只做一件事。单一职责原则可降低类的复杂度,提高代码可读性、可维护性、降低变更风险。单一职责原则可以看做是低耦合、高内聚在面向对象原则上的引申,将职责定义为引起变化的原因,以提高内聚性来减少引起变化的原因。职责过多,可能引起它变化的原因就越多,这将导致职责依赖,相互之间就产生影响,从而大大损伤其内聚性和耦合度。通常意义下的单一职责,就是指只有一种单一功能,不要为类实现过多的功能点,以保证实体只有一个引起它变化的原因。专注,是一个人优良的品质;同样的,单一也是一个类的优良设计。交杂不清的职责将使得代码看起来特别别扭牵一发而动全身,有失美感和必然导致丑陋的系统错误风险。

java 复制代码
public class MainTest {
    public static void main(String[] args) {
        Vehicle vehicle = new Vehicle();
        vehicle.running("汽车");
        // 飞机不是在路上行驶
        vehicle.running("飞机");
    }
}

/**
 * 在run方法中违反了单一职责原则
 * 解决方法根据不同的交通工具,分解成不同的类即可
 */
class Vehicle{
    public void running(String name) {
        System.out.println(name + "在路上行驶 ....");
    }
}
java 复制代码
// 解决
public class MainTest {
    public static void main(String[] args) {
        Driving driving = new Driving();
        driving.running("汽车");
        Flight flight = new Flight();
        flight.running("飞机");
    }
}

class Driving {
    public void running(String name) {
        System.out.println(name + "在路上行驶 ....");
    }
}

class Flight {
    public void running(String name) {
        System.out.println(name + "在空中飞行 ....");
    }
}

只要类中方法数量足够少,可以在方法级别保持单一职责原则。

java 复制代码
public class MainTest {
    public static void main(String[] args) {
        Vehicle2 vehicle2 = new Vehicle2();
        vehicle2.driving("汽车");
        vehicle2.flight("飞机");
    }
}
/*
 * 改进
 *↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
 */

class Vehicle2 {
    public void driving(String name) {
        System.out.println(name + "在路上行驶 ....");
    }
    public void flight(String name) {
        System.out.println(name + "在空中飞行 ....");
    }
}

开放封闭原则

软件实体应该是可扩展的,而不可修改的。也就是对提供方扩展开放,对使用方修改封闭的。开放封闭原则主要体现在两个方面:

  • 对扩展开放,意味着有新的需求或变化时,可以对现有代码进行扩展,以适应新的情况。
  • 对修改封闭,意味着类一旦设计完成,就可以独立完成其工作,而不要对其进行任何尝试的修改。

实现开放封闭原则的核心思想是对抽象编程,而不对具体编程。因为抽象相对稳定,让类依赖于固定的抽象,所以修改就是封闭的;而通过面向对象的继承和多态机制,又可以实现对抽象类的继承,通过覆写其方法来改变固有行为,实现新的拓展方法,所以就是开放的。需求总是变化,当我们给程序添加或者修改功能时,需要用开放封闭原则来封闭变化满足需求,同时还能保持软件内部的封装体系稳定,不被需求的变化影响。编程中遵循其他原则,以及使用其他设计模式的目的就是为了遵循开闭原则。

当软件需要变化时,尽量使用扩展的软件实体的方式行为来实现变化,而不是通过修改已有的代码来实现变化。

java 复制代码
public class MainTest {
    public static void main(String[] args) {
        Mother mother = new Mother();

        Son son = new Son();
        Daughter daughter = new Daughter();

        // 注入子类对象 如果扩展需要其他类 换成其他对象即可
        mother.setAbstractFather(son);
        mother.display();
    }
}

abstract class AbstractFather {

    protected abstract void display();

}
class Son  extends AbstractFather{
    @Override
    protected void display() {
        System.out.println("son class ...");
    }
}
class Daughter  extends AbstractFather{

    @Override
    protected void display() {
        System.out.println("daughter class ...");
    }
}

class Mother {

    private AbstractFather abstractFather;

    public void setAbstractFather(AbstractFather abstractFather) {
        this.abstractFather = abstractFather;
    }

    public void display() {
        abstractFather.display();
    }

}

依赖倒置原则

该原则依赖于抽象,具体而言就是高层模块不依赖于底层模块,二者都同依赖于抽象,抽象不依赖于具体,具体依赖于抽象。我们知道,依赖一定会存在于类与类、模块与模块之间。当两个模块之间存在紧密的耦合关系时,最好的方法就是分离接口和实现,即在依赖之间定义一个抽象的接口使得高层模块调用接口,而底层模块实现接口的定义,以此来有效控制耦合关系,达到依赖于抽象的设计目标。抽象的稳定性决定了系统的稳定性,因为抽象是不变的,依赖于抽象是面向对象设计的精髓,也是依赖倒置原则的核心。依赖于抽象是一个通用的原则,而某些时候依赖于细节则是在所难免的,必须权衡在抽象和具体之间的取舍。

依赖于抽象,就是对接口编程,不要对实现编程。

java 复制代码
// 定义一个接口,表示消息发送者
interface MessageSender {
    void sendMessage(String message);
}

// 实现接口的EmailSender类
class EmailSender implements MessageSender {
    @Override
    public void sendMessage(String message) {
        System.out.println("Sending email with message: " + message);
    }
}

// 实现接口的SmsSender类
class SmsSender implements MessageSender {
    @Override
    public void sendMessage(String message) {
        System.out.println("Sending SMS with message: " + message);
    }
}

// 高层模块的MessageService类依赖于MessageSender接口
class MessageService {
    private MessageSender sender;

    // 通过构造函数注入依赖
    public MessageService(MessageSender sender) {
        this.sender = sender;
    }

    public void processMessage(String message) {
        // 使用MessageSender接口发送消息
        sender.sendMessage(message);
    }
}

public class Main {
    public static void main(String[] args) {
        // 使用EmailSender发送消息
        MessageSender emailSender = new EmailSender();
        MessageService emailService = new MessageService(emailSender);
        emailService.processMessage("Hello via Email!");

        // 使用SmsSender发送消息
        MessageSender smsSender = new SmsSender();
        MessageService smsService = new MessageService(smsSender);
        smsService.processMessage("Hello via SMS!");
    }
}

接口隔离原则

使用多个小的专门的接口,而不要使用一个大的总接口。具体而言,接口隔离原则体现在,接口应该是内聚的,应该避免"胖"接口。一个类对另外一个类的依赖应该建立在最小的接口上,不要强迫依赖不用的方法,这是一种接口污染。接口有效地将细节和抽象隔离,体现了对抽象编程的一切好处,接口隔离强调接口的单一性,而胖接口存在明显的弊端,会导致实现的类型必须完全实现接口的所有方法、属性等。某些时候,实现类并非需要所有的接口定义,在设计上这是"浪费",而且在实施上这会带来潜在的问题,对胖接口的修改将导致一连串的客户端程序需要修改,有时候这是一种灾难。在这种情况下,应该将胖接口分离为多个特点的定制化方法,使得客户端仅仅依赖于它们的实际调用的方法,从而解除了客户端不会依赖于它们不用的方法。

分离的手段主要有以下两种:

  • 委托分离,通过增加一个新的类型来委托客户的请求,隔离客户和接口的直接依赖,但是会增加系统的开销。
  • 多重继承分离,通过接口多继承来实现客户的需求,这种方式是较好的。
java 复制代码
public class MainTest {
    public static void main(String[] args) {
        FuncImpl func = new FuncImpl();
        func.func1();
        func.func2();
        func.func3();
    }
}

interface Function1{
    void func1();
    // 如果将接口中的方法都写在一个接口就会造成实现该接口就要重写该接口所有方法。
    // 当然Java 8 接口可以有实现,降低了维护成本,解了决该问题;
    // 但是我们还是应当遵循该原则,使得接口看起来更加清晰
    // void func2();
    // void func3();
}
interface Function2 {
    void func2();
}
interface Function3 {
    void func3();
}

class FuncImpl implements Function1,Function2,Function3{

    @Override
    public void func1() {
        System.out.println("i am function1 impl");
    }

    @Override
    public void func2() {
        System.out.println("i am function2 impl");
    }

    @Override
    public void func3() {
        System.out.println("i am function3 impl");
    }
}

里氏替换原则

里氏替换原则这一思想体现为对继承机制的约束规范,只有子类能够替换基类时,才能保证系统在运行期内识别子类,这是保证继承复用的基础。在父类和子类的具体行为中,必须严格把握继承层次中的关系和特征,将基类替换为子类,程序的行为不会发生任何变化。同时这一约束反过来则是不成立的,子类可以替换基类,但是基类不一定能替换子类。里氏替换原则,主要着眼于对抽象和多态建立在继承的基础上,因此只有遵循了里氏替换原则,才能保证继承复用是可靠地。

实现的方法是面向接口编程,即将公共部分抽象为基类接口或抽象类,在子类中通过覆写父类的方法实现新的方式支持同样的职责。里氏替换原则是关于继承机制的设计原则,违反了里氏替换原则就必然导致违反开放封闭原则。里氏替换原则能够保证系统具有良好的拓展性,同时实现基于多态的抽象机制,能够减少代码冗余,避免运行期的类型判别。

简单来说就是子类可以扩展父类的功能,而不应该改变父类原有的功能。如果通过重写父类方法来完成新的功能,这样写起来虽然简单,但整个体系的可复用性会非常差,特别是运用多态比较频繁时,程序运行出错的概率会非常大。

java 复制代码
public class MainTest {
    public static void main(String[] args) {
        Rectangle rectangle = new Rectangle();
        rectangle.setWidth(20);
        rectangle.setHeight(10);
        resize(rectangle);
        print(rectangle);
        System.out.println("=======================");
        Rectangle square = new Square();
        square.setWidth(10);
        // 因为 Square类 重写了父类 setWidth setHeight 方法,会导致 while 循环变成一个无限循环
        resize(square);
        print(square);
    }
    public static void resize(Rectangle rectangle){
        while (rectangle.getWidth() >= rectangle.getHeight()){
            rectangle.setHeight(rectangle.getHeight() + 1);
        }
    }

    public static void print(Rectangle rectangle){
        System.out.println(rectangle.getWidth());
        System.out.println(rectangle.getHeight());
    }
}

// 正方形
class Square  extends Rectangle{

    @Override
    public void setWidth(Integer width) {
        super.setWidth(width);
        super.setHeight(width);
    }

    @Override
    public void setHeight(Integer height) {
        super.setWidth(height);
        super.setHeight(height);
    }
}

// 长方形
class Rectangle {
    private Integer width;
    private Integer height;

    public void setWidth(Integer width) {
        this.width = width;
    }

    public void setHeight(Integer height) {
        this.height = height;
    }

    public Integer getWidth() {
        return width;
    }

    public Integer getHeight() {
        return height;
    }
}

合成复用原则

在面向对象设计中,可以通过两种方法在不同的环境中复用已有的设计和实现,即通过组合/聚合关系或通过继承。首先应该考虑使用组合/聚合,因为组合/聚合可以使系统更加灵活,降低类与类之间的耦合度,一个类的变化对其他类造成的影响相对较少;其次才考虑继承,在使用继承时,需要严格遵循里氏代换原则,有效使用继承会有助于对问题的理解,降低复杂度,而滥用继承反而会增加系统构建和维护的难度以及系统的复杂度,因此需要慎重使用继承复用。

尽量使用对象组合,而不是继承来达到复用的目的。

java 复制代码
// 引擎接口
interface Engine {
    void start();
}

// 电动引擎
class ElectricEngine implements Engine {
    @Override
    public void start() {
        System.out.println("Electric engine starts...");
    }
}

// 燃油引擎
class GasEngine implements Engine {
    @Override
    public void start() {
        System.out.println("Gas engine starts...");
    }
}

// 汽车类
class Car {
    private Engine engine;

    public Car(Engine engine) {
        this.engine = engine;
    }

    public void start() {
        engine.start();
        System.out.println("Car starts...");
    }
}

public class Main {
    public static void main(String[] args) {
        // 创建一个电动引擎汽车
        Engine electricEngine = new ElectricEngine();
        Car electricCar = new Car(electricEngine);
        electricCar.start();

        System.out.println("=======================");

        // 创建一个燃油引擎汽车
        Engine gasEngine = new GasEngine();
        Car gasCar = new Car(gasEngine);
        gasCar.start();
    }
}

迪米特法则

迪米特法则又叫最少知识原则,就是说一个对象应当对其他对象有尽可能少的了解。其核心思想为,降低类之间的耦合。如果类与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大,所以一个对象应该对其他对象有最少的了解。通俗地讲,一个类应该对自己需要耦合或调用的类知道得最少,被耦合或调用的类的内部是如何复杂都和我没关系,那是你的事情,我就知道你提供的public方法,我就调用这么多,其他的一概不关心。迪米特法则其根本思想,是强调了类之间的松耦合。类之间的耦合越弱,越有利于复用,一个处在弱耦合的类被修改,不会对有关系的类造成搏击,也就是说,信息的隐藏促进了软件的复用。

迪米特法则还有个更简单的定义,只与直接的朋友交谈,不跟"陌生人"说话。

朋友定义:每个对象都会与其他对象有耦合关系,只要两个对象之间有耦合关系,我们就说这两个对象之间是朋友关系。 耦合的方式很多:依赖、关联、组合、聚合等。其中,我们称出现成员变量、方法参数、方法返回值中的类为直接的朋友,而出现在局部变量中的类不是直接的朋友。 也就是说,陌生的类最好不要以局部变量的形式出现在类的内部。

java 复制代码
// 学生类
class Student {
    private String name;
    private Class myClass;

    public Student(String name, Class myClass) {
        this.name = name;
        this.myClass = myClass;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    // 查询自己的班级信息
    public void queryClassInfo() {
        String className = myClass.getClassName();
        System.out.println(name + " is in class " + className);
    }
}

// 班级类
class Class {
    private String className;

    public Class(String className) {
        this.className = className;
    }

    public String getClassName() {
        return className;
    }
}

// 老师类
class Teacher {
    private String name;

    public Teacher(String name) {
        this.name = name;
    }

    // 不符合迪米特法则的方法示例,直接返回班级名
    // public String getStudentClass(Student student) {
    //     return student.myClass.getClassName();
    // }

    // 通过学生对象调用公共方法获取班级信息
    public String getStudentClass(Student student) {
        return student.queryClassInfo();
    }
}

public class Main {
    public static void main(String[] args) {
        // 创建班级
        Class class1 = new Class("Class 1");

        // 创建学生并设置班级
        Student student = new Student("Alice", class1);

        // 学生查询自己的班级信息
        student.queryClassInfo();

        System.out.println("=======================");

        // 创建老师并查询学生的班级信息
        Teacher teacher = new Teacher("Mr. Smith");
        String studentClass = teacher.getStudentClass(student);
        System.out.println(student.getName() + " is in class " + studentClass);
    }
}

面向对象三大特性

面向对象编程具有三大基本特性,它们是:封装、继承、多态。这些特性是面向对象编程语言如Java、Python等的基础。面向对象编程的三大特性共同作用,使得程序设计更加灵活、可扩展和易于维护。封装提供了数据的安全性和访问控制,继承实现了代码的重用和层次化设计,多态增加了代码的适应性和灵活性。这些特性共同构成了面向对象编程范式的核心,是现代软件开发中广泛应用的重要基础。

封装

封装是面向对象方法的重要原则,就是把对象的属性和操作(或服务)结合为一个独立的整体,并尽可能隐藏对象的内部实现细节。简单的说,一个类就是一个封装了数据以及操作这些数据的代码的逻辑实体。在一个对象内部,某些代码或某些数据可以是私有的,不能被外界访问。通过这种方式,对象对内部数据提供了不同级别的保护,以防止程序中无关的部分意外的改变或错误的使用了对象的私有部分。

良好的封装能够减少耦合,提高了可维护性和灵活性以及可重用性,允许类内部结构自由修改,并对成员变量进行精确控制,同时有效隐藏信息和实现细节。封装的目的是增强安全性和简化编程,使用者不必了解具体的实现细节,而只是要通过外部接口以特定的访问权限来使用类的成员。其中包括privateprotectedpublic三个访问权限修饰符,如果不加修饰符,则表示包级可见(default)。

修饰符 当前类 同一包下 其他包的子类 不同包的子类 其他包
public Y Y Y Y Y
protected Y Y Y Y/N N
default Y Y Y N N
private Y N N N N

这四种访问权限的控制符能够控制类中成员的可见性,当然需要满足在不使用Java反射的情况下。

  • private:仅在定义它们的类内部可见。
  • protected:同一个包内的类和该类的子类可以访问。
  • default:包级可见性,同一个包内的类可以访问。
  • public:任何类都可以访问。

设计良好的模块会隐藏所有的实现细节,把它的API与它的实现清晰地隔离开来。模块之间只通过它们的API进行通信,一个模块不需要知道其他模块的内部工作情况,这个概念被称为信息隐藏或封装。因此访问权限应当尽可能地使每个类或者成员不被外界访问。如果子类的方法重写了父类的方法,那么子类中该方法的访问级别不允许低于父类的访问级别。这是为了确保可以使用父类实例的地方都可以使用子类实例,也就是确保满足里氏替换原则。

某个类的字段决不能是公有的,因为这么做的话就失去了对这个字段修改行为的控制,其他类可以对其随意修改。下面的例子中,AccessExample拥有id公有字段,如果在某个时刻,我们想要使用int存储id字段,那么就需要修改所有类中的代码。

java 复制代码
public class AccessExample {
    public String id;
    // public int id;
}

可以使用公有的gettersetter方法来替换公有字段,这样的话就可以控制对字段的修改行为,实现了封装。

java 复制代码
public class AccessExample {

    private int id;

    public String getId() {
        return id + "";
    }

    public void setId(String id) {
        this.id = Integer.valueOf(id);
    }
}

但是也有例外,如果是包级私有的类或者私有的嵌套类,那么直接暴露成员不会有特别大的影响。

java 复制代码
public class AccessWithInnerClassExample {

    private class InnerClass {
        int x;
    }

    private InnerClass innerClass;

    public AccessWithInnerClassExample() {
        innerClass = new InnerClass();
    }

    public int getValue() {
        return innerClass.x;  // 直接访问
    }
}

继承

继承可以使用父类的所有功能,并在无需重新编写原来类的情况下对这些功能进行扩展。 通过继承创建的新类称为"子类"或"派生类",被继承的类称为"基类"、"父类"或"超类"。继承的过程,就是从一般到特殊的过程。继承概念的实现方式有两种,实现继承接口继承。实现继承是指直接使用基类的属性和方法而无需额外编码的能力;接口继承是指仅使用属性和方法的名称、但是子类必须提供实现的能力。

  • 实现继承:如果多个类的某个部分的功能相同,那么可以抽象出一个类来,把相同的部分放到父类中,让他们继承这个类;
  • 接口继承:如果多个类处理的目标是一样的,但是处理的方法方式不同,那么就定义一个接口,也就是一个标准,让他们的实现这个接口,各自实现自己具体的处理方法来处理那个目标;

继承的根本原因是因为要复用,而实现的根本原因是需要定义一个标准。

继承与组合

继承是实现复用代码的重要手段,但是继承会破坏封装;组合也是代码复用的重要方式,可以提供良好的封装性。

特性 组合 继承
优点 不破坏封装,整体类与局部类之间松耦合,彼此相对独立;具有较好的可扩展性;支持动态组合,运行时可以选择不同类型的局部对象;整体类可以对局部类进行包装,提供新的接口。 子类能自动继承父类的接口;支持扩展父类的功能;创建子类对象时无需创建父类对象。
缺点 整体类不能自动获得和局部类同样的接口;创建整体类对象时需要创建所有局部类的对象。 破坏封装,子类与父类之间紧密耦合,子类依赖于父类的实现,缺乏独立性;增加系统结构的复杂度;不支持动态继承,子类无法选择不同的父类。

在设计中,通常会同时使用继承和组合来实现代码的复用和关系建模,具体根据需求和设计原则来选择合适的关系模型。组合比继承更加灵活,所以在写代码如果这个功能组合和继承都能够完成,那么应该优先选择组合,但是继承在一些场景还是要优先于组合的:

  • 继承要慎用,其使用场合仅限于你确信使用该技术有效的情况。一个判断方法是,问一问自己是否需要从新类向基类进行向上转型。如果是必须的,则继承是必要的。反之则应该好好考虑是否需要继承。
  • 只有当子类真正是超类的子类型时,才适合用继承。换句话说,对于两个类A和B,只有当两者之间确实存在is-a关系的时候,类B才应该继承类A。

通过下列代码可以发现,子类可以访问父类的成员变量方法,并且通过重写可以改变父类方法实现从而破坏了封装性。

java 复制代码
// 继承
public class MainTest {

    public static void main(String[] args) {
        B b = new B();
        b.test();
    }
}

class A {

    protected int i;

    protected void test() {
        System.out.println("I am super class ... ");
    }

}

class B  extends A{

    // 调用父类成员 
    public void t() {
        System.out.println(super.i);
    }

}

在继承结构中,父类的内部细节对于子类是可见的。所以我们通常也可以说通过继承的代码复用是一种白盒式代码复用。如果基类的实现发生改变,那么派生类的实现也将随之改变,这样就导致了子类行为的不可预知性。

为了保证父类有良好的封装性,不会对子类随意更改,设计父类时应遵循以下原则:

  • 尽量隐藏父类的内部数据.尽量把所有父类的所有成员变量都用private修饰,不要让子类直接访问父类的成员;
  • 不要让子类随意的修改访问父类的方法,父类中那些仅为辅助其他的工具方法,应该使用private修饰,让子类无法访问该方法。如果父类中的方法需要被外部类调用,则需以public修饰,但又不希望重写父类方法可以使用final来修饰方法;但如果希望父类某个方法被重写,但又不希望其他类访问自由,可以使用protected修饰;
  • 尽量不要在父类构造器中调用将要被子类重写的方法;

super关键字通常用在继承的子类中,通常有以下作用:

  • 访问父类的构造函数:可以使用super函数访问父类的构造函数,从而委托父类完成一些初始化的工作。 应该注意到,子类一定会调用父类的构造函数来完成初始化工作,一般是调用父类的默认构造函数,如果子类需要调用父类其它构造函数,那么就可以使用super函数。

    java 复制代码
    class Parent {
        Parent() {
            System.out.println("Parent constructor");
        }
    }
    
    class Child extends Parent {
        Child() {
            super(); // 调用父类构造方法
            System.out.println("Child constructor");
        }
    }
  • 访问父类的成员:如果子类重写了父类的某个方法,可以通过使用super 关键字来引用父类的方法实现。

    java 复制代码
    class Parent {
        String name = "Parent";
    }
    
    class Child extends Parent {
        String name = "Child";
    
        void displayName() {
            System.out.println("Child's name: " + name);     // 输出 Child
            System.out.println("Parent's name: " + super.name); // 输出 Parent
        }
    }
  • 在子类中调用父类的方法:这对于子类重写父类方法时,希望在子类方法中调用父类方法的场景非常有用。

    java 复制代码
    class Parent {
        void display() {
            System.out.println("Parent's display method");
        }
    }
    
    class Child extends Parent {
        @Override
        void display() {
            super.display(); // 调用父类的display方法
            System.out.println("Child's display method");
        }
    }

继承是类与类或者接口与接口之间最常见的关系,继承是一种is-a的关系。而组合强调的是整体与部分、拥有的关系,即has-a的关系。组合是把旧类对象作为新类对象的成员变量组合进来,用以实现新类的功能。

java 复制代码
public class MainTest {

    public static void main(String[] args) {
        B b = new B(new A());
        b.test();
    }
}

class A {

    protected int i;

    protected void test() {
        System.out.println("I am super class ... ");
    }

}

class B {

    private final A a;

    public B(A a) {
        this.a = a;
    }
    public void test() {
        // 复用 A 类提供的 test 方法
        a.test();
    }
}

组合是通过对现有的对象进行拼装产生新的、更复杂的功能。因为在对象之间,各自的内部细节是不可见的,所以我们也说这种方式的代码复用是黑盒式代码复用。因为组合中一般都定义一个类型,所以在编译期根本不知道具体会调用哪个实现类的方法。

抽象类与接口

抽象类和接口也是Java继承体系中的重要组成部分。抽象类是用来捕捉子类的通用特性的,而接口则是抽象方法的集合;抽象类不能被实例化,只能被用作子类的超类,是被用来创建继承层级里子类的模板,而接口只是一种形式,自身不能做任何事情。

抽象类和抽象方法都使用abstract关键字进行声明。如果一个类中包含抽象方法,那么这个类必须声明为抽象类。抽象类和普通类最大的区别是,抽象类不能被实例化,需要继承抽象类才能实例化其子类。

java 复制代码
public abstract class AbstractClassExample {

    protected int x;
    private int y;

    public abstract void func1();

    public void func2() {
        System.out.println("func2");
    }
}
public class AbstractExtendClassExample extends AbstractClassExample {
    @Override
    public void func1() {
        System.out.println("func1");
    }
}

// 实例化抽象类
// AbstractClassExample ac1 = new AbstractClassExample(); 
// 实例化抽象类子类
// AbstractClassExample ac2 = new AbstractExtendClassExample();
// ac2.func1();

接口是抽象类的延伸,在Java8之前,它可以看成是一个完全抽象的类,也就是说它不能有任何的方法实现。但从Java8 开始,接口也可以拥有默认的方法实现,这是因为不支持默认方法的接口的维护成本太高了。在Java8之前,如果一个接口想要添加新的方法,那么要修改所有实现了该接口的类,现在不用修改所有实现该接口的类。

接口中的成员(字段、方法)默认都是public的,并且不允许定义为private或者 protected。接口的字段默认都是用staticfinal修饰的。

java 复制代码
public interface InterfaceExample {

    void func1();

    default void func2(){
        System.out.println("func2");
    }

    int x = 123;
    // int y;               // Variable 'y' might not have been initialized
    public int z = 0;       // Modifier 'public' is redundant for interface fields
    // private int k = 0;   // Modifier 'private' not allowed here
    // protected int l = 0; // Modifier 'protected' not allowed here
    // private void fun3(); // Modifier 'private' not allowed here
}
public class InterfaceImplementExample implements InterfaceExample {
    @Override
    public void func1() {
        System.out.println("func1");
    }
}

Java的接口可以多继承:

java 复制代码
interface Action extends Serializable,AutoCloseable {
	// to do ...
}

从设计层面上看,抽象类提供了一种is-a关系,那么就必须满足里式替换原则,即子类对象必须能够替换掉所有父类对象。而接口更像是一种 like-a 关系,它只是提供一种方法实现契约,并不要求接口和实现接口的类具有 is-a 关系。抽象类是一种自下而上的思想,而接口是一种自上而下的思想。抽象类是将多个类的公共特点聚合到同一个类中,然后实现父类的方法;接口更像是对类的一种约束,其他类调用实现某接口的类。

从语法角度来看,一个类可以实现多个接口,但是不能继承多个抽象类。接口的字段只能是staticfinal类型的,而抽象类的字段没有这种限制,接口的成员只能是public的,而抽象类的成员可以有多种访问权限。

接口适合的情况包括需要让不相关的类都实现相同方法,例如实现Comparable接口中的compareTo方法,以及需要实现类似多重继承的效果。抽象类则更适合于多个相关类需要共享代码逻辑,或者需要控制继承来的成员访问权限,以及需要继承非静态和非常量字段的场景。选择使用接口或抽象类取决于设计需求和代码结构的具体情况。

在很多情况下,接口优先于抽象类。因为接口没有抽象类严格的类层次结构要求,可以灵活地为一个类添加行为。并且从Java8开始,接口也可以有默认的方法实现,使得修改接口的成本也变的很低。

多态

多态是面向对象编程中的一个重要概念,它允许对象以多种形式出现。在Java中,多态主要通过继承和接口来实现。多态性使得一个对象可以被看作是其本身的类型,也可以被看作是其父类或接口的类型。多态存在的前提,有类继承或者接口实现、子类要重写父类的方法、父类的引用指向子类的对象,如:Parent p = new Child();

java 复制代码
class Parent {

    void contextLoads(){
        System.out.println("i am Parent ... ");
    }
}
class Child  extends Parent {
    @Override
    void contextLoads(){
        System.out.println("i am Child ... ");
    }
}
class mainTest{
    public static void main(String[] args) {
        Parent child = new Child();
        // i am Child ... 
        child.contextLoads();
    }
}

通过多态,可以将相似的操作封装在父类或接口中,子类只需要实现或重写这些操作,从而减少代码的重复。多态使得程序更加灵活和可扩展,可以在不修改现有代码的情况下增加新的功能。通过多态,可以用接口来定义一组可互换的操作,使得代码更具通用性。但多态不能使用子类特有的方法和属性,在编写代码期间使用多态调用方法或属性时,编译工具首先会检查父类中是否有该方法和属性,如果没有则会编译报错。

java 复制代码
class Parent {
    void contextLoads(){
        System.out.println("i am Parent ... ");
    }
}
class Child  extends Parent {

    String  c = "child";

    @Override
    void contextLoads(){
        System.out.println("i am Child ... ");
    }
    void test() {
        System.out.println("i am test method ...");
    }
}
class mainTest{
    public static void main(String[] args) {
        Parent child = new Child();
        // 编译报错: 无法解析 'Parent' 中的方法 'test'
        child.test();
        // 编译报错: 不能解决符号 'c'
        child.c;
    }
}

重写与重载

多态分为两种主要类型:静态绑定(编译时多态)和动态绑定(运行时多态)。

动态绑定发生在运行时,主要通过方法重写实现,我们通常所说的多态就是指这个。重写存在于继承体系中,指子类实现了一个与父类在方法声明上完全相同的一个方法。动态绑定的关键在于:父类的引用可以指向子类的对象,方法调用在运行时根据实际对象类型来决定执行哪个方法。

为了满足里式替换原则,重写有以下三个限制,使用@Override注解,可以让编译器帮忙检查是否满足这三个限制条件。

  • 子类方法的访问权限必须大于等于父类方法;
  • 子类方法的返回类型必须是父类方法返回类型或为其子类型;
  • 子类方法抛出的异常类型必须是父类抛出异常类型或为其子类型;
java 复制代码
class Animal {
    void makeSound() {
        System.out.println("Animal makes a sound");
    }
}

class Dog extends Animal {
    @Override
    void makeSound() {
        System.out.println("Dog barks");
    }
}

class Cat extends Animal {
    @Override
    void makeSound() {
        System.out.println("Cat meows");
    }
}

public class TestDynamicBinding {
    public static void main(String[] args) {
        Animal myDog = new Dog();
        Animal myCat = new Cat();

        myDog.makeSound(); // 输出 "Dog barks"
        myCat.makeSound(); // 输出 "Cat meows"
    }
}

静态绑定发生在编译时,主要通过方法重载实现。重载是在一个类里面,方法名字相同,但参数类型、个数、顺序至少有一个不同,返回类型可以相同也可以不同。应该注意的是,只有返回值不同,其它都相同不算是重载。编译器在编译时决定调用哪一个方法。每个重载的方法都必须有一个独一无二的参数类型列表。最常用的地方就是构造器的重载。

重载规则:

  • 被重载的方法必须改变参数列表(参数个数或类型不一样);
  • 被重载的方法可以改变返回类型;
  • 被重载的方法可以改变访问修饰符;
  • 被重载的方法可以声明新的或更广的检查异常;
  • 方法能够在同一个类中或者在一个子类中被重载;
  • 无法以返回值类型作为重载函数的区分标准;
java 复制代码
public class Overloading {
    public int test(){
        System.out.println("test1");
        return 1;
    }
 
    public void test(int a){
        System.out.println("test2");
    }   
 
    //以下两个参数类型顺序不同
    public String test(int a,String s){
        System.out.println("test3");
        return "returntest3";
    }   
 
    public String test(String s,int a){
        System.out.println("test4");
        return "returntest4";
    }   
 
    public static void main(String[] args){
        Overloading o = new Overloading();
        System.out.println(o.test());
        o.test(1);
        System.out.println(o.test(1,"test3"));
        System.out.println(o.test("test4",1));
    }
}

方法的重写和重载是Java多态性的不同表现,重写是父类与子类之间多态性的一种表现,重载可以理解成多态的具体表现形式。方法重载是一个类中定义了多个方法名相同,而他们的参数的数量不同或数量相同而类型和次序不同,则称为方法的重载。方法重写是在子类存在方法与父类的方法的名字相同,而且参数的个数与类型一样,返回值也一样的方法,就称为重写。方法重载是一个类的多态性的表现,而方法重写是子类与父类的一种多态性表现。

相关推荐
無限進步D3 小时前
Java 运行原理
java·开发语言·入门
難釋懷3 小时前
安装Canal
java
是苏浙3 小时前
JDK17新增特性
java·开发语言
不光头强3 小时前
spring cloud知识总结
后端·spring·spring cloud
GetcharZp7 小时前
告别 Python 依赖!用 LangChainGo 打造高性能大模型应用,Go 程序员必看!
后端
阿里加多7 小时前
第 4 章:Go 线程模型——GMP 深度解析
java·开发语言·后端·golang
likerhood7 小时前
java中`==`和`.equals()`区别
java·开发语言·python
小小李程序员7 小时前
Langchain4j工具调用获取不到ThreadLocal
java·后端·ai