面向对象编程(OOP) 是一个将现实世界抽象为一系列对象的编程范式,这些对象通过消息传递机制来互相交流和协作。

OOP 的主要特性包括四个基本概念:封装(Encapsulation)、继承(Inheritance)、多态(Polymorphism)以及抽象(Abstraction)。

封装(Encapsulation)

封装是一种将数据(属性)和行为(方法)绑定在一起的方法。

通过封装,可以隐藏对象的具体实现细节,仅暴露出有限的接口供外界访问。

优势

封装的优势:

  • 增强安全性:隐藏内部实现细节,防止外部直接访问对象内部的数据,减少因误用导致的错误

这里我编写了一个 UserCredentials 类,来进行演示一下 增强安全性,分别体现在什么地方,代码如下:

/**
 * @author BNTang
 * @description 用户凭证类
 */
public class UserCredentials {
    // 私有属性,外部无法直接访问
    private String username;
    private String password;

    // 公有的构造函数,用于初始化用户名和密码
    public UserCredentials(String username, String password) {
        this.username = username;
        this.password = password;
    }

    // 公有的方法,用于验证密码是否正确
    public boolean authenticate(String inputPassword) {
        return inputPassword != null && inputPassword.equals(this.password);
    }

    // 获得用户名的公有方法
    public String getUsername() {
        return this.username;
    }

    // 重置密码的方法,增加安全性校验
    public void resetPassword(String oldPassword, String newPassword) {
        if (authenticate(oldPassword)) {
            this.password = newPassword;
            System.out.println("密码重置成功。");
        } else {
            System.out.println("旧密码不正确,密码重置失败。");
        }
    }

    // 私有的设置密码方法,外部无法访问
    private void setPassword(String password) {
        this.password = password;
    }
}

在我提供的 UserCredentials 类的代码中,隐藏内部实现细节、防止外部直接访问对象内部的数据以及减少因误用导致的错误的概念都得到了实现。

  1. 隐藏内部实现细节
private void setPassword(String password) {
    this.password = password;
}

setPassword 方法是私有的 (private),意味着它只能在类内部被调用。外部代码不能直接调用此方法来设置密码,这正是隐藏内部实现细节的体现。

  1. 防止外部直接访问对象内部的数据
private String username;
private String password;

这段代码中,用户名 (username) 和密码 (password) 被声明为私有变量 (private),这意味着它们不能从类的外部直接访问,只能通过类提供的公有方法(如构造方法、getUsernameauthenticateresetPassword 方法等)来间接访问或修改。这种机制有效地保护了类的内部数据。

  1. 减少因误用导致的错误
public void resetPassword(String oldPassword, String newPassword) {
    if (authenticate(oldPassword)) {
        this.password = newPassword;
        System.out.println("密码重置成功。");
    } else {
        System.out.println("旧密码不正确,密码重置失败。");
    }
}

resetPassword 方法中,通过 authenticate 方法校验旧密码是否正确,只有在旧密码正确的情况下才允许用户设置新密码。这样的设计减少了因为外部代码错误使用(如直接设置密码而不进行旧密码验证)导致的安全问题,同时也确保了类内部数据的完整性和安全性。

  • 提高复用性:封装后的对象可以作为一个黑盒被重复使用,无需关心对象内部的复杂逻辑
  1. 封装后的对象作为一个黑盒被重复使用体现在:
UserCredentials adminCredentials = new UserCredentials("admin", "adminPass");
UserCredentials userCredentials = new UserCredentials("user", "userPass");

// 在不同场景中重复使用对象:
if (adminCredentials.authenticate("adminPass")) {
    // 执行管理员操作
}

if (userCredentials.authenticate("userPass")) {
    // 执行用户操作
}

adminCredentialsuserCredentialsUserCredentials 的实例,在创建它们之后可以多次使用其 authenticate 方法来验证密码,这里的实例就像是提供认证功能的黑盒,使用者不必关心里面的逻辑是怎样的。

  1. 无需关心对象内部的复杂逻辑体现在
private String username;
private String password;

private void setPassword(String password) {
    this.password = password;
}

由于 usernamepassword 属性被声明为私有的,外部代码不能直接访问或修改它们。设置密码的逻辑被隐藏在 setPassword 方法中,而这个方法也是私有的。外部代码需要通过公有方法如构造函数或 resetPassword 这些公有接口进行操作,因此外部代码不必关心如何存储或验证密码的内部逻辑,只需调用这些公有方法即可实现功能。

  • 易于维护:封装的代码更易理解与修改,修改内部实现时不会影响到使用该对象的代码
  1. 封装的代码更易理解与修改体现在
