跳至主要内容

Java 面向对象编程(OOP):类、对象、封装、继承与抽象

通过实践示例学习 Java 的面向对象编程。借助餐厅菜单系统掌握类、对象、继承、封装与抽象类。
· 15分钟 阅读

使用 AI 探索

Java 始终位列全球最受欢迎的三大编程语言之一。它在企业软件开发、Android 移动应用以及大规模 Web 应用等领域的采用无出其右。其强大的类型系统、庞大的生态以及“一次编写,到处运行”的能力,使其在构建健壮、可扩展系统方面尤为吸引人。本文将探讨 Java 的面向对象编程特性如何帮助开发者有效发挥这些优势,通过合理的代码组织与复用,构建可维护、可扩展的应用程序。

关于组织与运行 Java 代码的说明

在开始写代码之前,先进行一些准备工作。

与语法一样,Java 对代码组织也有严格规定。

首先,每个 public 类必须位于其各自的文件中,文件名与类名完全一致,并以 .java 为扩展名。因此,如果我要写一个 Laptop 类,文件名必须是 Laptop.java—区分大小写。您可以在同一文件中包含非 public 的类,但最好将它们分开。我知道我们有点超前——在编写类之前就讨论类的组织——但事先大致了解各部分应放在哪里是个好主意。

所有 Java 项目都必须有一个包含 Main 类的 Main.java 文件。这里是通过创建对象来测试各个类的地方。

要运行 Java 代码,我们将使用 IntelliJ IDEA,这是一款流行的 Java IDE。安装 IntelliJ 后:

  1. 创建一个新的 Java 项目(File > New > Project)
  2. 右键单击 src 文件夹以创建 Main.java 文件,并粘贴以下内容:
public class Main {
   public static void main(String[] args) {
       // Create and tests objects here
      
   }
}

每当我们讨论类时,代码写在除 Main.java 之外的文件里。但如果我们讨论创建和测试对象,就切换到 Main.java。

要运行程序,您可以单击 main 方法旁边的绿色播放按钮:

IntelliJ IDEA 中 Java 编辑器窗口的屏幕截图

输出将显示在底部的 Run 工具窗口中。

如果您完全是 Java 新手,请先查看我们的 Introduction to Java 课程,在继续之前了解 Java 的数据类型和控制流基础。

否则,我们直接开始。

Java 类与对象

那么,类究竟是什么?

类是 Java 中用于表示现实世界概念的编程构造。举例来说,考虑这个 MenuItem 类(在 IDE 中创建文件来编写该类):

public class MenuItem {
   public String name;
   public double price;
}

该类为我们提供了一个蓝图或模板,用于表示餐厅中的各种菜单项。通过更改类的两个属性 name 和 price,我们可以创建无数菜单对象,例如汉堡或沙拉。

因此,要在 Java 中创建一个类,您需要先写一行用于描述类访问级别的代码(private、public 或 protected),紧随其后的是类名。随后在大括号内概述类的各个属性。

但我们如何创建属于该类的对象呢?Java 通过构造方法来实现这一点:

public class MenuItem {
   public String name;
   public double price;
  
   // Constructor
   public MenuItem(String name, double price) {
       this.name = name;
       this.price = price;
   }
}

构造函数是在我们从类创建新对象时被调用的特殊方法。它会使用我们提供的值来初始化对象的属性。在上面的示例中,构造函数接收名称与价格参数,并使用 “this” 关键字将它们赋给未来对象实例的字段。

构造函数的语法不同于其他类方法,因为它不需要指定返回类型。此外,构造函数的名称必须与类名相同,并且其参数应与您在类定义后声明的属性数量相对应。上面的构造函数创建了两个属性,因为我们在类定义后声明了两个:name 和 price。

完成类及其构造函数的编写后,您可以在 main 方法中创建该类的实例(对象):

