Java中的面向对象编程:类,对象,封装,继承和抽象

Java一直是世界前三大最受欢迎的编程语言之一。它在企业软件开发、Android移动应用和大规模Web应用等领域的应用无与伦比。其强大的类型系统、庞大的生态系统以及“一次编写,到处运行”的能力使其特别适合构建健壮、可扩展的系统。在本文中,我们将探讨Java的面向对象编程特性如何使开发人员有效地利用这些能力,使他们能够通过适当的代码组织和重用构建可维护和可扩展的应用程序。

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

在我们开始编写任何代码之前,让我们进行一些设置。

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

首先,每个公共类必须放在自己的文件中,文件名与类名完全相同,但要添加一个.java扩展名。因此,如果我想编写一个Laptop类,文件名必须是Laptop.java区分大小写。您可以将非公共类放在同一个文件中,但最好将它们分开。我知道我们正在提前讨论如何组织类,甚至在编写类之前,但提前对事物的放置位置有一个大致的想法是个好主意。

所有 Java 项目必须有一个 Main.java 文件,其中包含 Main 类。在这里,你可以通过创建对象来测试你的类。

要运行Java代码,我们将使用 IntelliJ IDEA,一款流行的Java集成开发工具。安装完IntelliJ后:

  1. 创建一个新的Java项目(文件 > 新建 > 项目)
  2. 右键单击 src 文件夹创建 Main.java 文件,并粘贴以下内容:
public class Main { public static void main(String[] args) { // 在此创建和测试对象 } }

每当我们谈论类时,我们会在其他文件中编写代码,而不是Main.java文件。但如果我们要创建和测试对象,我们会切换到Main.java

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

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

如果您完全是Java新手,请查看我们的 Java入门课程,该课程涵盖了Java数据类型和控制流的基础知识。

否则,让我们开始吧。

Java类和对象

那么,类到底是什么?

类是Java中用于表示现实世界概念的编程构造。例如,考虑这个MenuItem类(在您的集成开发环境中创建一个文件来编写这个类):

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

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

因此,要在Java中创建一个类,您需要从描述类的访问级别的行开始(privatepublicprotected),然后是类名。括号后面,您可以定义类的属性。

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

public class MenuItem { public String name; public double price; // 构造方法 public MenuItem(String name, double price) { this.name = name; this.price = price; } }

构造方法是一种特殊的方法,当我们从类中创建一个新对象时调用它。它使用我们提供的值初始化对象的属性。在上面的示例中,构造方法接受一个名称和价格参数,并使用’this’关键字将它们分配给对象的字段,以引用未来的对象实例。

构造函数的语法与其他类方法不同,因为它不需要指定返回类型。此外,构造函数必须与类名相同,并且在类定义之后声明的属性数量相同。在上面的例子中,构造函数创建了两个属性,因为我们在类定义之后声明了两个属性:nameprice

在编写类及其构造函数之后,可以在主方法中创建其实例(对象):

public class Main { public static void main(String[] args) { // 在这里创建对象 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对象并将其赋值给变量burgersalad。按照Java的要求,变量的类型必须声明为MenuItem。然后,要创建类的实例,我们使用new关键字后跟调用构造方法。

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

public class MenuItem { public String name; public double price; // 构造函数 public MenuItem(String name, double price) { this.name = name; this.price = price; } // 用于计算税后价格的方法 public double getPriceAfterTax() { double taxRate = 0.08; // 8% 税率 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类非常基础且容易出错。一个人可能会创建具有荒谬属性的对象,比如价格为负的苹果派或价值百万美元的三明治:

// 在Main.java内部 MenuItem applePie = new MenuItem("Apple Pie", -5.99); // 价格为负! MenuItem sandwich = new MenuItem("Sandwich", 1000000); // 不合理昂贵 System.out.println("Apple pie price: $" + applePie.price); System.out.println("Sandwich price: $" + sandwich.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(仅在类内部可访问)
  • 静态(跨所有实例共享)
  • 最终(初始化后不可更改)
  • 设置为$100.0作为合理的最高价格

3. 我们添加了一个setPrice() 方法:

