《Java 编程的逻辑》笔记——第3章 类的基础

声明:

本博客是本人在学习《Java 编程的逻辑》后整理的笔记,旨在方便复习和回顾,并非用作商业用途。

本博客已标明出处,如有侵权请告知,马上删除。

开头语

程序主要就是数据以及对数据的操作,为方便理解和操作,高级语言使用数据类型这个概念,不同的数据类型有不同的特征和操作。

Java 定义了八种基本数据类型,四种整型 byte/short/int/long,两种浮点类型 float/double,一种真假类型 boolean,一种字符类型 char,其他类型的数据都用这个概念表达。

3.1 类的基本概念

在第1章,我们暂时将类看做函数的容器,在某些情况下,类也确实基本上只是函数的容器,但类更多表示的是自定义数据类型。我们先从容器的角度,然后从自定义数据类型的角度谈谈类。

3.1.1 函数容器

我们看个例子,Java API 中的类 Math,它里面主要就包含了若干数学函数,表 3-1 列出了其中一些。

在这里插入图片描述

使用这些函数,直接在前面加 Math. 即可,例如 Math.abs(-1) 返回 1。

这些函数都有相同的修饰符,public static。

static 表示类方法,也叫静态方法,与类方法相对的是实例方法。实例方法没有 static 修饰符,必须通过实例或者叫对象(待会介绍)调用,而类方法可以直接通过类名进行调用的,不需要创建实例。

public 表示这些函数是公开的,可以在任何地方被外部调用。与 public 相对的有 private, 如果是 private,表示私有,这个函数只能在同一个类内被别的函数调用,而不能被外部的类调用。在 Math 类中,有一个函数 Random initRNG() 就是 private 的,这个函数被 public 的方法 random() 调用以生成随机数,但不能在 Math 类以外的地方被调用。

将函数声明为 private 可以避免该函数被外部类误用,调用者可以清楚的知道哪些函数是可以调用的,哪些是不可以调用的。类实现者通过 private 函数封装和隐藏内部实现细节,而调用者只需要关心 public 的就可以了。可以说,通过 private 封装和隐藏内部实现细节,避免被误操作,是计算机程序的一种基本思维方式。

除了 Math 类,我们再来看一个例子 Arrays,Arrays 里面包含很多与数组操作相关的函数,表 3-2 列出了其中一些。

在这里插入图片描述

这里将类看做函数的容器,更多的是从语言实现的角度看,从概念的角度看,Math 和 Arrays 也可以看做是自定义数据类型,分别表示数学和数组类型,其中的 public static 函数可以看做是类型能进行的操作。接下来让我们更为详细的讨论自定义数据类型。

3.1.2 自定义数据类型

我们将类看做自定义数据类型,所谓自定义数据类型就是除了八种基本类型以外的其他类型,用于表示和处理基本类型以外的其他数据。

一个数据类型由其包含的属性以及该类型可以进行的操作组成,属性又可以分为是类型本身具有的属性,还是一个具体数据具有的属性,同样,操作也可以分为是类型本身可以进行的操作,还是一个具体数据可以进行的操作。

这样,一个数据类型就主要由四部分组成

  • 类型本身具有的属性,通过类变量体现
  • 类型本身可以进行的操作,通过类方法体现
  • 类型实例具有的属性,通过实例变量体现
  • 类型实例可以进行的操作,通过实例方法体现

不过,对于一个具体类型,每一个部分不一定都有,Arrays 类就只有类方法。

类变量和实例变量都叫成员变量,也就是类的成员,类变量也叫静态变量或静态成员变量。类方法和实例方法都叫成员方法,也都是类的成员,类方法也叫静态方法

类方法我们上面已经看过了,Math 和 Arrays 类中定义的方法就是类方法,这些方法的修饰符必须有 static。下面解释下类变量,实例变量和实例方法。

3.1.2.1 类变量

类型本身具有的属性通过类变量体现,经常用于表示一个类型中的常量。比如 Math 类,定义了两个数学中常用的常量,如下所示:

public static final double E = 2.7182818284590452354;
public static final double PI = 3.14159265358979323846;

E 表示数学中自然对数的底数,自然对数在很多学科中有重要的意义,PI 表示数学中的圆周率 π。与类方法一样,类变量可以直接通过类名访问,如 Math.PI。

这两个变量的修饰符也都有 public static,public 表示外部可以访问,static 表示是类变量。与 public 相对的主要也是 private,表示变量只能在类内被访问。与 static 相对的是实例变量,没有 static 修饰符。

这里多了一个修饰符 final,final 在修饰变量的时候表示常量,即变量赋值后就不能再修改了。使用 final 可以避免误操作,比如说,如果有人不小心将 Math.PI 的值改了,那么很多相关的计算就会出错。另外,Java 编译器可以对 final 变量进行一些特别的优化。所以,如果数据赋值后就不应该再变了,就加 final 修饰符吧。

