—- 来源: Java核心技术 卷1 基础知识(第十版)—-

面向对象程序设计概念

面向对象程序设计(OOP)是当今主流的程序设计范型,Java 是完全面向对象的。面向对象的程序是由对象组成的,每个对象包含对用户公开的特定功能部分和隐藏的实现部分。

OOP 的另外一个原则:可以通过扩展一个类来建立另一个新的类。(扩展的到的新类具有所扩展类的全部属性和方法)

类(class)是构造对象的模板或者蓝图。

对象

使用OOP,需要清楚对象的三个特性

对象的行为(behavior):可以对对象进行哪些操作,或者对对象施加哪些方法

对象的状态(state):当施加方法的时候,对象如何响应

对象的标识(identity):如何辨别具有相同行为与状态的不同对象

识别类

找名词与动词” 原则只是一种经验, 在创建类的时候, 哪些名词和动词是重要的完全取决于个人的开发经验

例如:订单(Order)类,你需要添加订单,可以使用addOrder(Order order);来进行命名方法

类与类直接的关系

常见的关系有:

  • 依赖(user - a)
    • 是一种最明显、最常见的关系。如果一个类的方法操作另外一个类的对象,就说一个类依赖于另一个类(也就是我们经常说的耦合)
  • 聚合(has - a)
    • 是一种具体且易于理解的关系。如果一个类包含了另外一个类的镀锡,就说这个类包含了另外一个类
  • 继承(is - a)
    • 是一种用于表示特殊与一般的关系。类A 扩展类B,类A 不但包含从类B 继承的方法,还会拥有一些额外的功能(类A 自己定义的)

使用预定义类

Java 中不是所有的类都具有面向对象的特征,例如Math 类,在程序中可以使用Math 类的方法,并且只需要知道方法名和参数,而不比了解它的具体实现过程,Math 类只封装了功能,不需要也不必隐藏数据,由于没有数据,因此也不必担心生成对象以及初始化实例域。

对象与对象变量

要使用对象,首先需要构造对象,并指定其初始化状态。在Java 中,使用构造器(constructor)构造新实例,构造器是一种特殊的方法,用来构造并初始化对象。

对象变量

定义了一个对象变量today,可以引用Date 类型的对象,但是变量today 不是一个对象,因为它并没有引用对象,所以不能使用这个变量去使用Date 的方法

  1. Date tody;

初始化变量,此时这个变量就引用了Date 对象(在 Java 中,任何对象变量的值都是对存储在另外一个地方的一个对象的引用。new 操作符的返回值也是一个引用)

  1. Date tody = new Date();

用户自定义类

没有main 方法,却拥有自己的实例域和实例方法

  1. /**
  2. * 订单
  3. * @author xGuo
  4. * @date 2022/03/27
  5. */
  6. public class Order {
  7. /**
  8. * 订单编号
  9. */
  10. private Integer orderNumber;
  11. /**
  12. * 价格
  13. */
  14. private String price;
  15. /**
  16. * 下单用户信息
  17. */
  18. private User user;
  19. /**
  20. * 收获地址
  21. */
  22. private String address;
  23. /**
  24. * 下单时间
  25. */
  26. private Date orderTime;
  27. public Order(Integer orderNumber, String price, User user, String address, Date orderTime) {
  28. this.orderNumber = orderNumber;
  29. this.price = price;
  30. this.user = user;
  31. this.address = address;
  32. this.orderTime = orderTime;
  33. }
  34. public Integer getOrderNumber() {
  35. return orderNumber;
  36. }
  37. public void setOrderNumber(Integer orderNumber) {
  38. this.orderNumber = orderNumber;
  39. }
  40. public String getPrice() {
  41. return price;
  42. }
  43. public void setPrice(String price) {
  44. this.price = price;
  45. }
  46. public User getUser() {
  47. return user;
  48. }
  49. public void setUser(User user) {
  50. this.user = user;
  51. }
  52. public String getAddress() {
  53. return address;
  54. }
  55. public void setAddress(String address) {
  56. this.address = address;
  57. }
  58. public Date getOrderTime() {
  59. return orderTime;
  60. }
  61. public void setOrderTime(Date orderTime) {
  62. this.orderTime = orderTime;
  63. }
  64. @Override
  65. public String toString() {
  66. return "Order{" +
  67. "orderNumber=" + orderNumber +
  68. ", price='" + price + '\'' +
  69. ", user=" + user +
  70. ", address='" + address + '\'' +
  71. ", orderTime=" + orderTime +
  72. '}';
  73. }
  74. }