public boolean authenticate(String inputPassword) {
    return inputPassword != null && inputPassword.equals(this.password);
}

public void resetPassword(String oldPassword, String newPassword) {
    if (authenticate(oldPassword)) {
        this.password = newPassword;
        System.out.println("密码重置成功。");
    } else {
        System.out.println("旧密码不正确,密码重置失败。");
    }
}

authenticateresetPassword 这两个公有方法中,封装的代码很易于理解:一个用于验证密码,一个用于重新设置密码。如果我们需要修改密码的存储逻辑,只需修改这些方法的内部逻辑,而无需修改方法的签名或其他使用这些方法的代码。

  1. 修改内部实现时不会影响到使用该对象的代码体现在
private String username;
private String password;

因为 usernamepassword 是私有属性,所以它们对外部代码是不可见和不可访问的。我们可以在不改变任何使用 UserCredentials 对象的代码的情况下,自由改变这些属性的内部表示方法(比如对密码进行加密存储)。因为任何这样的改变都会被 UserCredentials 类的公共接口所封装和抽象化,从而不会泄露出去或者影响到依赖于这些公共接口的代码。

  • 接口与实现分离:提供清晰的接口,使得对象之间的依赖关系只基于接口,降低了耦合度
  1. 提供清晰的接口体现在
public boolean authenticate(String inputPassword);
public void resetPassword(String oldPassword, String newPassword);
public String getUsername();

这些公共方法形成了 UserCredentials 类的接口,它为外部代码提供了清晰的通信协议,明确了可以进行的操作。使用这个类的代码只需要知道这些方法的声明和预期行为,不需要了解它们背后的具体实现。

  1. 使得对象之间的依赖关系只基于接口体现在
UserCredentials credentials = new UserCredentials("username", "password");
boolean valid = credentials.authenticate("password");

只要 authenticate 方法的接口保持不变,外部代码就可以正常工作,完全无须关心 UserCredentials 内部是如何处理认证逻辑的。

  1. 降低了耦合度体现在
private void setPassword(String password) {
    // 假设这里改用了一种新的加密方式来设置密码
    this.password = encryptPassword(password);
}

即使改变了 setPassword 方法的内部实现(如加密),由于这个方法是私有的,外部代码不会受到影响。这种隔离提高了系统的模块化,使得各个部分可以独立变化而不互相干扰,从而降低了耦合度。

  • 隐藏实现细节,简化接口:用户只需知道对象公开的方法,不必了解其内部的复杂过程

应用场景

封装的应用场景:

  • 类的设计:在类定义时,通常将属性私有化(private),通过公共的方法(public methods)来访问和修改这些属性
  • 模块化组件:在设计模块化的系统时,每个组件都通过封装来定义自己的行为和接口,使得系统更易于组合和扩展
  • 库和框架的开发:开发者提供库和框架时,会通过封装隐藏复杂逻辑,只暴露简洁的 API 接口给其他开发者使用
  • 隔离变化:将可能变化的部分封装起来,变化发生时,只需修改封装层内部,不影响外部使用

通过封装,能够构建出结构清晰、易于管理和维护的代码。

完整代码可在此查阅:GitHub

继承(Inheritance)

继承是一种能够让新创建的类(子类或派生类)接收另一个类(父类或基类)的属性和方法的机制。

在 Java 中,继承是通过使用 extends 关键字来实现的。从理论上解释一下,然后再通过代码示例来加深理解。

IS-A 关系

IS-A 是一种表达类之间关系的方式,主要用来表明一个实体(子类)是另一个实体(父类)的一种特殊类型。例如,Cat(猫)是 Animal(动物)的一种特殊类型。因此,可以说 Cat IS-A Animal。

里氏替换原则(Liskov Substitution Principle)

这是一个面向对象设计的原则,它表明如果 S 是 T 的一个子类型(在 Java 中意味着 S 类继承自 T 类),那么任何期望 T 类的对象的地方都可以用 S 类的对象来替换,而不会影响程序的行为。

向上转型(Upcasting)

向上转型是指子类类型的引用自动转换成父类类型。向上转型在多态中是常见的,它允许将子类的对象赋值给父类的引用。例如,可以将 Cat 类型的对象赋值给 Animal 类型的引用。

以代码形式展示上述概念:

/**
 * 动物
 *
 * @author BNTang
 * @date 2024/03/10 09:36:41
 * @description 创建一个表示动物的基类(父类)
 */
class Animal {
    // 动物类有一个叫的方法
    public void makeSound() {
        System.out.println("动物发出声音");
    }
}

