对象和类
面向对象程序设计(OOP)
特性
基本取代了以往的*"结构化的面向过程的程序开发技术"*。面向对象的特性包括封装 、继承 、多态。
使用思路
程序中的很多对象其实来源于标准库,少部分是自定义的。程序的封装性使得用户 不必关心其功能的具体实现过程,只要能够满足自己的需求。
对于一些规模较小的问题,将其分解为过程的开发方式比较理想;而面向对象更加适用于解决规模较大的问题,比如实现一个简单的Web浏览器,可能需要2000多个过程,而面向对象可能只需要100个类,对于寻找错误来说显然是后者更容易定位。
类之间的关系
-
依赖(uses-a)
-
应尽量让相互依赖的类之间耦合度最小,当一个类的方法参数中包含另一个类的对象,或者在方法中使用其他类的对象时,就形成了依赖关系,这是一种相对松散的关系,可以看作是"使用"关系,表示一个类对另一个类的引用。
-
在 UML 中用虚线和箭头表示依赖关系。
-
-
聚合(has-a)
- 聚合是一种"拥有"的关系,表示一个类是另一个类的一部分,但它们之间的生命周期是独立的。
- 在 UML 中,聚合关系用一个空心的菱形表示。
-
继承(is-a)
- 表示一个类(子类)从另一个类(父类)派生而来,子类继承了父类的属性和方法。
- 在 UML 中,继承关系用一个空心箭头表示,箭头指向父类。
一些专有名词
类是具有相同特征 的对象的集合,也是构造对象的模版 ,由类构造*(construct)对象的过程称为创建类的实例(instance*)。
对象中的数据称为实例域*(instance field ),这些实例域值的集合就是这个对象的当前状态 (state)。操纵数据的过程称为方法(method)*。
使用现有类
"Java中一切都是类。"
想要使用对象,就必须首先构造对象,并指定其初始状态,然后对对象施加方法。
只有声明的空对象(没有指向new出来的对象)不能调用任何方法。
对象也可以组成数组(一个对象数组)。
比如使用使用 Date
类和 Calendar
类来处理日期和时间,都需要先实例化一个类的具体对象,再调用对象的方法。
java
public static void main(String[] args) {
// 使用 Date 类来获取当前日期
Date currentDate = new Date();
System.out.println("当前日期和时间: " + currentDate);
// 使用 SimpleDateFormat 格式化日期
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String formattedDate = dateFormat.format(currentDate);
System.out.println("格式化后的当前日期: " + formattedDate);
// 使用 Calendar 类来获取当前日期
Calendar calendar = Calendar.getInstance();
System.out.println("当前年份: " + calendar.get(Calendar.YEAR));
System.out.println("当前月份: " + (calendar.get(Calendar.MONTH) +1)); // 月份从0开始 System.out.println("当前日期: " + calendar.get(Calendar.DAY_OF_MONTH));
// 设置特定日期(例如:2023年11月1日)
Calendar specificDate = Calendar.getInstance();
specificDate.set(2023, Calendar.NOVEMBER,1,0,0,0); // 设置为2023年11月1日00:00:00 specificDate.set(Calendar.MILLISECOND,0);
System.out.println("特定日期: " + dateFormat.format(specificDate.getTime()));
//计算两个日期之间的差异
long diffInMillis = specificDate.getTimeInMillis() - calendar.getTimeInMillis();
long diffInDays = diffInMillis / (1000 *60 *60 *24);
System.out.println("当前日期到2023年11月1日的天数差: " + diffInDays + " 天");
}
自定义Java类
实际开发中,业务需求多变,我们不得不创建自己的自定义类,去映射现实中的业务对象。在这个过程中还是能复用库中的各种类,采取继承、聚合等多种方式进行。如何使用这些模版去贴合实际的业务需求,是程序员的一门必修课。
在Java中,所有的类都源自一个"神通广大的超类 ",也就是Object
。类通过扩展已有的类,成为新的自定义类。
多个源文件的使用
一种编程规范是把每一个类都存放在一个单独的源文件中,这样在编译时就可以采用通配符来调用编译器。
实例域
实例域一般声明为private
,确保只有类自身的方法能够访问这些实例域,保证封装性。
构造器
-
构造器与类同名,在构建实例对象时构造器被运行,以便将实例域初始化为所希望的状态。
-
构造器不唯一,可以自定义
-
由0个或1个以上的参数
隐式参数
在Java里方法名前面的类对象 是隐式参数 ,C++里需要用this->
指明,而Java中可写可不写。
getter和setter(访问器和更改器)
为了充分发挥封装的优点,通常会有访问器(又被称为域访问器)去访问实例域,因为实例域一般都是私有的*(private)*,所以需要使用类方法去访问,这样相比直接去设置实例域为public
,要更为安全可靠可调试。更改器同理。
私有方法
在实现一个类时,由于公共数据非常危险,所以应该将所有的数据域都设置为私有的。然而,方法又应该如何设计呢?
尽管绝大多数方法都被设计为公共的,但在某些特殊情况下,也可能将它们设计为私有的。有时,++这些辅助方法 不应该成为公共接口的一部分++ ,这是由于它们往往与当前的实现机制非常紧密,或者需要一个特别的协议以及一个特别的调用顺序。这种方法最好被设计为private
的。
Final实例域
可以将实例域定义为final。构建对象时必须初始化这样的域。也就是说,必须确保每一个构造器执行之后,这个域的值被设置,并且在后面的操作中,不能够再对它进行修改。例如,可以将Employee
类中的name
声明为final
,因为在对象构建之后,这个值不会再被修改,即没有setName
方法。
java
class Employee{}
private final String name;
}
final
修饰符大都用于基本数据(*primitive)类型域,或不可变(immutable)*类的域(如果类中的每一个方法都不会改变其对象,这种类就是不可变的类。例如,String类就是一个不可变的类)。对于可变的类,使用final修饰可能会导致读者造成混乱。例如,
java
private final Date hiredate;
仅意味着存储在hiredate
变量中的对象引用在对象构造之后不能被改变,而并不意味着hiredate
对象是一个常量。++任何方法都可以对hiredate
引用的对象调用setTime
更改器。++
识别类
传统的过程化程序设计,必须从顶部的 main 函数开始编写程序。在设计面向对象的系统时没有所谓的"顶部"。对于学习 OOP 的初学者来说常常会感觉无从下手。答案是:首先从设计类开始,然后再往每个类中添加方法。
识别类 的简单规则是在分析问题的过程中寻找名词 ,而方法 对应动词。
静态域与静态方法
静态域(静态变量)
如果将域定义为static
,则每一个被实例化的对象实例都共享一个static
域,不属于任何一个对象,而是属于类。
要注意static
域的值是可以改变的!不是不能改变,要注意与常量的区别,它更像C++里的全局变量,为所有的实例共享。
静态常量
静态变量使用得比较少,但静态常量却使用得比较多。例如,在Math类中定义了一个静态常量:
java
public class Math {
...
public static final double PI = 3.14159265358979323846;
...
}
在程序中,可以采用Math.PI
的形式获得这个常量。如果关键字static
被省略,PI就变成了Math类的一个实例域。需要通过Math类的对象访问PI,并且每一个Math对象都有它自己的一份PI拷贝。
另一个多次使用的静态常量是System.out
。它在System类中声明:
java
public class System {
...
public static final PrintStream out = ...;
...
}
静态方法
用static
修饰的方法称为静态方法 ,静态方法不能操作对象,所以是无法访问对象的实例域的,例如Math
类的pow
方法就是一个静态方法。
java
//在运算时不使用任何Math对象,也就是说,没有隐式的参数
Math.pow(x,a);
但是,静态方法可以访问自身类中的静态域(static
修饰的静态变量 )。
静态方法可以直接通过类名调用 ,而不需要实例化一个对象!最好的例子就是main
方法,它也是静态方法,但是不对任何对象进行操作。
对象构造
重载
一个类可以有多个构造器,这种特征叫重载*(overloading)*,如果多个方法有相同的名字,不同的参数,便产生了重载。
注意:Java允许重载任何方法,而不是构造器方法。因此,要求明确地指定一个方法,需要指出方法名以及参数类型。这叫做方法的签名*(signature)*。例如,String类有4个称为indexOf
的公有方法。它们的签名是
java
indexOf(int)
indexOf(int, int)
indexOf(String)
indexOf(String, int)
返回类型不是方法签名的一部分。也就是说,不能有两个名称相同、参数类型也相同的方法。
默认域初始化
如果在构造器中没有显示地给域赋予初值,那么就会被自动赋予默认值:数值为0
,布尔值为false
,对象引用为null
。然而,只有缺少程序设计经验的人才会这样做。确实,如果不明确地对域进行初始化,就会影响程序的可读性。
这是域与局部变量的主要不同点。必须明确规定初始化方法中的局部变量。但是,如果没有初始化类中的域,将会被初始化为默认值(0,false,null)。
默认构造器
默认构造器是指没有参数的构造器。这个默认构造器将所有的实例域设置为默认值(见上)。
显式域初始化
由于类的构造器方法可以重载,所以可以采用多种形式设置类的实例域的初始化状态。确保不管怎样调用构造器,每个实例域都可以被设置为一个有意义的初值。这是一种很好的设计习惯。这种机制很类似++C++的初始化列表的语法++。
可以在类定义中,直接将一个值赋给任何域。例如:
java
class Employee{
...
private String name = "";
}
在执行构造器之前,先执行赋值操作。一个类的所有构造器都希望把相同的值赋予某个特定的实例域,这种方式特别有用。
初始值不是一定是常量。在下面的例子中,可以调用方法对域进行初始化。仔细看一下 Employee
类,其中每个雇员都有一个id
域。可以使用下面的方法进行初始化:
java
class Employee
{
...
static int assignId()
{
int r = nextId;
nextId++;
return r;
}
...
private int id = assignId();
}
参数名命名技巧
有时我们在给对象的某个属性赋值时,会选择使用单个字母来命名:
java
public Employee(String n, double s){
name = n;
salary = s;
}
但这样做有一个缺陷:只有阅读代码才能够了解参数n
和参数s
的含义。于是,有些程序员在每个参数前面加上一个前缀"a":
java
public Employee(String aName, double aSalary){
name = aName;
salary = aSalary;
}
这样很清晰,一眼就能够看懂参数的含义。
或者采用C++中的技巧,使用隐式参数this
java
public Employee(String name,double salary){
this.name=name;
this.salary=salary;
}
构造器调用构造器
关键字this
引用方法的隐式参数。然而,这个关键字还有另外一个含义。如果构造器的第一个语句形如this(...),这个构造器将调用同一个类的另一个构造器。
java
public Employee(double s)
{
// calls Employee(String, double)
this("Employee #" + nextId, s);
nextId++;
}
当调用new Employee(60000)
时,Employee(double)
构造器将调用Employee(String, double)
构造器。
采用这种方式使用this关键字非常有用,这样对公共的构造器代码部分只需写一次即可。
初始化块
Java一共有三种初始化数据域的方法
- 在构造器中设置值
- 在声明中赋值
- 初始化块(但是不常见)
在一个类中有很多块,只要构建一个用于初始化的块,我们叫做初始化块,就可以用来初始化实例域.
初始化块是Java中用于初始化实例变量的一段代码块。它们是在构造器之前执行的,这使得它们非常适合于需要在构造器执行前执行的复杂初始化逻辑。初始化块可以是实例初始化块,也可以是静态初始化块。
示例:
java
class Employee {
private String name;
private double salary;
// 实例初始化块
//前面还可以加static,表示是静态的初始化块,只能初始化类的静态域,静态域的初始化在类的第一次加载时就进行,而不是实例化new的时候才进行.
{
name = "Default Name";
salary =50000.0;
}
// 构造器
public Employee() {
// 构造器的其他逻辑
}
public void displayInfo() {
System.out.println("Name: " + name + ", Salary: " + salary);
}
}
public class Main {
public static void main(String[] args) {
Employee emp1 = new Employee();
emp1.displayInfo();
// 输出: Name: Default Name, Salary:50000.0
}
}
包(package)的概念和作用
Java允许使用包 *(package)*将类组织起来。借助于包可以方便地组织自己的代码,并将自己的代码与别人提供的代码隔离开来。包类似于C++中的命名空间。
标准的Java类库分布在多个包中,包括
java.lang
、java.util
和java.net
等。标准的Java包是一个层次结构,如同硬盘上的目录一样,也可以使用标准层次结构。所有标准的Java类都处于java和javax层次中。
使用包的主要原因是避免类名的冲突 。例如两个程序员可能都定义了Employee
类。只要将这些类放置在不同的包中,就不会产生冲突。实际上,为了保证包名的唯一性,Sun公司建议以公司的域名作为包的前缀(这意味着是唯一的)以避免形式上的冲突,并且对于不同的项目使用不同的包名。例如,com.horstmann.corejava
是一个包名。这个包还可以被进一步细分为子包,如com.horstmann.corejava.util
。
从编译器的角度来看,嵌套包之间没有任何关系。例如,java.util
包与java.util.jar
包无关。
类的导入
两种方式
-
在类前面添加完整的包名
-
使用
import
语句导入一个特定的类或包这不同于C++中的#include,C++编译器无法查看任何文件的内部, 除了正在编译的文件和在头文件中明确包含的文件,而Java编译器却可以,只要告诉它到哪里去找就可以了。
静态导入
import
语句还可以导入静态方法和静态域,而不必加类名前缀 。但是这不利于代码的清晰度(比如System.out->out
),通常在以下两个方面有所应用:
把类放入包中
一个类的源文件中如果没有package
语句,则会被放在一个没有名字的默认包中。
一般要使用package
语句来将自定义的类放在对应的包中,有利于层次化编程。
包作用域
前面已经接触过访问修饰符public
和private
。标记为public
的部分可以被任意的类使用,标记为private
的部分只能被定义它们的类使用。如果没有指定public
或private
,这个部分(类、方法或变量)可以被同一个包中的所有方法访问(属性是default
)。
类路径
类路径
在Java中,类路径(Classpath)指的是JVM在运行Java程序或查找类文件时用来寻找类的路径。类路径可以是一系列目录和JAR文件的集合,定义了Java程序在运行时要查找的类位置。
设置类路径
在不使用IDE而是使用命令行时要注意。
最好采用-classpath(或-cp)
选项指定类路径:
bash
java -classpath /home/user/..../a.jar;/.../b.jar;... MyCar.java
代码注释格式
JDK包含一个很有用的工具,叫做javadoc ,它可以由源文件生成一个HTML文件。实际上,在第三版讲述的相关API文档就是通过对标准Java类库的源代码运行javadoc
生成的。
如果在源代码中添加使用的注释"
/**
开始的注释",那么可以很容易生成一个看上去非常专业的文档。这是一种很好的方式,因为这种方式可以将代码与注释保存在一个地方。如果文件有一个独立的文件中,就有可能会随着时间的推移,出现代码和注释不一致的情况。然而,由于文档注释与源代码在同一个文件中,在修改代码的同时,重新运行javadoc就可以轻易地保持两者的一致性。
学习这块知识,可以多去看看标准库里的API文档,注释格式都是挺规范的。
javadoc命令可以去尝试着使用一下。
类注释
类注释的规范是放在import
语句之后,类定义之前。
方法注释
注释内部可以包含多个标签,如 @param
、@return
和 @throws
等。
java
/**
*计算两个整数的和。
*
* @param a 第一个整数
* @param b 第二个整数
* @return 两个整数的和
* @throws IllegalArgumentException(类名) 如果参数为负数 */
public int add(int a, int b) {
if (a <0 || b <0) {
throw new IllegalArgumentException("参数不能为负数");
}
return a + b;
}
通用注释
@author name
这个标记将产生一个 "author"(作者)条目。可以使用多个 @author 标记,每个 @author 标记对应一名作者。@version text
这个标记将产生一个 "version"(版本)条目。这里的text可以是对当前版本的任何描述。@since text
这个标记将产生一个 "since"(始于)条目。这里的text可以是对引入特定版本的描述。 例如,@since version1.7.1。@deprecated text
这个标记将标记类、方法或变量添加一个不再使用的注释。text中给出了取代的建议。
类设计技巧
在结束本章之前,简单地介绍几点技巧。应用这些技巧可以使得设计出来的类更具有OOP的专业水准。
一定将数据设计为私有的
最重要的是:绝对不要破坏封装性。有时候,需要编写一个访问器方法或更改器方法,但最好还是保持对象实例的私有性。很多惨痛的经验告诉我们,数据的表示形式很可能会改变,但它们的使用方式却不会经常发生变化。当数据保持私有时,它们的表示形式的变化不会对类的使用者产生影响,即使出现bug也易于检测。
一定不要将对象的实例域初始化
Java不对局部变量进行初始化,但会对对象的实例域进行初始化。最好不要依赖于系统的默认值,而是应该显式地初始化所有的数值,具体的初始化方式可以是提供默认值,也可以是在所有构造器中设置默认值。
不要在集合中使用基本数据类型
就是说,用其他的类代替多个相关的基本数据类型的使用。这种会使类更易于理解且易于修改。例如,用一个称为Address
的新的类替换下面Customer
类中的实例域:
java
private String street;
private String city;
private String state;
private int zip;
这样,可以很容易地顺应地址的变化,例如,需要增加对国际地址的处理。
不是所有的域都需要独立的域访问器和与更改器(getter和setter)
或许需要获得或设置雇员的薪金。而一旦构造了雇员对象,就应该禁止更改雇用日期,并且在对象中,常常包含一些不希望别人获得或设置的实例域,例如,在Address
类中存放州缩写的数组。
使用标准格式进行类的定义
建议采用下面的顺序书写类的内容:
公有访问特性部分→包作用域访问特性部分→私有访问特性部分
在每一部分中,建议按照下列顺序列出:
实例域→静态域→实例方法→静态方法
将职责过多的类进行分解
更多的设计思路可以参考设计模式的知识。重要!!!有空要去学习。
类名和方法名要能够体现它们的职责
与变量应该有一个能够反映其含义的名称一样,类也应该如此(在标准类库中,也存在一些含义不明确的例子,如:Date类实际上是一个用于描述时间的类)。
命名类名的最佳习惯是使用一个名词(例如Order
),前面有形容词修饰的名词(例如RushOrder
)或动名词(有"-ing"后缀)修饰名词(例如BillingAddress
)。对于方法来说,习惯上访问方法用小写字母开头(例如getSalary
),更改器方法用小写字母的set开头(例如setSalary
)。
而一旦构造了雇员对象,就应该禁止更改雇用日期,并且在对象中,常常包含一些不希望别人获得或设置的实例域,例如,在Address
类中存放州缩写的数组。
使用标准格式进行类的定义
建议采用下面的顺序书写类的内容:
公有访问特性部分→包作用域访问特性部分→私有访问特性部分
在每一部分中,建议按照下列顺序列出:
实例域→静态域→实例方法→静态方法
将职责过多的类进行分解
更多的设计思路可以参考设计模式的知识。重要!!!有空要去学习。
类名和方法名要能够体现它们的职责
与变量应该有一个能够反映其含义的名称一样,类也应该如此(在标准类库中,也存在一些含义不明确的例子,如:Date类实际上是一个用于描述时间的类)。
命名类名的最佳习惯是使用一个名词(例如Order
),前面有形容词修饰的名词(例如RushOrder
)或动名词(有"-ing"后缀)修饰名词(例如BillingAddress
)。对于方法来说,习惯上访问方法用小写字母开头(例如getSalary
),更改器方法用小写字母的set开头(例如setSalary
)。