这个类的所有方法都被标记为 public。 关键字 public 意味着任何类的任何方法都可以调用这些方法。

关键字private 确保只有Order 类自身的方法才能够访问这些实例域,而其他类的方法不能访问。

可以用 public 标记实例域, 但这是一种极为不提倡的做法,. public 數据域允许程序中的任何方法对其进行读取和修改。, 这就完全破坏了封装。 任何类的任何方法都可以修改 public 域, 从我们的经验来看, 某些代码将使用这种存取权限, 而这并不我们所希望的, 因此, 这里强烈建议将实例域标记为 private,

构造器

构造器与类同名,构造器与其他的方法有一个重要的不同点:构造器总是伴随着new 操作符的执行被调用,而不能对一个已存在的对象调用构造器来达到重新设置实例的目的。

public Order(Integer orderNumber, String price, User user, String address, Date orderTime) {
        this.orderNumber = orderNumber;
        this.price = price;
        this.user = user;
        this.address = address;
        this.orderTime = orderTime;
}

构造器的特点:

  • 构造器与类同名
  • 每个类可以有一个以上的构造器
  • 构造器可以有 0 个、1 个或多个参数
  • 构造器没有返回值
  • 构造器总是伴随着 new 操作一起调用

隐式参数与显式参数

order.setPrice(189.5);

setPrice 有两个参数:
一个是setPrice() 前面(左侧)的order 对象 -> 隐式(implicit)参数(有人将隐式参数称之为方法调用的目标或者接收方)

一个是setPrice(189.5) 括号中的数值 -> 显示(explcit)参数

封装的优点

public Double getPrice() {
    return price;
}

public void setPrice(Double price) {
    this.price = price;
}

public User getUser() {
    return user;
}

public void setUser(User user) {
    this.user = user;
}

getXXX() 方法都是典型的访问器方法,只返回实例域值(又称为域访问器)

如果将price、user 域标记为public ,用来取代独立的访问器是不是会更简单容易呢?

改为public 的话,一旦这些域值出现了错误,你就没办法知道具体是在哪里出错的,而使用了private ,使得他们只能通过setXXX() 方法来修改,可以精确定位到该方法进行调试。

基于类的访问权限

本类的方法可以访问本类的任何一个对象的私有域

私有方法

在实现一个类时,由于公有数据非常危险, 所以应该将所有的数据域都设置为私有的。

然而,方法又应该如何设计呢? 尽管绝大多数方法都被设计为公有的,但在某些特殊情况下,也可能将它们设计为私有的。有时,可能希望将一个计算代码划分成若干个独立的辅助方法。通常, 这些辅助方法不应该成为公有接口的一部分,这是由于它们往往与当前的实现机制非常紧密, 或者需要一个特别的协议以及一个特别的调用次序。最好将这样的方法设计为 private 的

在 Java 中,为了实现一个私有的方法, 只需将关键字 public 改为 private 即可

final 实例域

可以将实例域定义为 final。 构建对象时必须初始化这样的域。也就是说, 必须确保在每一个构造器执行之后, 这个域的值被设置, 并且在后面的操作中, 不能够再对它进行修改

public static void main(String[] args) {
   final int i = 10;
   i += 1; // java: 无法为最终变量i分配值
}

final 修饰符大都应用于基本 (primitive) 类型域,或不可变(immutable) 类的域(如果类中的每个方法都不会改变其对象, 这种类就是不可变的类。 例如,String类就是一个不可变的类)

静态域与静态方法

静态域

如果将域定义为 static, 每个类中只有一个这样的域。而每一个对象对于所有的实例域却都有自己的一份拷贝

静态常量

例如Math 类中定义了一个静态常量

public static final double PI = 3.14159265358979323846;

在程序中可以采用Math.PI 的形式来获取这个常量

静态方法

静态方法是一种不能向?象?施操作的方法。 例如, Math 类的 pow 方法就是一个静态方法

Math.pow(x,a);