表示类变量的时候,static 修饰符是必需的,但 public 和 final 都不是必需的

3.1.2.2 实例变量和实例方法

所谓实例,字面意思就是一个实际的例子。实例变量表示具体的实例所具有的属性,实例方法表示具体的实例可以进行的操作。如果将微信订阅号看做一个类型,那"老马说编程"订阅号就是一个实例,订阅号的头像、功能介绍、发布的文章可以看做实例变量,而修改头像、修改功能介绍、发布新文章可以看做实例方法。与基本类型对比,“int a;” 这个语句,int 就是类型,而 a 就是实例。

接下来,我们通过定义和使用类,来进一步理解自定义数据类型。

3.1.3 定义第一个类

我们定义一个简单的类,表示在平面坐标轴中的一个点,代码如下:

class Point {
    
    
    public int x;
    public int y;
    
    public double distance(){
    
    
        return Math.sqrt(x*x+y*y);
    }
}

我们来解释一下:

public class Point

表示类型的名字是 Point,是可以被外部公开访问的。这个 public 修饰似乎是多余的,不能被外部访问还能有什么用?在这里,确实不能用 private 修饰 Point。但修饰符可以没有(即留空),表示一种包级别的可见性,我们后续章节介绍,另外,类可以定义在一个类的内部,这时可以使用 private 修饰符,我们也在后续章节介绍。

public int x;
public int y;

定义了两个实例变量,x 和 y,分别表示 x 坐标和 y 坐标,与类变量类似,修饰符也有 public 或 private 修饰符,表示含义类似,public 表示可被外部访问,而 private 表示私有,不能直接被外部访问,实例变量不能有 static 修饰符。

public double distance(){
    
    
    return Math.sqrt(x*x+y*y);
}

定义了实例方法 distance,表示该点到坐标原点的距离。该方法可以直接访问实例变量 x 和 y,这是实例方法和类方法的最大区别。实例方法直接访问实例变量,到底是什么意思呢?其实,在实例方法中,有一个隐含的参数,这个参数就是当前操作的实例自己,直接操作实例变量,实际也需要通过参数进行。

实例方法和类方法更多的区别如下所示:

  • 类方法只能访问类变量,但不能访问实例变量,可以调用其他的类方法,但不能调用实例方法
  • 实例方法既能访问实例变量,也可以访问类变量,既可以调用实例方法,也可以调用类方法

关于实例方法和类方法更多的细节,后续会进一步介绍。

3.1.4 使用第一个类

定义了类本身和定义了一个函数类似,本身不会做什么事情,不会分配内存,也不会执行代码。方法要执行需要被调用,而实例方法被调用,首先需要一个实例。实例也称为对象,我们可能会交替使用

下面的代码演示了如何使用:

public static void main(String[] args) {
    
    
    Point p = new Point();
    p.x = 2;
    p.y = 3;
    System.out.println(p.distance());
}

我们解释一下:

Point p = new Point();

这个语句包含了 Point 类型的变量声明和赋值,它可以分为两部分:

Point p;
p = new Point();

Point p 声明了一个变量,这个变量叫 p,是 Point 类型的。这个变量和数组变量是类似的,都有两块内存,一块存放实际内容,一块存放实际内容的位置。声明变量本身只会分配存放位置的内存空间,这块空间还没有指向任何实际内容。因为这种变量和数组变量本身不存储数据,而只是存储实际内容的位置,它们也都称为引用类型的变量。

p = new Point(); 创建了一个实例或对象,然后赋值给了 Point 类型的变量 p,它至少做了两件事

  1. 分配内存,以存储新对象的数据,对象数据包括这个对象的属性,具体包括其实例变量 x 和 y。
  2. 给实例变量设置默认值,int 类型默认值为 0。

与方法内定义的局部变量不同,在创建对象的时候,所有的实例变量都会分配一个默认值,这与在创建数组的时候是类似的,数值类型变量的默认值是 0,boolean 是 false, char 是 ‘\u0000’,引用类型变量都是 null,null 是一个特殊的值,表示不指向任何对象。这些默认值可以修改,我们待会介绍。

p.x = 2;
p.y = 3;

给对象的变量赋值,语法形式是:<对象变量名>.<成员名>

System.out.println(p.distance());

调用实例方法 distance,并输出结果,语法形式是:<对象变量名>.<方法名>。实例方法内对实例变量的操作,实际操作的就是 p 这个对象的数据。

我们在介绍基本类型的时候,是先定义数据,然后赋值,最后是操作,自定义类型与此类似:

  • Point p = new Point(); 是定义数据并设置默认值
  • p.x = 2; p.y = 3; 是赋值
  • p.distance() 是数据的操作

可以看出,对实例变量和实例方法的访问都通过对象进行,通过对象来访问和操作其内部的数据是一种基本的面向对象思维。本例中,我们通过对象直接操作了其内部数据 x 和 y,这是一个不好的习惯,一般而言,不应该将实例变量声明为 public,而只应该通过对象的方法对实例变量进行操作。原因也是为了减少误操作,直接访问变量没有办法进行参数检查和控制,而通过方法修改,可以在方法中进行检查。