// 创建一个 Cat 类(子类),继承自 Animal 类
class Cat extends Animal {
    // 重写父类的 makeSound 方法
    @Override
    public void makeSound() {
        // 这里的调用体现了多态性,即 Cat 的叫声不同于一般 Animal
        System.out.println("猫咪喵喵叫");
    }
}

public class InheritanceExample {
    public static void main(String[] args) {
        // Upcasting: 将 Cat 对象向上转型为 Animal 类型
        Animal myAnimal = new Cat();
        // 虽然 myAnimal 在编译时是 Animal 类型,但实际执行的是 Cat 的 makeSound 方法
        myAnimal.makeSound();

        // 创建一个 Animal 类型的对象,调用 makeSound 方法
        Animal anotherAnimal = new Animal();
        anotherAnimal.makeSound();

        // 这里可以看到,Cat 对象(myAnimal)能够替换 Animal 对象(anotherAnimal)的位置,
        // 并且程序的行为没有发生错误,体现了里氏替换原则
    }
}

定义了两个类:Animal 和 Cat。

Cat 类继承自 Animal 类,并重写了 makeSound 方法。在 main 方法中,创建了一个 Cat 对象,并将其向上转型为 Animal 类型的引用 myAnimal。调用 myAnimal 的 makeSound 方法时,会执行 Cat 类的重写方法而不是 Animal 类的方法,这就体现了多态性和里氏替换原则。同时,Cat 对象(向上转型后的 myAnimal)可以在任何需要 Animal 对象的地方使用,这也满足了 IS-A 关系的定义

完整代码可在此查阅:GitHub

多态(Polymorphism)

多态可以允许使用一个统一的接口来操作不同的底层数据类型或对象。多态分为 编译时 多态和 运行时 多态两种类型。
编译时多态(方法的重载),也被称为静态多态,主要是通过 方法重载(Method Overloading)来实现的。方法重载指的是在同一个类中存在多个同名的方法,但这些方法的参数列表不同(参数数量或类型不同)。

编译器根据方法被调用时传入的参数类型和数量,来决定具体调用哪个方法。这种决策是在编译时做出的,因此称为编译时多态。

方法重载

代码示例:

/**
 * 打印机类
 * 用于演示方法重载
 *
 * @author BNTang
 */
class Printer {
    /**
     * 打印字符串
     *
     * @param content 要打印的字符串
     */
    public void print(String content) {
        System.out.println("打印字符串: " + content);
    }

    /**
     * 重载 print 方法,参数类型为 int,与打印字符串的方法区分开来
     *
     * @param number 要打印的数字
     */
    public void print(int number) {
        System.out.println("打印数字: " + number);
    }
}

public class OverloadingExample {
    public static void main(String[] args) {
        Printer printer = new Printer();

        // 调用 print 方法打印字符串
        printer.print("Hello, World!");

        // 调用重载的 print 方法打印数字
        printer.print(12345);

        // 编译器根据参数类型来决定调用哪个方法
    }
}

运行时多态,也被称为动态多态或动态绑定,是通过 方法覆盖(Method Overriding)实现的。

运行时多态是在继承的基础上工作的,所以只要其中子类覆盖父类的方法。

运行时多态的决策是在程序执行期间进行的,即虚拟机在运行时刻根据对象的实际类型来确定调用哪个类中的方法。

方法覆盖

代码示例:

/**
 * 动物
 * 创建一个表示动物的基类(父类)
 *
 * @author BNTang
 */
class Animal {
    public void makeSound() {
        System.out.println("动物发出声音");
    }
}

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

public class PolymorphismExample {
    public static void main(String[] args) {
        // 向上转型
        Animal animal = new Dog();

        // 运行时多态,调用的是 Dog 类的 makeSound 方法
        animal.makeSound();
    }
}

虽然在编译时 animal 的类型是 Animal,但是在运行时 JVM 会调用实际对象类型(也就是 Dog)的 makeSound 方法,因此输出的将是 “汪汪汪”,而不是 “动物发出声音”。这就是运行时多态的体现。

运行时多态的三个条件

  1. 继承:子类需要继承父类
  2. 方法覆盖:子类需要提供一个具体的实现,这个实现覆盖了父类的方法
  3. 向上转型:你可以将子类类型的引用转换为父类类型的引用(即将子类对象赋值给父类引用),之后通过这个父类引用来调用方法时,执行的将是子类的覆盖实现

利用多态写出可扩展性和可维护性更佳的代码,能够应对不断变化的需求。使得可以通过相同的接口来调用不同类的实现,提供了软件设计的灵活性。

完整代码可在此查阅:GitHub

抽象(Abstraction)