在运算时,不使用任何Math 对象(也就是没有隐式参数,可以认为静态方法是没有 this 参数的方法( 在一个非静态的方法中, this 参数表示这个方法的隐式参数)

工厂方法

静态方法还有另外一种常见的用途。类似 LocalDate 和 NumberFormat 的类使用静态工厂方法 (factory methocO 来构造对象

public static void main(String[] args) {
    NumberFormat currencyInstance = NumberFormat.getCurrencyInstance();
    NumberFormat percentInstance = NumberFormat.getPercentInstance();
    double x = 0.1;
    System.out.println(currencyInstance.format(x)); // 输出 -> ¥0.10
    System.out.println(percentInstance.format(x)); // 输出 -> 10%
}

为什么 NumberFormat 类不利用构造器完成这些操作呢? 这主要有两个原因:

  1. 无法命名构造器。构造器的名字必须与类名相同。但是, 这里希望将得到的货币实例和百分比实例采用不同的名字。
  2. 当使用构造器时,无法改变所构造的对象类型。而 Factory 方法将返回一个 DecimalFormat 类对象, 这是 NumberFormat 的子类。

main 方法

main 方法不对任何对象进行操作。事实上,在启动程序时还没有任何一个对象。静态的main 方法将执行并创建程序所需要的对象(每一个类可以有一个 main 方法。这是一个常用于对类进行单元测试的技巧)

public static void main(String[] args) {
}

方法参数

Java 程序设计语言总是采用按值调用。也就是说, 方法得到的是所有参数值的一个拷贝,特别是,方法不能修改传递给它的任何参数变量的内容。

public class MyTest {
    public static void count(double x){
        x = x * 3;
        System.out.println("x =" + x); // 输出 -> 30
    }

    public static void main(String[] args) {
        double a = 10;
        count(a);
        System.out.println("a = " + a); // 输出 -> 10
    }
}

调用这个方法之后,a 的值还是 10。看一下具体的执行过程:
1 ) x 被初始化为 a 值的一个拷贝(也就是 10 )
2 ) x 被乘以3 后等于 30。 但是 a 仍然是 10
3 ) 这个方法结束之后,参数变量 X 不再使用。

方法参数共有两种类型:
1.基本数据类型(数字、布尔值)
2.对象引用

一个方法不可能修改一个基本数据类型的参数,但是对象引用就不一样了。

public class MyTest {
    public static void count(Order x){
        x.setPrice(200.0);
        System.out.println("x =" + x); 
      // 输出 -> x =Order{orderNumber=null, price='200.0', user=null, address='null', orderTime=null}
    }

    public static void main(String[] args) {
        Order order = new Order();
        count(order);
        System.out.println("order = " + order);
      // 输出 -> order =Order{orderNumber=null, price='200.0', user=null, address='null', orderTime=null}
    }
}

具体的执行过程为:
1 ) X 被初始化为 order 值的拷贝,这里是一个对象的引用。
2 ) setPrice 方法应用于这个对象引用。x 和 order 同时引用的那个 Order 对象的价格修改为了200.0。
3 ) 方法结束后,参数变量 x 不再使用。当然,对象变量 order 继续引用那个价格修改为200.0 的订单对象

实现一个改变对象参数状态的方法并不是一件难事。理由很简单, 方法得到的是对象引用的拷贝,对象引用及其他的拷贝同时引用同一个对象。

总结一下 Java 中方法参数的使用情况:

  1. 一个方法不能修改一个基本数据类型的参数(即数值型或布尔型)。
  2. 一个方法可以改变一个对象参数的状态。
  3. 一个方法不能让对象参数引用一个新的对象。

对象构造

重载

有些类有多个构造器

public static void main(String[] args) {
    StringBuilder stringBuilder = new StringBuilder();
    StringBuilder hello = new StringBuilder("hello");
}

这种特征叫做重载 (overloading)。如果多个方法(比如, StringBuilder 构造器方法)有相同的名字、 不同的参数,便产生了重载。编译器必须挑选出具体执行哪个方法,它通过用各个方法给出的参数类型与特定方法调用所使用的值类型进行匹配来挑选出相应的方法。如果编译器找不到匹配的参数, 就会产生编译时错误, 因为根本不存在匹配。(这个过程被称为重载解析(overloading resolution)。)

Java 允许重载任何方法, 而不只是构造器方法(返回类型不是方法签名的一部分。 也就是说, 不能有两个名字相同、 参数类型也相
同却返回不同类型值的方法。)

indexOf(int)
indexOf(int, int)
indexOf(String)
indexOf(String, int)

默认域初始化

如果在构造器中没有显式地给域赋予初值,那么就会被自动地赋为默认值: 数值为 0、布尔值为 false、 对象引用为 null。

无参数的构造器

如果在编写一个类时没有编写构造器, 那么系统就会提供一个无参数构造器。这个构造器将所有的实例域设置为默认值。

如果类中提供了至少一个构造器, 但是没有提供无参数的构造器, 则在构造对象时如果没有提供参数就会被视为不合法。