3.1.5 变量默认值

之前我们说,实例变量都有一个默认值,如果希望修改这个默认值,可以在定义变量的同时就赋值,或者将代码放入初始化代码块中,代码块用 {} 包围,如下面代码所示:

int x = 1;
int y;
{
    
    
    y = 2;
}

x 的默认值设为了 1,y 的默认值设为了 2。在新建一个对象的时候,会先调用这个初始化,然后才会执行构造方法中的代码。关于构造方法,我们稍后介绍。

静态变量也可以这样初始化:

static int STATIC_ONE = 1;
static int STATIC_TWO;
static
{
    
    
    STATIC_TWO = 2;    
}

STATIC_TWO=2; 语句外面包了一个 static {},这叫静态初始化代码块静态初始化代码块在类加载的时候执行,这是在任何对象创建之前,且只执行一次

3.1.6 private 变量

上面我们说一般不应该将实例变量声明为 public,下面我们修改一下类的定义,将实例变量定义为 private,通过实例方法来操作变量,代码如下:

class Point {
    
    
    private int x;
    private int y;

    public void setX(int x) {
    
    
        this.x = x;
    }
    
    public void setY(int y) {
    
    
        this.y = y;
    }
    
    public int getX() {
    
    
        return x;
    }
    
    public int getY() {
    
    
        return y;
    }
    
    public double distance() {
    
    
        return Math.sqrt(x * x + y * y);
    }
}

这个定义中,我们加了四个方法,setX/setY 用于设置实例变量的值,getX/getY 用于获取实例变量的值。

这里面需要介绍的是 this 这个关键字。this 表示当前实例,在语句 this.x=x; 中,this.x 表示实例变量 x,而右边的 x 表示方法参数中的 x。前面我们提到,在实例方法中,有一个隐含的参数,这个参数就是 this,没有歧义的情况下,可以直接访问实例变量,在这个例子中,两个变量名都叫 x,则需要通过加上 this 来消除歧义。

这四个方法看上去是非常多余的,直接访问变量不是更简洁吗?而且上节我们也说过,函数调用是有成本的。在这个例子中,意义确实不太大,实际上,Java 编译器一般也会将对这几个方法的调用转换为直接访问实例变量,而避免函数调用的开销。但在很多情况下,通过函数调用可以封装内部数据,避免误操作,我们一般还是不将成员变量定义为 public

使用这个类的代码如下:

public static void main(String[] args) {
    
    
    Point p = new Point();
    p.setX(2);
    p.setY(3);
    System.out.println(p.distance());
}

将对实例变量的直接访问改为了方法调用

3.1.7 构造方法

在初始化对象的时候,前面我们都是直接对每个变量赋值,有一个更简单的方式对实例变量赋初值,就是构造方法,我们先看下代码,在 Point 类定义中增加如下代码:

public Point(){
    
    
    this(0,0);
}

public Point(int x, int y){
    
    
    this.x = x;
    this.y = y;
}

这两个就是构造方法,构造方法可以有多个。不同于一般方法,构造方法有一些特殊的地方:

  • 名称是固定的,与类名相同。这也容易理解,靠这个用户和 Java 系统就都能容易的知道哪些是构造方法。
  • 没有返回值,也不能有返回值。这个规定大概是因为返回值没用吧。

与普通方法一样,构造方法也可以重载。第二个构造方法是比较容易理解的,使用 this 对实例变量赋值。

我们解释下第一个构造方法,this(0,0) 的意思是调用第二个构造方法,并传递参数 0,0。我们前面解释说 this 表示当前实例,可以通过 this 访问实例变量,这是 this 的第二个用法,用于在构造方法中调用其他构造方法。

这个 this 调用必须放在第一行,这个规定应该也是为了避免误操作。构造方法是用于初始化对象的,如果要调用别的构造方法,先调别的,然后根据情况自己再做调整,而如果自己先初始化了一部分,再调别的,自己的修改可能就被覆盖了。

这个例子中,不带参数的构造方法通过 this(0,0) 又调用了第二个构造方法,这个调用是多余的,因为 x 和 y 的默认值就是 0,不需要再单独赋值,我们这里主要是演示其语法。

我们来看下如何使用构造方法,代码如下

Point p = new Point(2,3);

这个调用就可以将实例变量 x 和 y 的值设为 2 和 3。前面我们介绍 new Point() 的时候说,它至少做了两件事,一个是分配内存,另一个是给实例变量设置默认值,这里我们需要加上一件事,就是调用构造方法。调用构造方法是 new 操作的一部分

通过构造方法,可以更为简洁的对实例变量进行赋值。关于构造方法,下面我们讨论两个细节概念:一个是默认构造方法;另一个是私有构造方法。

3.1.7.1 默认构造方法