public class Main {
   public static void main(String[] args) {
       // Create objects here
       MenuItem burger = new MenuItem("Burger", 3.5);
       MenuItem salad = new MenuItem("Salad", 2.5);
       System.out.println(burger.name + ", " + burger.price);
   }
}

输出:

Burger, 3.5

上面我们创建了两个 MenuItem 对象并赋给变量 burger 和 salad。按照 Java 的要求,变量类型必须声明,这里就是 MenuItem。随后,为了创建类的实例,我们写上 new 关键字并调用构造方法。

除了构造函数,您还可以创建常规方法来赋予类以行为。例如,下面我们添加一个方法来计算含税总价:

public class MenuItem {
   public String name;
   public double price;
  
   // Constructor
   public MenuItem(String name, double price) {
       this.name = name;
       this.price = price;
   }
  
   // Method to calculate price after tax
   public double getPriceAfterTax() {
       double taxRate = 0.08; // 8% tax rate
       return price + (price * taxRate);
   }
}

现在我们可以计算含税总价:

public class Main {
   public static void main(String[] args) {
       MenuItem burger = new MenuItem("Burger", 3.5);
       System.out.println("Price after tax: $" + burger.getPriceAfterTax());
   }
}

输出:

Price after tax: $3.78

封装

类的目的在于为创建对象提供蓝图。这些对象随后会被其他脚本或程序使用。例如,我们的 MenuItem 对象可以被一个用户界面使用,在屏幕上展示它们的名称、价格和图片。

因此,我们必须以一种只能按预期方式使用其实例的方式来设计类。现在我们的 MenuItem 类非常基础且容易出错。有人可能会创建一些荒谬的属性,比如负价格的苹果派或价值百万美元的三明治:

// Inside Main.java
MenuItem applePie = new MenuItem("Apple Pie", -5.99);  // Negative price!
MenuItem sandwich = new MenuItem("Sandwich", 1000000); // Unreasonably expensive

System.out.println("Apple pie price: $" + applePie.price);
System.out.println("Sandwich price: $" + sandwich.price);

所以,编写完类之后的首要任务是通过限制属性的创建与访问方式来保护它们。首先,我们只允许 price 为正值,并设置一个上限,避免意外显示离谱的高价。

Java 允许我们通过 setter 方法来实现:

public class MenuItem {
   private String name;
   private double price;
   private static final double MAX_PRICE = 100.0;
  
   public MenuItem(String name, double price) {
       this.name = name;
       setPrice(price);
   }
  
   public void setPrice(double price) {
       if (price < 0) {
           throw new IllegalArgumentException("Price cannot be negative");
       }
       if (price > MAX_PRICE) {
           throw new IllegalArgumentException("Price cannot exceed $" + MAX_PRICE);
       }
       this.price = price;
   }
  
}

让我们来看看上面代码块中的新内容:

1. 我们通过添加 private 关键字将属性设为私有。这意味着它们只能在 MenuItem 类内部访问。封装始于这一步关键操作。

2. 我们新增了一个常量 MAX_PRICE,它:

  • 是 private(仅在类内可访问)
  • 是 static(在所有实例间共享)
  • 是 final(初始化后不可更改)
  • 被设置为 100.0 美元,作为一个合理的最高价格

3. 我们添加了 setPrice() 方法,它:

  • 接收一个 price 参数
  • 校验价格不为负数
  • 校验价格不超过 MAX_PRICE
  • 若校验失败,抛出带有描述信息的 IllegalArgumentException
  • 仅在通过所有校验后才设置价格

4. 我们修改了构造函数,使用 setPrice() 而不是直接赋值价格。这确保在创建对象时进行价格校验。

我们刚刚实现了优良面向对象设计的核心支柱之一——封装。该范式强制进行数据隐藏和对对象属性的受控访问,确保内部实现细节免受外部干扰,只能通过定义良好的接口进行修改。

让我们通过将封装应用到 name 属性上来进一步强调这一点。想象我们有一家咖啡店,只提供拿铁、卡布奇诺、浓缩、美式和摩卡。