显示域初始化

通过重载类的构造器方法, 可以采用多种形式设置类的实例域的初始状态。确保不管怎样调用构造器,每个实例域都可以被设置为一个有意义的初值,这是一种很好的设计习惯。

参数名

在编写很小的构造器时(这是十分常见的,) 常常在参数命名上出现错误,需要见名知意。

调用另一个构造器

public Order(Integer orderNumber, Double price, User user, String address, Date orderTime) {
    this.orderNumber = orderNumber;
    this.price = price;
    this.user = user;
    this.address = address;
    this.orderTime = orderTime;
}

public Order(Double price,Integer orderNumber){
    this(orderNumber,price,new User(),"上海",new Date());
}
public static void main(String[] args) {
    Order order = new Order(12.0,212737193);
    // 调用public Order(Double price,Integer orderNumber) 构造器时,
    // 实际调用了public Order(Integer orderNumber, Double price, User user, String address, Date orderTime) 构造器
}

采用这种方式使用 this 关键字非常有用, 这样对公共的构造器代码部分只编写一次即可

初始化块

前面已经讲过两种初始化数据域的方法:
1.在构造器中设置值
2.在声明中赋值
实际上,Java 还有第三种机制, 称为初始化块(initializationblock)。在一个类的声明中,可以包含多个代码块。只要构造类的对象,这些块就会被执行。

public class MyTest {

    private static int a;

    private int b;

    {
        a++;
        b = a;
    }

    public int getB() {
        return b;
    }

    public void setB(int b) {
        this.b = b;
    }

    public static void main(String[] args) {
        MyTest myTest = new MyTest();
        System.out.println(myTest.getB()); // 输出 -> 1
    }
}

无论使用哪个构造器构造对象, b 域都在对象初始化块中被初始化。首先运行初始化块,然后才运行构造器的主体部分。这种机制不是必需的,也不常见。通常会直接将初始化代码放在构造器中。


由于初始化数据域有多种途径,所以列出构造过程的所有路径可能相当混乱。下面是调用构造器的具体处理步骤:
1 ) 所有数据域被初始化为默认值(0、false 或 null。)

2 ) 按照在类声明中出现的次序, 依次执行所有域初始化语句和初始化块

3 ) 如果构造器第一行调用了第二个构造器, 则执行第二个构造器主体

4 ) 执行这个构造器的主体.
当然,应该精心地组织好初始化代码,这样有利于其他程序员的理解。 例如, 如果让类的构造器行为依赖于数据域声明的顺序, 那就会显得很奇怪并且容易引起错误


如果对类的静态域进行初始化的代码比较复杂,那么可以使用静态的初始化块,在类第一次加载的时候, 将会进行静态域的初始化。与实例域一样,除非将它们显式地设置成其他值, 否则默认的初始值是 0、 false 或 null。 所有的静态初始化语句以及静态初始化块都将依照类定义的顺序执行。

对象析构与finalize 方法

有些面向对象的程序设计语言,特别是 C++, 有显式的析构器方法, 其中放置一些当对象不再使用时需要执行的清理代码。在析构器中, 最常见的操作是回收分配给对象的存储空间。

由于 Java 有自动的垃圾回收器,不需要人工回收内存, 所以 Java 不支持析构器。

当然, 某些对象使用了内存之外的其他资源, 例如, 文件或使用了系统资源的另一个对象的句柄。在这种情况下, 当资源不再需要时, 将其回收和再利用将显得十分重要。

可以为任何一个类添加 finalize 方法。finalize 方法将在垃圾回收器清除对象之前调用。在实际应用中,不要依赖于使用 finalize 方法回收任何短缺的资源, 这是因为很难知道这个方法什么时候才能够调用。