每个类都至少要有一个构造方法,在通过 new 创建对象的过程中会被调用。但构造方法如果没什么操作要做,可以省略。Java 编译器会自动生成一个默认构造方法,也没有具体操作。但一旦定义了构造方法,Java 就不会再自动生成默认的。具体什么意思呢?在这个例子中,如果我们只定义了第二个构造方法(带参数的),则下面语句:

Point p = new Point();

就会报错,因为找不到不带参数的构造方法。

为什么 Java 有时候帮助自动生成,有时候不生成呢?你在没有定义任何构造方法的时候,Java 认为你不需要,所以就生成一个空的以被 new 过程调用,你定义了构造方法的时候,Java 认为你知道自己在干什么,认为你是有意不想要不带参数的构造方法的,所以不会帮你生成。

3.1.7.2 私有构造方法

构造方法可以是私有方法,即修饰符可以为 private, 为什么需要私有构造方法呢?大概可能有这么几种场景:

  • 不能创建类的实例,类只能被静态访问,如 Math 和 Arrays 类,它们的构造方法就是私有的。
  • 能创建类的实例,但只能被类的的静态方法调用。有一种常用的场景,即类的对象有但是只能有一个,即单例模式(后续文章介绍),在这个场景中,对象是通过静态方法获取的,而静态方法调用私有构造方法创建一个对象,如果对象已经创建过了,就重用这个对象。
  • 只是用来被其他多个构造方法调用,用于减少重复代码

3.1.8 类和对象的生命周期

了解了类和对象的定义和使用,下面我们再从程序运行的角度理解下类和对象的生命周期。

3.1.8.1 类

在程序运行的时候,当第一次通过 new 创建一个类的对象的时候,或者直接通过类名访问类变量和类方法的时候,Java 会将类加载进内存,为这个类分配一块空间,这个空间会包括类的定义,它有哪些变量,哪些方法等,同时还有类的静态变量,并对静态变量赋初始值。后续文章会进一步介绍有关细节

类加载进内存后,一般不会释放,直到程序结束。一般情况下,类只会加载一次,所以静态变量在内存中只有一份

3.1.8.2 对象

当通过 new 创建一个对象的时候,对象产生,在内存中,会存储这个对象的实例变量值,每 new 一次,对象就会产生一个,就会有一份独立的实例变量

每个对象除了保存实例变量的值外,可以理解还保存着对应类型即类的地址,这样,通过对象能知道它的类,访问到类的变量和方法代码

实例方法可以理解为一个静态方法,只是多了一个参数 this,通过对象调用方法,可以理解为就是调用这个静态方法,并将对象作为参数传给 this

对象的释放是被 Java 用垃圾回收机制管理的,大部分情况下,我们不用太操心,当对象不再被使用的时候会被自动释放

具体来说,对象和数组一样,有两块内存,保存地址的部分分配在栈中,而保存实际内容的部分分配在堆中。栈中的内存是自动管理的,函数调用入栈就会分配,而出栈就会释放

堆中的内存是被垃圾回收机制管理的,当没有活跃变量指向对象的时候,对应的堆空间就可能被释放,具体释放时间是 Java 虚拟机自己决定的。活跃变量,具体的说,就是已加载的类的类变量,和栈中所有的变量

3.1.9 小结

本节我们主要从自定义数据类型的角度介绍了类,谈了如何定义类,以及如何创建对象,如何使用类。自定义类型由类变量、类方法、实例变量和实例方法组成,为方 便对实例变量赋值,介绍了构造方法。本节引入了多个关键字,我们介绍了这些关键字的含义。最后我们介绍了类和对象的生命周期。

通过类实现自定义数据类型,封装该类型的数据所具有的属性和操作,隐藏实现细节,从而在更高的层次上(类和对象的层次,而非基本数据类型和函数的层次)考虑和操作数据,是计算机程序解决复杂问题的一种重要的思维方式

本节我们提到了多个关键字,这里汇总一下:

  • public:可以修饰类、类方法、类变量、实例变量、实例方法、构造方法,表示可被外部访问
  • private:可以修饰类、类方法、类变量、实例变量、实例方法、构造方法,表示不可以被外部访问,只能在类内被使用
  • static:修饰类变量和类方法,它也可以修饰内部类(后续章节介绍)。
  • this:表示当前实例,可以用于调用其他构造方法,访问实例变量,访问实例方法
  • final:修饰类变量、实例变量,表示只能被赋值一次,final 也可以修饰实例方法和局部变量(后续章节介绍)。

本节介绍的 Point 类,其属性只有基本数据类型,下节我们介绍类的组合,以表达更为复杂的概念。

3.2 类的组合

程序是用来解决现实问题的,将现实中的概念映射为程序中的概念,是初学编程过程中的一步跨越。本节通过一些例子来演示,如何将一些现实概念和问题,通过类以及类的组合来表示和处理。

我们先介绍两个基础类 String 和 Date,他们都是 Java API 中的类,分别表示文本字符串和日期。

