6.1 类

115 阅读15分钟

1. 面向对象的概念

在 OOP 中, 不必关心对象的具体实现,只要能够满足用户的需求即可。

1.1 类

类( class) 是构造对象的模板或蓝图。由类构造(construct) 对象的过程称为创建类的实例 (instance )。

封装( encapsulation , 有时称为数据隐藏) 是与对象有关的一个重要概念。从形式上看,封装不过是将数据和行为组合在一个包中, 并对对象的使用者隐藏了数据的实现方式。对象中的数据称为实例域( instance field ), 操纵数据的过程称为方法( method )。 对于每个特定的类实例(对象)都有一组特定的实例域值。这些值的集合就是这个对象的当前状态( state )。无论何时,只要向对象发送一个消息,它的状态就有可能发生改变。实现封装的关键在于绝对不能让类中的方法直接地访问其他类的实例域。程序仅通过对象的方法与对象数据进行交互。

1.2 对象

要想使用 OOP,一定要清楚对象的三个主要特性:

  • 对象的行为(behavior) —— 可以对对象施加哪些操作,或可以对对象施加哪些方法?
  • 对象的状态(state) —— 当施加那些方法时,对象如何响应?
  • 对象标识 (identity) —— 如何辨别具有相同行为与状态的不同对象?

同一个类的所有对象实例, 由于支持相同的行为而具有家族式的相似性。对象的行为是用可调用的方法定义的。