因此,我们的菜单项名称只能是该列表中的某一个。以下是如何在代码中强制执行:

// Rest of the class here
...
private static final String[] VALID_NAMES = {"latte", "cappuccino", "espresso", "americano", "mocha"};
private String name;
public void setName(String name) {
   String lowercaseName = name.toLowerCase();
   for (String validName : VALID_NAMES) {
       if (validName.equals(lowercaseName)) {
           this.name = name;
           return;
       }
   }
   throw new IllegalArgumentException("Invalid drink name. Must be one of: " + String.join(", ", VALID_NAMES));
}

上述代码实现了咖啡店菜单项名称的校验。我们来分解一下:

1. 首先,它定义了一个私有的静态常量数组 VALID_NAMES,其中包含唯一允许的饮品名称:latte、cappuccino、espresso、americano 和 mocha。该数组:

  • private:仅在类内可访问
  • static:在所有实例间共享
  • final:初始化后不可修改

2. 它声明了一个私有的 String 类型 name 字段用于存储饮品名称

3. setName() 方法实现校验逻辑:

  • 接收一个 String 类型的 name 参数
  • 转换为小写以实现不区分大小写的比较
  • 遍历 VALID_NAMES 数组
  • 如果找到匹配,则设置名称并返回
  • 如果未找到匹配,则抛出 IllegalArgumentException,并给出包含所有合法选项的描述信息

以下是目前完整的类:

public class MenuItem {
   private String name;
   private double price;

   private static final String[] VALID_NAMES = {"latte", "cappuccino", "espresso", "americano", "mocha"};

   private static final double MAX_PRICE = 100.0;

   public MenuItem(String name, double price) {
       setName(name);
       setPrice(price);
   }

   public void setName(String name) {
       String lowercaseName = name.toLowerCase();
       for (String validName : VALID_NAMES) {
           if (validName.equals(lowercaseName)) {
               this.name = name;
               return;
           }
       }
       throw new IllegalArgumentException("Invalid drink name. Must be one of: " + String.join(", ", VALID_NAMES));
   }

   public void setPrice(double price) {
       if (price < 0) {
           throw new IllegalArgumentException("Price cannot be negative");
       }
       if (price > MAX_PRICE) {
           throw new IllegalArgumentException("Price cannot exceed $" + MAX_PRICE);
       }
       this.price = price;
   }
}

在保护了属性的创建方式之后,我们也要保护它们的访问方式。可以通过 getter 方法来实现:

public class MenuItem {
   // Rest of the code here
   ...
   public String getName() {
       return name;
   }
   public double getPrice() {
       return price;
   }
}

Getter 方法为类的私有属性提供受控访问。它们解决了直接访问属性可能导致非预期修改、破坏封装的问题。

例如,没有 getter 时,我们可能会直接访问属性:

MenuItem item = new MenuItem("Latte", 4.99);
String name = item.name; // Direct access to attribute
item.name = "INVALID"; // Can modify directly, bypassing validation

使用 getter 时,我们可强制正确访问:

MenuItem item = new MenuItem("Latte", 4.99);
String name = item.getName(); // Controlled access through getter
// item.name = "INVALID"; // Not allowed - must use setName() which validates

这种封装:

  1. 通过防止无效修改来保护数据完整性
  2. 允许我们在不影响使用该类代码的情况下更改内部实现
  3. 提供一个可在需要时加入额外逻辑的统一访问入口
  4. 让代码更易维护且更不易出错

继承

我们的类开始变得不错,但它仍存在不少问题。比如对于一家提供多种菜品和饮品的大型餐厅来说,这个类不够灵活。

如果我们想添加不同类型的食物项,会遇到诸多挑战。有些菜品可以打包带走,有些需要立即食用。菜单项可能有不同价格和折扣。菜品可能需要温度监控或特殊存储。饮品可冷可热并支持自定义配料。条目可能需要过敏原信息与分量选项。当前系统无法处理这些多样化需求。