3.2.1 基础类

3.2.1.1 String

String 是 Java API 中的一个类,表示多个字符,即一段文本或字符串,它内部是一个 char 的数组,它提供了若干方法用于方便操作字符串

String 可以用一个字符串常量初始化,字符串常量用双引号括起来(注意与字符常量区别,字符常量是用单引号),例如,如下语句声明了一个 String 变量 name,并赋值为"老马说编程"。

String name = "老马说编程";

String 类提供了很多方法,用于操作字符串。在 Java 中,由于 String 用的非常普遍,Java 对它有一些特殊的处理,本节暂不介绍这些内容,只是把它当做一个表示字符串的类型来看待。

3.2.1.2 Date

Date 也是 Java API 中的一个类,表示日期和时间,它内部是一个 long 类型的值,它也提供了若干方法用于操作日期和时间

用无参的构造方法新建一个 Date 对象,这个对象就表示当前时间。

Date now = new Date();

日期和时间处理是一个比较长的话题,我们留待后续章节详解,本节我们只是把它当做表示日期和时间的类型来看待。

3.2.2 图形类

3.2.2.1 拓展 Point

我们先扩展一下 Point 类,在其中增加一个方法,计算到另一个点的距离,代码如下:

public double distance(Point p){
    
    
    return Math.sqrt(Math.pow(x-p.getX(), 2)
            +Math.pow(y-p.getY(), 2));
}

3.2.2.1 线 Line

在类型 Point 中,属性 x,y 都是基本类型,但类的属性也可以是类,我们考虑一个表示线的类,它由两个点组成,有一个实例方法计算线的长度,代码如下:

public class Line {
    
    
    private Point start;
    private Point end;
    
    public Line(Point start, Point end){
    
    
        this.start= start;
        this.end = end;
    }
    
    public double length(){
    
    
        return start.distance(end);
    }
}

Line 由两个 Point 组成,在创建 Line 时这两个 Point 是必须的,所以只有一个构造方法,且需传递这两个点,length 方法计算线的长度,它调用了 Point 计算距离的方法获取线的长度。可以看出,在设计线时,我们考虑的层次是点,而不考虑点的内部细节。每个类封装其内部细节,对外提供高层次的功能,使其他类在更高层次上考虑和解决问题,是程序设计的一种基本思维方式

使用这个类的代码如下所示:

public static void main(String[] args) {
    
    
    Point start = new Point(2,3);
    Point end = new Point(3,4);
    
    Line line = new Line(start, end);
    System.out.println(line.length());
}

这个也很简单。我们再说明一下内存布局,line 的两个实例成员都是引用类型,引用实际的 point,整体内存布局大概如图 3-1 所示。

在这里插入图片描述

start, end, line 三个引用型变量分配在栈中,保存的是实际内容的地址,实际内容保存在堆中,line 的两个实例变量还是引用,同样保存的是实际内容的地址

3.2.3 用类描述电商概念

接下来,我们用类来描述一下电商系统中的一些基本概念,电商系统中最基本的有产品、用户和订单:

  • 产品:有产品唯一 Id、名称、描述、图片、价格等属性。
  • 用户:有用户名、密码等属性。
  • 订单:有订单号、下单用户、选购产品列表及数量、下单时间、收货人、收货地址、联系电话、订单状态等属性。

当然,实际情况可能非常复杂,这是一个非常简化的描述。

这是产品类 Product 的代码:

public class Product {
    
    
    //唯一id
    private String id; 
    
    //产品名称 
    private String name; 
    
    //产品图片链接    
    private String pictureUrl; 
    
    //产品描述
    private String description;
    
    //产品价格
    private double price;
}

我们省略了类的构造方法,以及属性的 getter/setter 方法,下面大部分示例代码也都会省略。

这是用户类 User 的代码:

public class User {
    
    
    private String name;
    private String password;
}

一个订单可能会有多个产品,每个产品可能有不同的数量,我们用订单条目 OrderItem 这个类来描述单个产品及选购的数量,代码如下所示:

public class OrderItem {
    
    
    //购买产品
    private Product product;
    
    //购买数量
    private int quantity;
    
    public OrderItem(Product product, int quantity) {
    
    
        this.product = product;
        this.quantity = quantity;
    }

    public double computePrice(){
    
    
        return product.getPrice()*quantity;
    }
}

OrderItem 引用了产品类 Product,我们定义了一个构造方法,以及计算该订单条目价格的方法。

下面是订单类 Order 的代码:

public class Order {
    
    
    //订单号
    private String id;
    
    //购买用户
    private User user;
    
    //购买产品列表及数量
    private OrderItem[] items;
    
    //下单时间
    private Date createtime;
    
    //收货人
    private String  receiver;
    
    //收货地址
    private String address;
    
    //联系电话
    private String phone;
    
    //订单状态
    private String status;
    