此外,每个对象都保存着描述当前特征的信息。这就是对象的状态。对象的状态可能会随着时间而发生改变,但这种改变不会是自发的。对象状态的改变必须通过调用方法实现(如果不经过方法调用就可以改变对象状态,只能说明封装性遭到了破坏。

但是,对象的状态并不能完全描述一个对象。每个对象都有一个唯一的身份( identity)。 例如,在一个订单处理系统中, 任何两个订单都存在着不同之处’即使所订购的货物完全相同也是如此。需要注意,作为一个类的实例, 每个对象的标识永远是不同的,状态常常也存在着差异。

对象的这些关键特性在彼此之间相互影响着。例如, 对象的状态影响它的行为(如果一个订单“ 已送货” 或“ 已付款”, 就应该拒绝调用具有增删订单中条目的方法。反过来, 如果订单是“ 空的”,即还没有加人预订的物品,这个订单就不应该进人“ 已送货” 状态。

1.3 类的关系

在类之间, 最常见的关系有

1.3.1 依赖(“ uses-a”)

依赖( dependence ), 即“ uses-a” 关系, 是一种最明显的、 最常见的关系。例如,Order类使用 Account 类是因为 Order 对象需要访问 Account 对象查看信用状态。但是 Item 类不依赖于 Account 类, 这是因为 Item 对象与客户账户无关。因此, 如果一个类的方法操纵另一个类的对象,我们就说一个类依赖于另一个类。应该尽可能地将相互依赖的类减至最少。如果类 A 不知道 B 的存在, 它就不会关心 B的任何改变(这意味着 B 的改变不会导致 A 产生任何 bug )。用软件工程的术语来说,就是让类之间的耦合度最小。

1.3.2 聚合(“ has-a”)

聚合(aggregation ), 即“ has-a ” 关系, 是一种具体且易于理解的关系。例如, 一个Order 对象包含一些 Item 对象。聚合关系意味着类 A 的对象包含类 B 的对象。

1.3.2继承(“ is-a”)

继承( inheritance ), 即“ is-a” 关系, 是一种用于表示特殊与一般关系的。例如,RushOrder 类由 Order 类继承而来。在具有特殊性的 RushOrder 类中包含了一些用于优先处理的特殊方法, 以及一个计算运费的不同方法;而其他的方法, 如添加商品、 生成账单等都是从Order 类继承来的。一般而言, 如果类 A 扩展类 B, 类 A 不但包含从类 B 继承的方法,还会拥有一些额外的功能。

1.4 使用预定义类(Java定义好的类)

在 Java 中, 没有类就无法做任何事情, 我们前面曾经接触过几个类。然而,并不是所有的类都具有面向对象特征。例如,Math 类。在程序中,可以使用 Math 类的方法, 如 Math,random, 并只需要知道方法名和参数(如果有的话) 而不必了解它的具体实现过程。这正是封装的关键所在,当然所有类都是这样。但遗憾的是,Math 类只封装了功能,它不需要也不必隐藏数据。由于没有数据,因此也不必担心生成对象以及初始化实例域:

1.4.1对象和对象变量

要想使用对象,就必须首先构造对象, 并指定其初始状态。然后,对对象应用方法。在 Java 程序设计语言中, 使用构造器(constructor ) 构造新实例。构造器是一种特殊的方法, 用来构造并初始化对象

构造器的名字应该与类名相同。

new Date();//构建一个对象 
System.out.printTn(new Date());// 构建一个对象,并把它传递给一个方法 
new Date().toString();//构建一个对象,并应用他的一个方法 
Date birthday = new Date();//将对象存放在一个变量中

image.png

birthday是一个Date类型的对象变量,并不是一个对象,new 关键字返回的是对象的引用

Date birthday = new Date();

对象变量的初始化: 创建一个对象(new,反射,) 或者引用另一个已经存在的对象

一个对象变量并没有实际包含一个对象,而仅仅引用一个对象。在 Java 中,任何对象变量的值都是对存储在另外一个地方的一个对象的引用。new 操作符的返回值也是一个引用。

可以显式地将对象变量设置为null 表明这个对象变量目前没有引用任何对象

局部变量不会自动地初始化为 null,而必须通过调用 new 或将它们设置为 null 进行初始化

1.4.2 更改器方法与访问器方法

再来看上一节中的 plusDays 方法调用:

LocalDate aThousandDaysLater = newYearsEve.plusDays(1000);

这个调用之后 newYeareEve 会有什么变化? 它会改为 1000 天之后的日期吗? 事实上,并没有。

plusDays 方法会生成一个新的 LocalDate 对象,然后把这个新对象赋给 aThousandDaysLater变量。原来的对象不做任何改动。

只 访 问 对 象 而 不 修 改 对 象 的 方 法 有 时 称 为 访 问 器方法

访 问 对 象 而且修 改 对 象 的 方 法 称 为 更改器 方 法

1.5 用户自定义类

类{
    属性
    构造器
    方法
}

在构造对象时, 构造器会运行,以便将实例域初始化为所希望的状态。构造器与其他的方法有一个重要的不同。构造器总是伴随着 new 操作符的执行被调用,而不能对一个已经存在的对象调用构造器来达到重新设置实例域的目的。

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

方法用于操作对象以及存取它们的实例域。

方法的 隐式参数与显式参数

public void (隐式参数)raiseSalary(double byPercent(显式参数))){ 
    double raise = salary * byPercent / 100; salary += raise; 
}

raiseSalary 方法有两个参数。 第一个参数称为隐式 ( implicit ) 参数, 是出现在方法名前的Employee 类对象。第二个参数位于方法名后面括号中的数值,这是一个显式 ( explicit) 参数 ( 有些人把隐式参数称为方法调用的目标或接收者。)

可以看到,显式参数是明显地列在方法声明中的, 例如 double byPercent。隐式参数没有出现在方法声明中。在每一个方法中, 关键字 this 表示隐式参数。

1.5.1 封装的好处

public String getName() 
public double getSalary(){return salary;}
public LocalDate getHireDay(){return hireDay;}

getXxx() 由于它们只返回实例域值, 因此又称为域访问器

1.5.1 私有变量

将 name、 salary 和 hireDay 域标记为 public , 以此来取代独立的访问器方法会不会更容易些呢?

关键在于 name 是一个只读域。一旦在构造器中设置完毕,就没有任何一个办法可以对它进行修改,这样来确保 name 域不会受到外界的破坏。

虽然 salary 不是只读域,但是它只能用 raiseSalary 方法修改。特别是一旦这个域值出现了错误, 只要调试这个方法就可以了。如果 salary 域是 public 的,破坏这个域值的捣乱者有可能会出没在任何地方。

在有些时候, 需要获得或设置实例域的值。因此,应该提供下面三项内容:

  • 一 私有的数据域;
  • 一 公有的域访问器方法;
  • 一个公有的域更改器方法。

这样做要比提供一个简单的公有数据域复杂些, 但是却有着下列明显的好处:

首先, 可以改变内部实现,除了该类的方法之外,不会影响其他代码

第二点好处:更改器方法可以执行错误检查, 然而直接对域进行赋值将不会进行这些处理.

1.5.2 私有方法

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

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

对于私有方法, 如果改用其他方法实现相应的操作, 则不必保留原有的方法。如果数据的表达方式发生了变化,这个方法可能会变得难以实现, 或者不再需要。然而,只要方法是私有的,类的设计者就可以确信:它不会被外部的其他类操作调用,可以将其删去。如果方法是公有的, 就不能将其删去,因为其他的代码很可能依赖它。

1.5.3 final 实例域

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

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

对于可变的类, 使用 final 修饰符可能会对读者造成混乱。即对象的引用不可改变,但是类的属性却可以改变。

1.6 静态域与静态方法

1.6.1 静态域

如果将域定义为 static, 每个类中只有一个这样的域。而每一个对象对于所有的实例域却都有自己的一份拷贝。例如, 假定需要给每一个雇员賦予唯一的标识码。这里给 Employee类添加一个实例域 id 和一个静态域 nextld:

class Employee{ private static int nextld = 1; private int id; }

现在, 每一个雇员对象都有一个自己的 id 域, 但这个类的所有实例将共享一个 iiextld域。换句话说, 如果有 1000 个 Employee 类的对象, 则有 1000 个实例域 id。但是, 只有一个静态域 nextld。即使没有一个雇员对象, 静态域 nextld 也存在。它属于类,而不属于任何独立的对象。

1.6.2 静态常量

静态变量使用得比较少,但静态常量却使用得比较多。例如, 在 Math 类中定义了一个静态常量:

public class Math{
    public static final double PI = 3.14159265358979323846; 
}

在程序中,可以采用 Math.PI 的形式获得这个常量。

如果关键字 static 被省略, PI 就变成了 Math 类的一个不能改变的实例域。需要通过 Math 类的对象访问 PI,并且每一个 Math 对象都有它自己的一份 PI 拷贝。

1.6.3 静态方法

静态方法是一种不能向对象实施操作的方法。例如Math.pow() 没有使用任何Math对象,也没有对对象实施任何操作。换句话说,没有隐式的参数。可以认为静态方法是没有this参数的方法(在一个非静态的方法中,this参数表示这个方法的隐式参数)。

类的静态方法不能访问实例域,因为它不能操作对象。但是,静态方法可以访问自身类中的静态域。下面是使用这种静态方法的一个示例:

public static int Id=100; 
public static int getId(){ return Id; }

在下面2种情况下使用静态方法:

  • 方法不需要访问对象状态,其所需参数都是通过显式参数提供(例如:Math.pow)
  • 一个方法只需要访问类的静态域(例如:Xxx.getId()

1.6.3 工厂方法

静态方法还有另外一种常见的用途。类似 LocalDate 和 NumberFormat 的类使用静态工厂方法 (factory method) 来构造对象。你已经见过工厂方法 LocalDate.now 和 LocalDate.of。NumberFormat 类如下使用工厂方法生成不同风格的格式化对象:

NumberFormat currencyFormatter = NumberFormat.getCurrencylnstance(); 
NumberFormat percentFormatter = NumberFormat.getPercentlnstance(); 
double x = 0.1;
System.out.println(currencyFormatter.format(x)); // prints SO.10 System.out.println(percentFomatter.format(x)); // prints 10%

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

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

1.6 方法参数

一个方法可以修改传递引用所对应的变量值,而不能修改传递值调用所对应的变量值。

  • 按值调用 (call by value) 表示方法接收的是调用者提供的值。
  • 按引用调用 ( call by reference)表示方法接收的是调用者提供的变量地址。

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

为什么引用类型也是值传递?

每个方法的参数和变量 都在栈中开辟空间存储, 他的值是实参传过来的引用地址, 方法中形参改变不会影响实参, 引用数据类型 实参和形参 都是内存地址,而不是对象的属性,所以属性改变不是影响了实参。可以把形参赋值一个新的对象,实参并不受影响, 所以也是值类型,形参 实参是彼此独立,只是都指向一个的对象而已。

基本数据类型参数

public class App { 
    public static void swap(int a){ a=1; } 
    public static void main( String[] args ){ 
        int b=12; swap(b); System.out.println(b);//12 
    } 
}

引用数据类型

public class App { 
    public static void swap(Student a,Student b){ Student temp=a; a=b; b=temp; } 
    public static void main( String[] args ) { 
        Student stu1=new Student("aaaa"); 
        Student stu2=new Student("bbbb"); 
        swap(stu1,stu2); 
        System.out.println(stu1.a);//aaaa 
        System.out.println(stu2.a);//bbbb 
    } 
} 
class Student{ 
    public String a; public Student(String xx){ a=xx; } 
}

1.7 对象的构造

1.7.1 重载

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

1.7.2 无参数的构造器

很多类都包含一个无参数的构造函数,对象由无参数构造函数创建时, 其状态会设置为适当的默认值。

如果在编写一个类时没有编写构造器, 那么系统就会提供一个无参数构造器。这个构造器将所有的实例域设置为默认值。于是, 实例域中的数值型数据设置为 0、 布尔型数据设置为 false、 所有对象变量将设置为 null。如果类中提供了至少一个构造器, 但是没有提供无参数的构造器, 则在构造对象时如果没有提供参数就会被视为不合法。

1.7.3 显式域初始化

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