继承为这些问题提供了优雅的解决方案。它允许我们通过定义一个包含通用属性的基础 MenuItem 类,然后创建继承这些基础并添加独特特性的子类,来构建菜单项的专门版本。

例如,我们可以有一个包含温度选项的 Drink 类、一个需要立即食用的 Food 类,以及一个具有特殊存储需求的 Dessert 类——它们都继承菜单项的核心功能。

扩展类

让我们从 Drink 开始实现这些想法:

public class Drink extends MenuItem {
   private boolean isCold;
  
   public Drink(String name, double price, boolean isCold) {
       this.name = name;
       this.price = price;
       this.isCold = isCold;
   }
  
   public boolean getIsCold() {
       return isCold;
   }
  
   public void setIsCold(boolean isCold) {
       this.isCold = isCold;
   }
}

要定义一个从父类继承的子类,我们在子类名后使用 extends 关键字,随后是父类名。在类定义后,我们定义该子类拥有的任何新属性并实现其构造函数。

但请注意,我们不得不与 isCold 一起重复初始化 name 和 price。这并不理想,因为父类可能拥有上百个属性。此外,上述代码在编译时会报错,因为这不是初始化父类属性的正确方式。正确方式是使用 super 关键字:

public class Drink extends MenuItem {
   private boolean isCold;
  
   public Drink(String name, double price, boolean isCold) {
       super(name, price);
       this.isCold = isCold;
   }
  
   public boolean getIsCold() {
       return isCold;
   }
  
   public void setIsCold(boolean isCold) {
       this.isCold = isCold;
   }
}

super 关键字用于调用父类构造函数。在此示例中,super(name, price) 调用 MenuItem 的构造函数来初始化这些属性,从而避免代码重复。我们只需初始化 Drink 类特有的新属性 isCold 即可。

该关键字非常灵活,您可以在子类的任何部分用它来引用父类。例如,要调用父类方法,使用 super.methodName(),而 super.attributeName 则用于属性。

方法重写

现在,假设我们想为类添加一个新方法来计算含税总价。由于不同菜单项可能具有不同的税率(例如,现制食品与包装饮料),我们可以使用方法重写,在保持父类方法名一致的同时,在各个子类中实现特定的税费计算。

如下所示:

public class MenuItem {
   // Rest of the MenuItem class
  
   public double calculateTotalPrice() {
       // Default tax rate of 10%
       return price * 1.10;
   }
}
public class Food extends MenuItem {
   private boolean isVegetarian;
  
   public Food(String name, double price, boolean isVegetarian) {
       super(name, price);
       this.isVegetarian = isVegetarian;
   }
  
   @Override
   public double calculateTotalPrice() {
       // Food has 15% tax
       return super.getPrice() * 1.15;
   }
}
public class Drink extends MenuItem {
   private boolean isCold;
  
   public Drink(String name, double price, boolean isCold) {
       super(name, price);
       this.isCold = isCold;
   }
  
   @Override
   public double calculateTotalPrice() {
       // Drinks have 8% tax
       return super.getPrice() * 1.08;
   }
}

在该示例中,方法重写允许每个子类提供其自身对 calculateTotalPrice() 的实现:

基础的 MenuItem 类定义了 10% 的默认税率计算。

当 Food 和 Drink 类扩展 MenuItem 时,它们会重写该方法以实现各自的税率:

  • 食品有更高的 15% 税率
  • 饮品有较低的 8% 税率

@Override 注解用于明确表示这些方法重写了父类的方法。如果方法签名与父类不匹配,它有助于捕获错误。

每个子类仍可通过 super.getPrice() 访问父类的价格,展示了重写方法如何在添加自身行为的同时利用父类功能。

简而言之,方法重写是继承不可或缺的一部分,它允许子类为父类中定义的方法提供自己的实现,在保持相同方法签名的同时实现更具体的行为。

抽象类