    public double computeTotalPrice(){
    
    
        double totalPrice = 0;
        if(items!=null){
    
    
            for(OrderItem item : items){
    
    
                totalPrice+=item.computePrice();
            }
        }
        return totalPrice;
    }
}

Order 类引用了用户类 User,以及一个订单条目的数组 orderItems,它定义了一个计算总价的方法。这里用一个 String 类表示状态 status,更合适的应该是枚举类型,枚举我们后续文章再介绍。

以上类定义是非常简化的了,但是大概演示了将现实概念映射为类以及类组合的过程,这个过程大概就是,想想现实问题有哪些概念,这些概念有哪些属性,哪些行为,概念之间有什么关系,然后定义类、定义属性、定义方法、定义类之间的关系,大概如此。概念的属性和行为可能是非常多的,但定义的类只需要包括那些与现实问题相关的就行了

3.2.4 用类描述人之间的血缘关系

上面介绍的图形类和电商类只会引用别的类,但一个类定义中还可以引用它自己。比如我们要描述人以及人之间的血缘关系,我们用类 Person 表示一个人,它的实例成员包括其父亲、母亲、和孩子,这些成员也都是 Person 类型。

下面是代码:

public class Person {
    
    
    //姓名
    private String name;
    
    //父亲
    private Person father;
    
    //母亲
    private Person mother;
    
    //孩子数组
    private Person[] children;

    public Person(String name) {
    
    
        this.name = name;
    }
}

这里同样省略了 setter/getter 方法。对初学者,初看起来,这是比较难以理解的,有点类似于函数调用中的递归调用,这里面的关键点是,实例变量不需要一开始都有值。我们来看下如何使用。

public static void main(String[] args){
    
    
    Person laoma = new Person("老马");
    Person xiaoma = new Person("小马");
    
    xiaoma.setFather(laoma);
    laoma.setChildren(new Person[]{
    
    xiaoma});
    
    System.out.println(xiaoma.getFather().getName());
}

这段代码先创建了老马(laoma),然后创建了小马(xiaoma),接着调用 xiaoma 的 setFather 方法和 laoma 的 setChildren 方法设置了父子关系。内存中的布局大概如图 3-2 所示。

在这里插入图片描述

3.2.5 目录和文件

接下来,我们介绍两个类 MyFile 和 MyFolder,分别表示文件管理中的两个概念,文件和文件夹。文件和文件夹都有名称、创建时间、父文件夹,根文件夹没有父文件夹,文件夹还有子文件列表和子文件夹列表。

下面是文件类 MyFile 的代码:

public class MyFile {
    
    
    //文件名称
    private String name;
    
    //创建时间
    private Date createtime;
    
    //文件大小
    private int size;
    
    //上级目录
    private MyFolder parent;

    //其他方法 ....
    
    public int getSize() {
    
    
        return size;
    }
}

下面是 MyFolder 的代码:

public class MyFolder {
    
    
    //文件夹名称
    private String name;
    
    //创建时间
    private Date createtime;
        
    //上级文件夹
    private MyFolder parent;
    
    //包含的文件
    private MyFile[] files;
    
    //包含的子文件夹
    private MyFolder[] subFolders;
    
    public int totalSize(){
    
    
        int totalSize = 0;
        if(files!=null){
    
    
            for(MyFile file : files){
    
    
                totalSize+=file.getSize();
            }
        }
        if(subFolders!=null){
    
    
            for(MyFolder folder : subFolders){
    
    
                totalSize+=folder.totalSize();
            }
        }
        return totalSize;
    }
    //其他方法...
}

MyFile 和 MyFolder,我们都省略了构造方法、settter/getter 方法,以及关于父子关系维护的代码,主要演示实例变量间的组合关系。两个类之间可以互相引用,MyFile 引用了 MyFolder,而 MyFolder 也引用了 MyFile,这个是没有问题的。因为正如之前所说,这些属性不需要一开始就设置,也不是必须设置的。另外,演示了一个递归方法 totalSize(),返回当前文件夹下所有文件的大小,这是使用递归函数的一个很好的场景。

3.2.6 一些说明

类中定义哪些变量,哪些方法是与要解决的问题密切相关的,本节中并没有特别强调问题是什么,定义的属性和方法主要用于演示基本概念,实际应用中应该根据具体问题进行调整。

类中实例变量的类型可以是当前定义的类型,两个类之间可以互相引用,这些初听起来可能难以理解,但现实世界就是这样的,创建对象的时候这些值不需要一开始都有,也可以没有,所以是没有问题的。

类之间的组合关系,在 Java 中实现的都是引用,但在逻辑关系上,有两种明显不同的关系,一种是包含,另一种就是单纯引用。比如说,在订单类 Order 中,Order 与 User 的关系就是单纯引用,User 是独立存在的,而 Order 与 OrderItem 的关系就是包含,OrderItem 总是从属于某一个 Order。

3.2.7 小结

对初学编程的人来说,不清楚如何用程序概念表示现实问题,本节通过一些简化的例子来解释,如何将现实中的概念映射为程序中的类。

