第二部分 面向对象~~第3章 类的基础

230 阅读39分钟

程序主要就是数据以及对数据的操作,为方便理解和操作,高级语言使用数据类型这个概念,不同的数据类型有不同的特征和操作,Java 定义了8种基本数据类型:4种整型byte、short、int、long, 两种浮点类型float、double, 一种真假类型boolean,一种字符类型char。其他类型的数据都用类这个概念表达。

类比较复杂,本章主要介绍类的一些基本知识,具体分类3节:3.1节主要介绍类的额基本概念;3.2节主要通过一些例子来演示如何将一些现实概念和问题通过类以及类的组合来表示和处理;3.3节介绍类代码的组织机制。

3.1 类的基本概念

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

3.1.1 函数容器

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

要使用这些函数,直接在前面加Math.即可,例如Math.abs(-1) 返回1。这些函数都有相同的修饰符:public static。 static表示类方法,也叫静态方法,与类方法相对的是实例方法。实例方法没有static修饰符,必须通过实例或者对象调用,而类方法可以直接通过类名进行调用,不需要创建实例。public表示这些函数是公开的,可以在任何地方被外部调用。

表3-1 Math类的常用函数

image.png

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

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

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

表3-2 Arrays类的一些函数

image.png

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

3.1.2 自定义数据类型

我们将类看作自定义数据类型,所谓自定义数据类型就是除了8种基本类型以外的其他类型,用于表示和处理基本类型以外的其他数据。一个数据类型由其包含的属性以及该类型可以进行的操作组成,属性又可以分为是类型本身具有的属性,还是一个具体实例具有的属性,同样,操作也可以分为是类型本身可以进行的操作,还是一个具体实例可以进行的操作。

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

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

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

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

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

1. 类变量

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

    public static final double E = 2.718281818284590452354;
    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都不是必需的。

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。但修饰符可以没有(即留空),表示一种包级别的可见性,关于包,3.3节再介绍。另外,类可以定义在一个类的内部,这时可以使用private修饰符,关于内部类我们在第5章介绍。

    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类型的变量声明和赋值,它可以分为两部分:

    1  Point p;
    2  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, 通过实例方法来操作变量,如代码清单3-1所示。

代码清单3-1 Point类定义--实例变量定义为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(int x){
            return x;
        }
        public int getY(int y){
            return y;
        }
        public double distance(){
            return Math.sqrt(x * x + y * y);
        }
    }

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

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

这4个方法看上去非常多余,直接访问变量不是更简洁吗?而且第一章我们也说过,函数调用是有成本的。在这个例子中,意义确实不太大,实际上,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;
    }

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

  1. 名称是固定的,与类名相同。这也容易理解,靠这个用户和Java系统就都能容易地知道哪些是构造方法
  2. 没有返回值,也不能有返回值。构造方法隐含的返回值就是实例本身

与普通方法一样,构造方法也可以重载。第二个构造方法是比较容易理解的,使用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操作的一部分

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

1. 默认构造方法

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

    Point p = new Point();

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

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

2. 私有构造方法

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

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

3.1.8 类和对象的生命周期

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

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

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

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

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

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

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

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

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

3.1.9 小结

本节我们主要从自定义数据类型的角度介绍了类,谈了如何定义和使用类。自定义类型由类变量、类方法、实例变量和实例方法组成,为方便对实例变量赋值,介绍了构造方法,最后介绍了类和对象的生命周期。

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

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

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

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

3.2 类的组合

程序是用来解决现实问题的,将现实中的概念映射为程序中的概念,是初学编程过程中的一步跨越。本节通过一些例子来演示如何将一些现实概念和问题通过类以及类的组合来表示和处理,涉及的概念包括图形处理、电商、人之间的血缘关系以及计算机中文件和目录。

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

3.2.1 String和Date

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

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

    String name = "老马说编程";

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

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

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

    Date now = new Date();

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

3.2.2 图形类

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

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

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

代码清单3-2 表示线的类Line

    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所示。

1701701560423.png

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

3.2.3 用类描述电商概念

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

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

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

产品类Product如代码清单3-3所示。

代码清单3-3 表示产品的类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这个类来描述单个产品及选购的数量,如代码清单3-4所示。

代码清单3-4 表示订单条目的类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如代码清单3-5所示。

代码清单3-5 表示订单的类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;
        //订单状态
        priate String status;
        public double computeTotalPrice(){
            double totalPrice = 0;
            if(items!=null){
                for(OrderItem item : items){
                    totalPrice += item.computePrice();
                }
            }
            return totalPrice;
        } 
    }

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

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

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

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

代码清单3-6 表示人的类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.getFatcher().getName());
    }

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

image.png

3.2.5 目录和文件

接下来,我们介绍两个类MyFile和MyFolder,分别表示文集爱你管理中的两个概念:文件和文件夹。文件和文件夹都有名称、创建时间、父文件夹,根文件夹没有父文件夹,文件夹还有子文件列表和子文件夹列表。文集爱你类MyFile入代码清单3-7所示。

代码清单3-7 文件类MyFile

    public class MyFile{
        //文件名称
        private String name;
        //创建时间
        private Date createTime;
        //文件大小
        private int size;
        //上级目录
        private MyFolder parent;
        //其他方法...
        public int getSize(){
            return size;
        }
    
    }

文件夹类MyFolder如代码清单3-8所示。

代码清单3-8 文件夹类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(subFoloders!=null){
                for(MyFolder folder : subFolders){
                    totalSize+=folder.totalSize();
                }
            }
            return totalSize;
        }
        //其他方法....
    }

MyFile和MyFolder都省略了构造方法、setter/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是扩展包。

接下来,我们讨论包的细节,包括包的声明、使用和包范围可见性。

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

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

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

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就可以自动管理引用的类。

有一种特殊类型的导入,称为静态导入,它有一个static关键字,可以直接导入类的公共静态方法和成员。看个例子:

    import static java.lang.System.out; //导入静态变量out
    public class Hello{
        public static void main(String[] args){
            int[] arr = new int[]{1,4,2,3};
            sort(arr); //可以直接使用Arrays中的sort方法
            out.println(Arrays.toString(arr)); //可以直接使用out变量
        }
    }

静态导入不应过度使用,否则难以区分访问的是哪个类的代码。

3.包范围可见性

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

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

除了public和private修饰符,还有一个与继承有关的修饰符protected。关于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带.* ), 则根据classpath找对应父包,再在父包下寻找是否有对应的类。如果多个模糊导入的包下都有同样的类型,则Java会编译错误,此时应该明确指定导入哪个类。

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

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

3.3.4 小结

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

在Java9中,清晰地引入了模块的概念,JDK和JRE都按模块化进行了重构,传统的组织机制依然是支持的,但新的引用可以使用模块。一个应用可由多个模块组成,一个模块可由多个包组成。模块之间可以有一定的依赖关系,一个模块可以导出包给其他模块用,可以提供服务给其他模块用,也可以使用其他模块提供的包,调用其他模块提供的服务。对于复杂的应用,模块化有很多好处,比如更强的封装、更为可靠的配置、更为松散的耦合、更动态灵活等。模块是一个很大的主题,限于篇幅,我们就不详细介绍了。

至此,关于类的基础知识就介绍完了。类之间除了组合关系,还有一种非常重要的关系,那就是继承,我们下章来探讨。