  • 接受一个价格参数
  • 验证价格不为负数
  • 验证价格不超过MAX_PRICE
  • 如果验证失败,则抛出带有描述性消息的IllegalArgumentException
  • 只有在所有验证通过时才设置价格

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

我们刚刚实现了面向对象设计的核心支柱之一—封装。这种编程范式强调数据隐藏和对对象属性的受控访问,确保内部实现细节受到保护,防止外部干扰,并且只能通过明确定义的接口进行修改。

让我们通过将封装应用到名称属性来加深理解。假设我们有一家只出售拿铁、卡布奇诺、浓缩咖啡、美式咖啡和摩卡的咖啡店。

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

// 类的其余部分 ... 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 { // 其余代码在这里 ... public String getName() { return name; } public double getPrice() { return price; } }

Getter方法提供对类的私有属性进行受控访问。它们解决了直接属性访问可能导致不希望的修改并破坏封装性的问题。

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

MenuItem item = new MenuItem("Latte", 4.99); String name = item.name; // 直接访问属性 item.name = "INVALID"; // 可以直接修改,绕过验证

使用getter,我们强制执行正确的访问方式:

MenuItem item = new MenuItem("Latte", 4.99); String name = item.getName(); // 通过getter进行受控访问 // item.name = "INVALID"; // 不允许 - 必须使用setName() 进行验证

这种封装:

  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关键字,然后跟上父类。在类定义之后,我们定义子类具有的任何新属性并实现其构造函数。

但请注意,我们必须重复初始化nameprice以及isCold。 这并不理想,因为父类可能有数百个属性。 除此之外,上述代码在编译时会抛出错误,因为这不是初始化父类属性的正确方式。 正确的方式是使用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的构造函数来初始化这些属性,从而避免代码重复。我们只需要初始化新的isCold属性,这是特定于Drink类的。

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

方法重写

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

这是它的样子:

public class MenuItem { // MenuItem类的其余部分 public double calculateTotalPrice() { // 默认税率为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() { // 食品税率为15% 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() { // 饮料税率为8% return super.getPrice() * 1.08; } }

在这个例子中,方法重写允许每个子类提供自己的calculateTotalPrice()的实现:

基类MenuItem 类定义了默认的税率为10%。

FoodDrink 类扩展 MenuItem 类时,它们会重写此方法来实现自己的税率:

  • 食品项目有更高的15%税率
  • 饮料的税率较低,为8%

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

每个子类仍可以使用 super.getPrice() 访问父类的价格,演示了重写方法如何利用父类功能并添加自己的行为。

简而言之,方法重写是继承的一个重要部分,允许子类提供其在父类中定义的方法的实现,从而在保持相同方法签名的同时实现更具体的行为。

抽象类

我们的MenuItem类层次结构有效,但存在一个问题:是否应该允许任何人创建一个普通的MenuItem对象?毕竟,在我们的餐厅里,每个菜单项都是食物或饮料 – 不存在只是“通用菜单项”的情况。

通过将MenuItem设为抽象类,我们可以预防这种情况发生。抽象类仅提供一个基本蓝图 – 它只能作为父类用于继承,而不能直接实例化。

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

public abstract class MenuItem { private String name; private double price; public MenuItem(String name, double price) { setName(name); setPrice(price); } // 现有的getter/setter保持不变 // 使此方法成为抽象方法 - 每个子类都必须实现它 public abstract double calculateTotalPrice(); }

抽象类也可以有像calculateTotalPrice() 这样的抽象方法。这些抽象方法充当契约,强制子类提供它们的实现。换句话说,抽象类中的任何抽象方法都必须由子类实现。

因此,让我们根据以下更改重新编写食品饮料

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% 税 } } 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% 税 } }

通过这个菜单系统的实现,我们看到抽象和继承是如何共同工作,创建灵活、可维护的代码,可以轻松适应不同的业务需求。

结论

今天,我们已经初步了解了作为面向对象编程语言的Java的能力。我们涵盖了类,对象和面向对象编程的一些关键支柱:封装,继承和通过餐厅菜单系统实现的抽象。

要使这个系统达到生产就绪状态,您还有很多东西要学习,比如接口(抽象的一部分),多态性和面向对象设计模式。要了解更多关于这些概念的内容,请参考我们的 Java面向对象编程导论 课程。

如果你想测试一下自己对Java的了解,可以尝试回答一些问题我们的Java面试问题文章中的一些问题

Source:
https://www.datacamp.com/tutorial/oop-in-java