分解现实问题中涉及的概念,以及概念间的关系,将概念表示为多个类,通过类之间的组合,来表达更为复杂的概念以及概念间的关系,是计算机程序的一种基本思维方式

3.3 代码的组织机制

使用任何语言进行编程都有一个类似的问题,那就是如何组织代码,具体来说,如何避免命名冲突?如何合理组织各种源文件?如何使用第三方库?各种代码和依赖库如何编译连接为一个完整的程序?

本节就来讨论 Java 中的解决机制,具体包括包、jar 包、程序的编译与连接等。

3.3.1 包的概念

使用任何语言进行编程都有一个相同的问题,就是命名冲突。程序一般不全是一个人写的,会调用系统提供的代码、第三方库中的代码、项目中其他人写的代码等,不同的人就不同的目的可能定义同样的类名/接口名,Java 中解决这个问题的方法就是包

即使代码都是一个人写的,将很多个关系不太大的类和接口都放在一起,也不便于理解和维护,Java 中组织类和接口的方式也是包

包是一个比较容易理解的概念,类似于电脑中的文件夹,正如我们在电脑中管理文件,文件放在文件夹中一样,类和接口放在包中,为便于组织,文件夹一般是一个层次结构,包也类似。

包有包名,这个名称以号(.)分隔表示层次结构。比如说,我们之前常用的 String 类,就位于包 java.lang 下,其中 java 是上层包名, lang 是下层包名,带完整包名的类名称为其完全限定名,比如 String 类的完全限定名为 java.lang.String。Java API 中所有的类和接口都位于包 java 或 javax 下,java 是标准包,javax 是扩展包。

接下来,我们讨论包的细节,从声明类所在的包开始。

3.3.1.1 声明类所在的包

语法

我们之前定义类的时候没有定义其所在的包,默认情况下,类位于默认包下,使用默认包是不建议的,文章中使用默认包只是简单起见。

定义类的时候,应该先使用关键字 package,声明其包名,如下所示:

package shuo.laoma;

public class Hello {
    
    
    //类的定义
}

以上声明类 Hello 的包名为 shuo.laoma,包声明语句应该位于源代码的最前面,前面不能有注释外的其他语句。

包名和文件目录结构必须匹配,如果源文件的根目录为 E:\src\,则上面的 Hello 类对应的文件 Hello.java,其全路径就应该是 E:\src\shuo\laoma\Hello.java。如果不匹配,Java 会提示编译错误。

命名冲突

为避免命名冲突,Java 中命名包名的一个惯例是使用域名作为前缀。因为域名是唯一的,一般按照域名的反序来定义包名,比如,域名是:apache.org,包名就以 org.apache 开头。

没有域名的,也没关系,使用一个其他代码不太会用的包名即可,比如本文使用的 “shuo.laoma”,表示"老马说编程"中的例子。

如果代码需要公开给其他人用,最好有一个域名以确保唯一性,如果只是内部使用,则确保内部没有其他代码使用该包名即可。

组织代码

除了避免命名冲突,包也是一种方便组织代码的机制。一般而言,同一个项目下的所有代码,都有一个相同的包前缀,这个前缀是唯一的,不会与其他代码重名,在项目内部,根据不同目的再细分为子包,子包可能又会分为子包,形成层次结构,内部实现一般位于比较底层的包。

包可以方便模块化开发,不同功能可以位于不同包内,不同开发人员负责不同的包。包也可以方便封装,供外部使用的类可以放在包的上层,而内部的实现细节则可以放在比较底层的子包内。

3.3.1.2 通过包实用类

同一个包下的类之间互相引用是不需要包名的,可以直接使用。但如果类不在同一个包内,则必须要知道其所在的包,使用有两种方式,一种是通过类的完全限定名,另外一种是将用到的类引入到当前类

只有一个例外,java.lang 包下的类可以直接使用,不需要引入,也不需要使用完全限定名,比如 String 类,System 类,其他包内的类则不行。

比如说,使用 Arrays 类中的 sort 方法,通过完全限定名,可以这样使用:

int[] arr = new int[]{
    
    1,4,2,3};
java.util.Arrays.sort(arr);
System.out.println(java.util.Arrays.toString(arr));

显然,这样比较繁琐,另外一种就是将该类引入到当前类,引入的关键字是 import,import 需要放在 package 定义之后,类定义之前,如下所示:

package shuo.laoma;
import java.util.Arrays;

public class Hello {
    
    
    public static void main(String[] args) {
    
    
        int[] arr = new int[]{
    
    1,4,2,3};
        Arrays.sort(arr);
        System.out.println(Arrays.toString(arr));
    }
}

import 时,可以一次将某个包下的所有类引入,语法是使用 .* 。

比如,将 java.util 包下的所有类引入,语法是:

import java.util.*