我们的 MenuItem 类层次结构已经可用,但有个问题:任何人都应该能够创建一个普通的 MenuItem 对象吗?毕竟,在我们的餐厅里,每个菜单项要么是 Food,要么是 Drink——并不存在所谓“泛化的菜单项”。

我们可以通过将 MenuItem 设为抽象类来阻止这种情况。抽象类只提供基础蓝图——它只能用作继承的父类,不能直接实例化。

要将 MenuItem 设为抽象类,我们在其访问修饰符后添加 abstract 关键字:

public abstract class MenuItem {
   private String name;
   private double price;
  
   public MenuItem(String name, double price) {
       setName(name);
       setPrice(price);
   }
  
   // Existing getters/setters remain the same
  
   // Make this method abstract - every subclass MUST implement it
   public abstract double calculateTotalPrice();
}

抽象类也可以拥有如上所示的抽象方法 calculateTotalPrice()。这些抽象方法充当契约,强制子类提供其实现。换言之,抽象类中的任何抽象方法都必须由子类实现。

因此,让我们在这些更改的基础上重写 Food 和 Drink:

public class Food extends MenuItem {
   private boolean isVegetarian;
  
   public Food(String name, double price, boolean isVegetarian) {
       super(name, price);
       this.isVegetarian = isVegetarian;
   }
  
   @Override
   public double calculateTotalPrice() {
       return getPrice() * 1.15;  // 15% tax
   }
}
public class Drink extends MenuItem {
   private boolean hasCaffeine;
  
   public Drink(String name, double price, boolean hasCaffeine) {
       super(name, price);
       this.hasCaffeine = hasCaffeine;
   }
  
   @Override
   public double calculateTotalPrice() {
       return getPrice() * 1.10;  // 10% tax
   }
}

通过该菜单系统的实现,我们看到了抽象与继承如何协同工作,从而创建灵活、可维护、能轻松适应不同业务需求的代码。

结语

今天,我们一窥 Java 作为面向对象编程语言的能力。我们借助餐厅菜单系统,介绍了类、对象,以及 OOP 的几个关键支柱:封装、继承与抽象。

要让该系统达到生产可用的程度,您仍有许多内容需要学习,例如接口(抽象的一部分)、多态以及面向对象设计模式。想进一步了解这些概念,请参阅我们的 Introduction to OOP in Java 课程。

如果您希望检验自己的 Java 知识,不妨试试回答我们的 Java 面试题文章中的部分问题。

Java OOP 常见问答

学习本 Java OOP 教程需要哪些前置知识?

您应具备 Java 编程的基础知识,包括数据类型、变量、控制流(if/else、循环)以及基本语法。如果需要复习,我们的 Introduction to Java 课程涵盖了这些基础内容。

为什么用餐厅菜单系统来解释 OOP 概念?

餐厅菜单系统是一个直观的示例,能够展示 OOP 原则的现实应用。它自然体现了继承(不同类型的菜单项)、封装(保护价格和名称的取值)以及接口(会员计划、温度控制)。大多数人都了解餐厅的运作方式,因此更易于理解这些概念。

Java 中抽象类与接口有什么区别?
  • 一个类只能扩展(extend)一个抽象类,但可以实现(implement)多个接口。
  • 抽象类可以有字段和构造函数;接口不可以。
  • 抽象类既可包含抽象方法也可包含具体方法;接口在 Java 8 之前传统上只包含抽象方法。
  • 抽象类提供基础实现,而接口用于定义契约。
Bexruz (Bex) Tuychiev

作者

Bexruz (Bex) Tuychiev

我是一名数据科学内容创作者,拥有超过 2 年的经验,并在 Medium 上拥有较大的读者群。我喜欢用略带讽刺的笔调撰写有关 AI 和机器学习的深度文章——毕竟总得想点办法让这些内容不那么枯燥。我已发表 130 多篇文章,并制作了一门 DataCamp 课程,另有一门正在筹备中。我的内容已被超过 500 万人次阅读,其中有 2 万人在 Medium 和 LinkedIn 上成为关注者。 

主题