Java 允许使用包( package > 将类组织起来。借助于包可以方便地组织自己的代码,并将自己的代码与别人提供的代码库分开管理。
标准的 Java 类库分布在多个包中, 包括 java.lang、java.util 和java.net 等。标准的 Java包具有一个层次结构。如同硬盘的目录嵌套一样,也可以使用嵌套层次组织包。所有标准的Java 包都处于java 和javax 包层次中。

使用包的主要原因是确保类名的唯一性。 假如两个程序员不约而同地建立了 Order 类。 只要将这些类放置在不同的包中, 就不会产生冲突。

事实上,为了保证包名的绝对唯一性, Sun 公司建议将公司的因特网域名(这显然是独一无二的)以逆序的形式作为包名,并且对于不同的项目使用不同的子包。 例如, horstmann.com 是本书作者之一注册的域名。逆序形式为 com.horstmann。 这个包还可以被进一步地划分成子包, 如 com.horstmann.corejava

类的导入

一个类可以使用所属包中的所有类, 以及其他包中的公有类(public class)。

可以采用两种方式访问另一个包中的公有类:

1.在每个类名之前添加完整的包名

public static void main(String[] args) {
    java.time.LocalDate today = java.time.LocalDate.now();
}

2.使用import 语句(是一种引用包含在包中的类的简明描述。一旦使用了 import 语句,在使用类时,就不必写出包的全名了)

import java.time.LocalDate;

public class MyTest {
    public static void main(String[] args) {
        LocalDate today = LocalDate.now();
    }
}

静态导入

mport 语句不仅可以导人类,还增加了导人静态方法和静态域的功能。

import static java.lang.System.*;

public class MyTest {
    public static void main(String[] args) {
        out.println("hello");
        exit(0);
    }
}

将类放入包中

要想将一个类放人包中, 就必须将包的名字放在源文件的开头, 包中定义类的代码之前。

例如自定义的Order 类

package com.xguo.bootredisson.api;

import java.util.Date;

/**
 * 订单
 *
 * @author xGuo
 * @date 2022/03/27
 */
public class Order {
   ...
}

类路径

为了使类能够被多个程序共享,需要做到下面几点:
1 ) 把类放到一个目录中, 例如 /home/user/classdir。需要注意, 这个目录是包树状结构的基目录。 如果希望将 com.xguo.bootredisson.api.Order 类添加到其中,这个 Order.class 类文件就必须位于子目录 /../com/xguo/bootredisson/api 中。
2 ) 将 JAR 文件放在一个目录中,例如:/home/user/archives。
3 ) 设置类路径(classpath)。类路径是所有包含类文件的路径的集合

文档注释

/**
 * 订单实体类
 *
 * @author xGuo
 * @date 2022/03/27
 */
public class Order {

}
/**
 * 订单
 *
 * @param orderNumber 订单号
 * @param price       价格
 * @param user        用户
 * @param address     地址
 * @param orderTime   订单时间
 */
public Order(Integer orderNumber, Double price, User user, String address, Date orderTime) {
    this.orderNumber = orderNumber;
    this.price = price;
    this.user = user;
    this.address = address;
    this.orderTime = orderTime;
}
/**
 * 设置名称
 *
 * @param name 名字
 * @return {@link String}
 */
private static String setName(String name){
    return name;
}
//返回设置的名称
return name;

类设计技巧

简单的介绍几点设计技巧:

1.一定要保证数据私有

这是最重要的;绝对不要破坏封装性

2.一定要对数据初始化

Java 不对局部变量进行初始化, 但是会对对象的实例域进行初始化。最好不要依赖于系统的默认值, 而是应该显式地初始化所有的数据, 具体的初始化方式可以是提供默认值, 也可以是在所有构造器中设置默认值。

3.不要在类中使用过多的基本类型

用其他的类代替多个相关的基本类型的使用,这样会使类更加易于理解且易于修改

4.不是所有的域都需要独立的域访问器和域更改器

在对象中,常常包含一些不希望别人获得或设置的实例域

5.将职责过多的类进行分解

这样说似乎有点含糊不清, 究竟多少算是“ 过多” ? 每个人的看法不同。但是, 如果明显地可以将一个复杂的类分解成两个更为简单的类,就应该将其分解(但另一方面, 也不要走极端。设计 10 个类,每个类只有一个方法,显然有些矫枉过正了)

6.类名和方法名要能够体现它们的职责

变量应该有一个能够反映其含义的名字一样, 类也应该如此,命名类名的良好习惯是采用一个名词(Order )、 前面有形容词修饰的名词( RushOrder)或动名词(有“ -ing” 后缀)修饰名词(例如, BillingAddress )。对于方法来说,习惯是访问器方法用小写 get 开头 ( getSalary ), 更改器方法用小写的 set 开头(setSalary )

7.优先使用不可变的类

LocalDate 类以及 java.time 包中的其他类是不可变的—没有方法能修改对象的状态。

类似 plusDays 的方法并不是更改对象,而是返回状态已修改的新对象。

更改对象的问题在于, 如果多个线程试图同时更新一个对象, 就会发生并发更改。其结果是不可预料的。

如果类是不可变的,就可以安全地在多个线程间共享其对象。因此, 要尽可能让类是不可变的, 这是一个很好的想法。