需要注意的是,这个引入不能递归,它只会引入 java.util 包下的直接类,而不会引入 java.util 下嵌套包内的类,比如,不会引入包 java.util.zip 下面的类。试图嵌套引入的形式也是无效的,如

 import java.util.*.*

在一个类内,对其他类的引用必须是唯一确定的,不能有重名的类,如果有,则通过 import 只能引入其中的一个类,其他同名的类则必须要使用完全限定名。

引入类是一个比较繁琐的工作,不过,大多数 Java 开发环境都提供工具自动做这件事,比如,在 Eclipse 中,通过菜单 “Source->Organize Imports” 或对应的快捷键 ctrl+shift+O 就可以自动管理引入类。

3.3.1.3 包范围可见性

前面章节我们介绍过,对于类、变量和方法,都可以有一个可见性修饰符,public/private/protected,而上节,我们提到可以不写修饰符。如果什么修饰符都不写,它的可见性范围就是同一个包内,同一个包内的其他类可以访问,而其他包内的类则不可以访问

需要说明的是,同一个包指的是同一个直接包,子包下的类并不能访问。比如说,类 shuo.laoma.Hello 和 shuo.laoma.inner.Test,其所在的包 shuo.laoma 和 shuo.laoma.inner 是两个完全独立的包,并没有逻辑上的联系,Hello 类和 Test 类不能互相访问对方的包可见性方法和属性。

另外,需要说明的是 protected 修饰符,protected 可见性包括包可见性,也就是说,声明为 protected,不仅表明子类可以访问,还表明同一个包内的其他类可以访问,即使这些类不是子类也可以

总结来说,可见性范围从小到大是:

private < 默认(包) < protected < public

3.3.2 jar 包

为方便使用第三方代码,也为了方便我们写的代码给其他人使用,各种程序语言大多有打包的概念,打包的一般不是源代码,而是编译后的代码,打包将多个编译后的文件打包为一个文件,方便其他程序调用。

在 Java 中,编译后的一个或多个包的 Java class 文件可以打包为一个文件,Java 中打包命令为 jar,打包后的文件后缀为 .jar,一般称之为 jar 包

可以使用如下方式打包,首先到编译后的 java class 文件根目录,然后运行如下命令打包:

jar -cvf <包名>.jar <最上层包名>

比如,对前面介绍的类打包,如果 Hello.class 位于 E:\bin\shuo\laoma\Hello.class,则可以到目录 E:\bin 下,然后运行:

jar -cvf hello.jar shuo

hello.jar 就是 jar 包,jar 包其实就是一个压缩文件,可以使用解压缩工具打开。

Java 类库、第三方类库都是以 jar 包形式提供的。如何使用 jar 包呢?将其加入到类路径(classpath)中即可

类路径是什么呢?我们下面来看。

3.3.3 程序的编译与连接

从 Java 源代码到运行的程序,有编译和连接两个步骤。编译是将源代码文件变成一种字节码,后缀是 .class 的文件,这个工作一般是由 javac 这个命令完成的。连接是在运行时动态执行的,.class 文件不能直接运行,运行的是 Java 虚拟机,虚拟机听起来比较抽象,执行的就是 java 这个命令,这个命令解析 .class 文件,转换为机器能识别的二进制代码,然后运行,所谓连接就是根据引用到的类加载相应的字节码并执行。

Java 编译和运行时,都需要以参数指定一个 classpath,即类路径。类路径可以有多个,对于直接的 class 文件,路径是 class 文件的根目录,对于 jar 包,路径是 jar 包的完整名称(包括路径和 jar 包名),在 Windows 系统中,多个路径用分号分隔,在其他系统中,以冒号分隔。

在 Java 源代码编译时,Java 编译器会确定引用的每个类的完全限定名,确定的方式是根据 import 语句和 classpath。如果 import 的是完全限定类名,则可以直接比较并确定。如果是模糊导入(import 带 .* ),则根据 classpath 找对应父包,再在父包下寻找是否有对应的类。如果多个模糊导入的包下都有同样的类名,则 Java 会提示编译错误,此时应该明确指定 import 哪个类。

Java 运行时,会根据类的完全限定名寻找并加载类。寻找的方式就是在类路径中寻找,如果是 class 文件的根目录,则直接查看是否有对应的子目录及文件,如果是 jar 文件,则首先在内存中解压文件,然后再查看是否有对应的类。

总结来说,import 是编译时概念,用于确定完全限定名,在运行时,只根据完全限定名寻找并加载类,编译和运行时都依赖类路径,类路径中的 jar 文件会被解压缩用于寻找和加载类。

3.3.4 小结

本节介绍了 Java 中代码组织的机制,包和 jar 包,以及程序的编译和连接。将类和接口放在合适的具有层次结构的包内,避免命名冲突,代码可以更为清晰,便于实现封装和模块化开发,通过 jar 包使用第三方代码,将自身代码打包为 jar 包供其他程序使用,这些都是解决复杂问题所必需的。

猜你喜欢

转载自blog.csdn.net/bm1998/article/details/107730158