编程基础和二进制

数据类型和变量

基本类型

  • 整型类型: | 类型名 | 取值范围 | 占用内存 | | —- | —- | —- | | byte | -2^7 ~ (2^7) - 1 即 -128~127 | 1B | | short | -2^15 ~ (2^15) - 1 即 -32768~32767 | 2B | | int | -2^31 ~ (2^31) - 1 | 4B | | long | -2^63 ~ (2^63) - 1 | 8B |

在给long类型赋值时,如果常量超过了int的表示范围,需要在常量后面加大写或小写字母L,即L或1

因为数字常量默认为是int类型

  • 小数类型 | 类型名 | 取值范围 | 占用内存 | | —- | —- | —- | | float | 1.4E-45 ~ 3.4E+38
    -3.4E+38~-1.4E-45 | 4B | | double | 4.9E-324~1.7E+308
    -1.7E+308~-4.9E-324 | 8B |

E表示以10为底的指数,E后面的+号和-号代表正指数和负指数,例如:14E-45表示14乘以10的-45次方

对于 double,直接把熟悉的小数表示赋值给变量即可

但对于float,需要在数字后面加大写字母F或小写字母f

这是由于小数常量默认是 double类型

  • 真假类型boolean:true/false
  • 字符类型char,占两个字节,用单引号

数组

  • 赋值方式
  1. 1. int[] arr = {1,2,3};
  2. 2. int[] arr = new int[]{1,2,3};
  3. 3. int[] arr = new int[3];
  4. //即使没有给每个元素赋值,每个元素也都有一个默认值,
  5. //这个默认值跟数组类型有关,数值类型的值为0, boolean为false,char为空字符
  6. arr[0]=1; arr[1]=2; arr[2]=3;

注意:

  1. 数组长度虽然可以动态确定,但定了之后就不可以变 数组有一个 length属性,但只能读,不能改

  2. 不能在给定初始值的同时给定长度,即如下格式是不允许的:

    1. int[] arr =new int[3]{1,2,3}


理解:因为初始值已经决定了长度,再给个长度,如果还不一致,计算机将无所适从

数组与基本类型的不同

  • 一个基本类型変量,内存中只会有一块对应的内存空间

  • 但数组有两块:一块用于存储数组内容本身,另一块用于存储内容的位置

Java编程的逻辑 - 图1

  • 给数组变量赋值和给数组中元素赋值是两回事,给数组中元素赋值是改变数组内容
  • 而给数组变量赋值则会让变量指向一个不同的位置
  • 数组的长度是不可以变的,不可变指的是数组的内容空间,一经分配,长度就不能再变
  • 但可以改变数组变量的值,让它指向一个长度不同的空间

基本运算

算术运算

  • 整数相除不是四舍五入,而是直接舍去小数位,如:
  1. double d = 10/4;//结果是2,不是2.5
  2. //正确写法
  3. a) double d = 10/4.0;
  4. b) double d = 10/(double)4;
  • 自增与自减
    如果只是对自己操作,这两种形式也没什么差别,区别在于还有其他操作的时候 放在变量后
    (a++)是先用原来的值进行其他操作,然后再对自己做修改,而放在变量前(++a)是先对自己做修
    改,再用修改后的值进行其他操作

  • 比较运算
    对于数组,==判断的是两个变量指向的是不是同一个数组,而不是两个数组的元素内容是否一样,即使两个数组的内容是一样的,但如果是两个不同的数组,==依然会返回 false,如:

    1. int[] a = new int[] {1,2,3};
    2. int[] b = new int[] {1,2,3};
    3. //a==b的结果是false


如果需要比较数组的内容是否一样,需要逐个比较里面存储的每个元素

  • 逻辑运算
    短路与(&&):和&类似,不同之处:&两边都计算,&&左边为假则右边的不计算
    短路或(||):与|类似,不同之处同上

条件执行

条件语句为tue,则执行括号{}中的代码,如果后面没有括号,则执行后面第一个分号(;)前的代码

if的陷阱:初学者有时会忘记在if后面的代码块中加括号,有时希望执行多条语句而没有加括号,结果只会执行第一条语句,建议所有if后面都加括号

if/else陷阱:需要注意的是,在 if/else中,判断的顺序是很重要的,后面的判断只有在前面的条件为 false的时候オ会执行

  • switch
  1. switch(表达式){
  2. case 1
  3. 代码1; break;
  4. case 2
  5. 代码2; break;
  6. ...
  7. ...
  8. case n
  9. 代码n; break;
  10. default:代码n+1
  11. }

根据表达式的值找匹配的case,找到后执行后面的代码,碰到 breakl时结束,如果没有找到匹配的值则执行 default后的语句 表达式值的数据类型只能是byte、 short、int、char、枚举和 String(Java7以后)

简单总结下:

  • 单一条件满足时,执行某操作使用if;
  • 根据一个条件是否满足执行不同分支使用 if/else;
  • 表达复杂的条件使用if/else;
  • 条件赋值使用三元运算符
  • 根据某个表达式的值不同执行不同的分支使用 switch
  • 从逻辑上讲, if/else、if/ else if/else、三元运算符、 switchi都可以只用if代替,但使用不同的语法表达更简洁,在条件比较多的时侯, switch从性能上看也更高

swich性能高的原因

switch的转换和具体系统实现有关 如果分支比较少,可能会转换为跳转指令

如果分支比较多,使用条件跳转会进行很多次的比较运算,效率比较低,可能会使用一种更为高效的方式,叫跳转表 跳转表是一个映射表,存储了可能的值以及要跳转到的地址

跳转表高效的原因:因为其中的值必须为整数,且按大小顺序排序

  • 按大小排序的整数可以使用高效的二分査找
  • 如果值是连续的,则跳转表还会进行特殊优化,优化为一个数组,连找都不用找了,值就是数组的下标索引,直接根据值就可以找到跳转的地址
  • 即使值不是连续的,但数字比较密集,差的不多,编译器也可能会优化为一个数组型的跳转表,没有的值指向 default分支
  • 程序源代码中的case值排列不要求是排序的,编译器会自动排序

switch表达式的数据类型限制的原因:

  • 其中byte/ short/int本来就是整数,char本质上也是整数
  • 不可以使用long,为什么呢?跳转表值的存储空间一般为32位,容纳不下long
  • 枚举类型也有对应的整数
  • String用于 switche时也会转换为整数
  • String是通过hash Code方法转换为整数的,但不同 Stringl的 hashCode可能相同,跳转后会再次根据 String的内容进行比较判断

循环的4中形式

  • while

  • do-while:如果不管条件语句是什么,代码块都会至少执行一次,则可以使用do/ while循环

  • for

  • foreach
    foreach不是一个关键字,它使用冒号:,冒号前面是循环中的每个元素,包括数据类型和变量名称,冒号后面是要遍历的数组或集合(第9章介绍),每次循环 element都会自动更新 对于不需要使用索引变量,只是简单遍历的情况, foreach语法上更为简洁

    1. int[] arr = {1,2,3,4};
    2. for(int element : arr){
    3. System.out.println(element);
    4. }

循环控制

  • break:用于提前结束循环
  • continue:跳过循环体中剩下的代码,然后执行步进操作

函数

  1. public static void main(String[] args){
  2. ...
  3. }
  • main函数:表示程序的入口
  • String[] args 表示从控制台接受到的参数
  • Java中运行一个程序的时候,需要指定一个定义了main函数的类,Java会寻找main函数,并从main函数开始执行
  • 定义函数时声明参数,实际上就是定义变量,只是这些变量的值是未知的,调用函数时传递参数,实际上就是给函数中的变量赋值
  • 对于需要重复执行的代码,可以定义函数,然后在需要的地方调用,这样可以减少重复代码 对于复杂的操作,可以将操作分为多个函数,会使得代码更加易读

参数传递

有两类特殊类型的参数:数组和可变长度的参数

数组

数组作为参数与基本类型是不一样的,基本类型不会对调用者中的变量造成任何影响,但数组不是,在函数内修改数组中的元素会修改调用者中的数组内容

  1. public static void reset(int[] arr){
  2. for(int i=0;i<arr.length;i++){
  3. arr[i] = i;
  4. }
  5. }
  6. public static void main(String[] args) {
  7. int[] arr = {10,20,30,40};
  8. reset(arr);
  9. for(int i=0;i<arr.length;i++){
  10. System.out.println(arr[i]);
  11. }
  12. }

在reset函数内给参数数组元素赋值,在main函数中数组arr的值也会变

  • 数组变量有两块空间,一块用于存储数组内容本身,另一块用于存储内容的位置,给数组变量赋值不会影响原有的数组内容本身,而只会让数组变量指向一个不同的数组内容空间
  • 在上例中,函数参数中的数组变量arr和main函数中的数组变量arr存储的都是相同的位置,而数组内容本身只有一份数据,所以,在reset中修改数组元素内容和在main中修改是完全一样的

可变长度的参数

前面介绍的函数,参数个数都是固定的,但有时候可能希望参数个数不是固定的,比如求若干个数的最大值,可能是两个,也可能是多个。Java支持可变长度的参数

  1. public static int max(int min, int ... a){
  2. int max = min;
  3. for(int i=0;i<a.length;i++){
  4. if(max<a[i]){
  5. max = a[i];
  6. }
  7. }
  8. return max;
  9. }
  10. public static void main(String[] args) {
  11. System.out.println(max(0));
  12. System.out.println(max(0,2));
  13. System.out.println(max(0,2,4));
  14. System.out.println(max(0,2,4,5));
  15. }

可变长度参数的语法是在数据类型后面加三个点“… ”,在函数内,可变长度参数可以看作是数组 可变长度参数必须是参数列表中的最后一个,一个函数也只能有一个可变长度的参数

可变长度参数实际上会转换为数组参数,也就是说,函数声明max(int min,int… a)实际上会转换为max(int min, int[] a),在main函数调用max(0,2,4,5)的时候,实际上会转换为调用max(0, new int[]{2,4,5}),使用可变长度参数主要是简化了代码书写

函数重载

同一个类里,函数可以重名,但是参数不能完全一样,即要么参数个数不同,要么参数个数相同但至少有一个参数类型不一样 同一个类中函数名相同但参数不同的现象,一般称为函数重载 为什么需要函数重载呢?一般是因为函数想表达的意义是一样的,但参数个数或类型不一样

  • 调用的匹配过程:在只有一个函数的情况下,即没有重载,只要可以进行类型转换,就会调用该函数,在有函数重载的情况下,会调用最匹配的函数

函数调用的基本原理

栈的概念

栈一般是从高位地址向低位地址扩展,换句话说,栈底的内存地址是最高的,栈顶的是最低的

函数执行的基本原理

针对基本数据类型,函数中的参数和函数内定义的变量,都分配在栈中,这些变量只有在函数被调用的时候才分配,而且在调用结束后就被释放了

数组和对象的内存分配

  1. public class ArrayMax {
  2. public static int max(int min, int[] arr) {
  3. int max = min;
  4. for(int a : arr){
  5. if(a>max){
  6. max = a;
  7. }
  8. }
  9. return max;
  10. }
  11. public static void main(String[] args) {
  12. int[] arr = new int[]{2,3,4};
  13. int ret = max(0, arr);
  14. System.out.println(ret);
  15. }
  16. }

main函数新建了一个数组,然后调用函数max计算0和数组中元素的最大值,在程序执行到max函数的return语句之前的时候,内存中栈和堆的情况如图

Java编程的逻辑 - 图2

  • 对于数组和对象类型,它们都有两块内存,一块存放实际的内容一块存放实际内容的地址实际的内容空间一般不是分配在栈上的,而是分配在堆中,但存放地址的空间是分配在栈上

  • 存放地址的栈空间会随着入栈分配,出栈释放,但存放实际内容的堆空间不受影响 但说堆空间完全不受影响是不正确的,在这个例子中,当main函数执行结束,栈空间没有变量指向它的时候,Java系统会自动进行垃圾回收,从而释放这块空间

背后的二进制

二进制、十六进制

在Java中,可以使用如下代码查看Integer和Long的二进制、十六进制

  1. System.out.println(Integer.toBinaryString(a)); //二进制
  2. System.out.println(Integer.toHexString(a)); //十六进制
  3. System.out.println(Long.toBinaryString(a));//二进制
  4. System.out.println(Long.toHexString(a)); //十六进制

位移

位运算有移位运算和逻辑运算 移位有以下几种

  1. 左移:操作符为<<,向左移动,右边的低位补0,高位的就舍弃掉了,将二进制看作整数,左移1位就相当于乘以2。
  2. 无符号右移:操作符为>>>,向右移动,右边的舍弃掉,左边补0。
  3. 有符号右移:操作符为>>,向右移动,右边的舍弃掉,左边补什么取决于原来最高位是什么,原来是1就补1,原来是0就补0,将二进制看作整数,右移1位相当于除以2。

逻辑运算有以下几种

❑ 按位与&:两位都为1才为1。

❑ 按位或|:只要有一位为1,就为1。

❑ 按位取反~:1变为0,0变为1。

❑ 按位异或^:相异为真,相同为假

浮点数

如果想查看浮点数的具体二进制形式,在Java中,可以使用如下代码:

  1. Integer.toBinaryString(Float.floatToIntBits(value))
  2. Long.toBinaryString(Double.doubleToLongBits(value));

字符的编码与乱码

常见非Unicode编码

为了保持与ASCII码的兼容性,一般都是将最高位设置为1。也就是说,当最高位为0时,表示ASCII码,当为1时就是各个国家自己的字符 在这些扩展的编码中,在西欧国家中流行的是ISO 8859-1和Windows-1252,在中国是GB2312、GBK、GB18030和Big5。

Unicode编码

Unicode主要做了这么一件事,就是给所有字符分配了唯一数字编号 它并没有规定这个编号怎么对应到二进制表示,这是与上面介绍的其他编码不同的,其他编码都既规定了能表示哪些字符,又规定了每个字符对应的二进制是什么,而Unicode本身只规定了每个字符的数字编号是多少

  • 那编号怎么对应到二进制表示呢?有多种方案,主要有UTF-32、UTF-16和UTF-8。

  • 对于一个Unicode编号,具体怎么编码呢?首先将其看作整数,转化为二进制形式(去掉高位的0),然后将二进制位从右向左依次填入对应的二进制格式x中,填完后,如果对应的二进制格式还有没填的x,则设为0。
    Java编程的逻辑 - 图3

恢复乱码的方法

Java中处理字符串的类有String, String中有我们需要的两个重要方法

1)public byte[] getBytes(String charsetName),这个方法可以获取一个字符串的给定编码格式的二进制形式

2)public String(byte bytes[], String charsetName),这个构造方法以给定的二进制数组bytes按照编码格式charsetName解读为一个字符串

  1. public static void recover(String str) throws UnsupportedEncodingException{
  2. String[] charsets = new String[]{"windows-1252","GB18030","Big5","UTF-8"};
  3. for(int i=0;i<charsets.length;i++){
  4. for(int j=0;j<charsets.length;j++){
  5. if(i!=j){
  6. String s = new String(str.getBytes(charsets[i]),charsets[j]);
  7. System.out.println("---- ܻ原来的编码(A)假设是: "
  8. +charsets[j]+", 被错误解读为了(B): "+charsets[i]);
  9. System.out.println(s);
  10. System.out.println();
  11. }
  12. }
  13. }
  14. }

char的真正含义

在Java内部进行字符处理时,采用的都是Unicode,具体编码格式是UTF-16BE。

  • char本质上是一个固定占用两个字节的无符号正整数,这个正整数对应于Unicode编号,用于表示那个Unicode编号对应的字符

  • 由于固定占用两个字节,char只能表示Unicode编号在65 536以内的字符,而不能表示超出范围的字符 那超出范围的字符怎么表示呢?使用两个char。类Character、String有一些相关的方法

由于char本质上是一个整数,所以可以进行整数能做的一些运算,在进行运算时会被看作int,但由于char占两个字节,运算结果不能直接赋值给char类型,需要进行强制类型转换,这和byte、short参与整数运算是类似的 char类型的比较就是其Unicode编号的比较

查看char的二进制,用Integer方法:

  1. char c = '马';
  2. System.out.println(Integer.toBinaryString(c))

面向对象

类的基础

类的基本概念

在某些情况下,类也确实只是函数的容器,但类更多表示的是自定义数据类型

函数容器

Math类的常用函数(静态方法)

Java编程的逻辑 - 图4

类方法(静态方法):static修饰,直接通过类名进行调用,不需要创建实例

实例方法:通过实例调用,或者创建对象后进行对象调用

public:表示这些函数是公开的,可以在任何地方被外部调用

private:私有的,这个函数只能在同一个类内被别的函数调用,而不能被外部的类调用

通过private封装和隐藏内部实现细节,避免被误操作,是计算机程序的一种基本思维方式

Arrays类的一些函数

Java编程的逻辑 - 图5

自定义数据类型

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

❑ 类型本身具有的属性,通过类变量体现

❑ 类型本身可以进行的操作,通过类方法体现

❑ 类型实例具有的属性,通过实例变量体现

❑ 类型实例可以进行的操作,通过实例方法体现

术语 别名与解释
成员变量 类变量和实例变量,就是类的成员
静态变量或静态成员变量 类变量,static修饰的变量
成员方法 类方法和实例方法,都是类的成员
静态方法 类方法,static修饰的方法

类变量

类型本身具有的属性通过类变量体现,经常用于表示一个类型中的常量

  • 与类方法一样,类变量可以直接通过类名访问,如Math.PI

  • final在修饰变量的时候表示常量,即变量赋值后就不能再修改了,使用final可以避免误操作

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

实例变量和实例方法

实例,字面意思就是一个实际的例子

术语 解释
实例方法 具体的实例可以进行的操作
实例变量 具体的实例所具有的属性

实例方法和类方法的区别:

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

使用第一个类

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

分析:

  1. Point p = new Point();
  2. //可以分为两部分
  3. 1 Point p ;
  4. //Point p声明了一个变量,这个变量叫p,是Point类型的。
  5. //声明变量本身只会分配存放位置的内存空间,这块空间还没有指向任何实际内容。
  6. //因为这种变量和数组变量本身不存储数据,而只是存储实际内容的位置,它们也都称为引用类型的变量。
  7. 2 p = new Point();
  8. //创建了一个实例或对象,然后赋值给了Point类型的变量p,它至少做了两件事:
  9. //1)分配内存,以存储新对象的数据,对象数据包括这个对象的属性,具体包括其实例变量x和y。
  10. //2)给实例变量设置默认值,int类型默认值为0。

默认值:

  • 数值类型变量的默认值是0

  • boolean是false,

  • char是“\u0000”

  • 引用类型变量都是null

  • null是一个特殊的值,表示不指向任何对象,这些默认值可以修改。

一般而言,不应该将实例变量声明为public,而只应该通过对象的方法对实例变量进行操作

这也是为了减少误操作,直接访问变量没有办法进行参数检查和控制,而通过方法修改,可以在方法中进行检查

变量默认值

如果希望修改这个默认值,可以在定义变量的同时就赋值,或者将代码放入初始化代码块中,代码块用{}包围

如:

  1. int x = 1;
  2. int y;
  3. {
  4. y = 2;
  5. }

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

  1. static int STATIC_ONE = 1;
  2. static int STATIC_TWO;
  3. static
  4. {
  5. STATIC_TWO = 2;
  6. }

static{}是静态初始化代码块:静态初始化代码块在类加载的时候执行,这是在任何对象创建之前,且只执行一次

private变量

Point类定义——实例变量定义为private

  1. class Point {
  2. private int x;
  3. private int y;
  4. public void setX(int x) {
  5. this.x = x;
  6. }
  7. public void setY(int y) {
  8. this.y = y;
  9. }
  10. public int getX() {
  11. return x;
  12. }
  13. public int getY() {
  14. return y;
  15. }
  16. public double distance() {
  17. return Math.sqrt(x * x + y * y);
  18. }
  19. }

this表示当前实例,在语句this.x=x;中,this.x表示实例变量x,而右边的x表示方法参数中的x。

前面我们提到,在实例方法中,有一个隐含的参数,这个参数就是this,没有歧义的情况下,可以直接访问实例变量,在这个例子中,两个变量名都叫x,则需要通过加上this来消除歧义

set/get方法的意义

实际上,Java编译器一般也会将对这几个方法的调用转换为直接访问实例变量,而避免函数调用的开销。

但在很多情况下,通过函数调用可以封装内部数据避免误操作,我们一般还是不将成员变量定义为public。

构造方法

  1. public Point(){
  2. this(0,0);//调用Point(0, 0)构造函数
  3. //这个this调用必须放在第一行,这个规定也是为了避免误操作。
  4. //构造方法是用于初始化对象的,如果要调用别的构造方法,先调别的,然后根据情况自己再做调整,而如果自己先初始化了一部分,再调别的,自己的修改可能就被覆盖了
  5. }
  6. public Point(int x, int y){
  7. this.x = x;
  8. this.y = y;
  9. }

构造方法有一些特殊的地方:

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

构造方法:

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

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

类和对象的生命周期

  1. 当第一次通过new创建一个类的对象时,或者直接通过类名访问类变量和类方法时,Java会将类加载进内存,为这个类分配一块空间,这个空间会包括类的定义、它的变量和方法信息,同时还有类的静态变量,并对静态变量赋初始值
  2. 类加载进内存后,一般不会释放,直到程序结束。一般情况下,类只会加载一次,所以静态变量在内存中只有一份。
  3. 当通过new创建一个对象的时候,对象产生,在内存中,会存储这个对象的实例变量值,每做new操作一次,就会产生一个对象,就会有一份独立的实例变量。
  4. 每个对象除了保存实例变量的值外,可以理解为还保存着对应类型即类的地址,这样,通过对象能知道它的类,访问到类的变量和方法代码。
  5. 对象和数组一样,有两块内存,保存地址的部分分配在栈中,而保存实际内容的部分分配在堆中。栈中的内存是自动管理的,函数调用入栈就会分配,而出栈就会释放。
  6. 堆中的内存是被垃圾回收机制管理的,当没有活跃变量指向对象的时候,对应的堆空间就可能被释放,具体释放时间是Java虚拟机自己决定的。活跃变量就是已加载的类的类变量,以及栈中所有的变量。

小结

  1. public:可以修饰类、类方法、类变量、实例变量、实例方法、构造方法,表示可被外部访问。

  2. private:可以修饰类、类方法、类变量、实例变量、实例方法、构造方法,表示不可以被外部访问,只能在类内部被使用。

  3. static:修饰类变量和类方法,它也可以修饰内部类。

  4. this:表示当前实例,可以用于调用其他构造方法,访问实例变量,访问实例方法。

  5. final:修饰类变量、实例变量,表示只能被赋值一次,也可以修饰实例方法和局部变量。

类的组合

每个类封装其内部细节,对外提供高层次的功能,使其他类在更高层次上考虑和解决问题,是程序设计的一种基本思维方式。

类中实例变量的类型可以是当前定义的类型,两个类之间可以互相引用

代码的组织机制

包的概念

带完整包名的类名称为其完全限定名,比如String类的完全限定名为java.lang.String

Java API中所有的类和接口都位于包Java或javax下,Java是标准包,javax是扩展包

声明类所在的包

使用package声明其包名

  • 包名和文件目录结构必须匹配

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

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

通过包使用类

使用有两种方式:一种是通过类的完全限定名;另外一种是将用到的类引入当前类(import …)。

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

    • 需要注意的是,这个引入不能递归,它只会引入java.util包下的直接类,而不会引入java.util下嵌套包内的类,比如,不会引入包java.util.zip下面的类。试图嵌套引入的形式也是无效的,如import java.util..
  • 在一个类内,对其他类的引用必须是唯一确定的,不能有重名的类,如果有,则通过import只能引入其中的一个类,其他同名的类则必须要使用完全限定名

  • 有一种特殊类型的导入,称为静态导入,它有一个static关键字,可以直接导入类的公开静态方法和成员

      1. import java.util.Arrays;
      2. import static java.util.Arrays.*;//静态引入Arrays中的所有静态方法
      3. import static java.lang.System.out; //导入静态变量out
      4. public class Hello {
      5. public static void main(String[] args) {
      6. int[] arr = new int[]{1,4,2,3};
      7. sort(arr); //可直接使用Arrays中的sort方法
      8. out.println(Arrays.toString(arr)); //可直接使用out变量
      9. }
      10. }
  • 静态导入不应过度使用,否则难以区分访问的是哪个类的代码。

包范围可见性
  • 如果什么修饰符都不写,它的可见性范围就是同一个包内,同一个包内的其他类可以访问,而其他包内的类则不可以访问

    • 同一个包指的是同一个直接包,子包下的类并不能访问
  • protected可见性包括包可见性,也就是说,声明为protected不仅表明子类可以访问,还表明同一个包内的其他类可以访问,即使这些类不是子类也可以
  • 可见性范围从小到大是:private < 默认(包) < protected <public

jar包

打包方式:

  1. 首先到编译后的java class文件根目录,运行
    jar -cvf <包名>.jar <最上层包名>

  2. 如果Hello.class位于E:\bin\shuo\laoma\Hello.class,则可以到目录 E:\bin下,然后运行:
    jar -cvf <hello>.jar <shuo>
    hello.jar就是jar包,jar包其实就是一个压缩文件,可以使用解压缩工具打开。

  3. 使用jar包:加入类路径(classpath)

程序的编译与链接

  • 编译:将源代码文件变成扩展名是.class的一种字节码,由javac命令完成
  • 链接:在运行时动态执行,.class文件不能直接运行,运行的是Java虚拟机,所谓链接就是根据引用到的类加载相应的字节码并执行
  • Java编译和运行时,都需要以参数指定一个classpath,即类路径。类路径可以有多个,对于直接的class文件,路径是class文件的根目录
  • 对于jar包,路径是jar包的完整名称(包括路径和jar包名)
  • 编译时,Java编译器会确定引用的每个类的完全限定名,确定的方式是根据import语句和classpath

    • 如果导入的是完全限定类名,则可以直接比较并确定
    • 如果是模糊导入(import带.*),则根据classpath找对应父包,再在父包下寻找是否有对应的类
    • 如果多个模糊导入的包下都有同样的类名,则Java会提示编译错误,此时应该明确指定导入哪个类
  • 运行时,会根据类的完全限定名寻找并加载类,寻找的方式就是在类路径中寻找

    • 如果是class文件的根目录,则直接查看是否有对应的子目录及文件
    • 如果是jar文件,则首先在内存中解压文件,然后再查看是否有对应的类

小结

Java 9中,清晰地引入了模块的概念,JDK和JRE都按模块化进行了重构,传统的组织机制依然是支持的,但新的应用可以使用模块

  • 一个应用可由多个模块组成,一个模块可由多个包组成
  • 模块之间可以有一定的依赖关系,一个模块可以导出包给其他模块用,可以提供服务给其他模块用,也可以使用其他模块提供的包,调用其他模块提供的服务。对于复杂的应用,模块化有很多好处,比如更强的封装、更为可靠的配置、更为松散的耦合、更动态灵活等

类的继承

父类=基类

子类=派生类

基本概念

在Java中,所有类都有一个父类Object

根父类Object

Object没有定义属性,但定义了一些方法

Java编程的逻辑 - 图6

  1. Point p = new Point(2,3);
  2. System.out.println(p.toString());
  3. //输出
  4. Point@76f9aa66
  5. //源码
  6. public String toString() {
  7. return getClass().getName() + "@" + Integer.toHexString(hashCode());
  8. }
  9. //分析:
  10. 1.getClass().getName() 返回当前对象的类名,hashCode()返回一个对象的哈希值
  11. 2.整数默认情况下,通常是对象的内存地址值,Integer.toHexString(hashCode())返回这个哈希值的十六进制表示。

子类可以重写父类的方法,以反映自己的不同实现。所谓重写,就是定义和父类一样的方法,并重新实现。

  1. public class Point {
  2. private int x;
  3. private int y;
  4. public Point(int x, int y) {
  5. this.x = x;
  6. this.y = y;
  7. }
  8. public double distance(Point point){
  9. return Math.sqrt(Math.pow(this.x-point.getX(),2)
  10. +Math.pow(this.y-point.getY(), 2));
  11. }
  12. public int getX() {
  13. return x;
  14. }
  15. public int getY() {
  16. return y;
  17. }
  18. @Override
  19. public String toString() {
  20. return "("+x+","+y+")";
  21. }
  22. }
  23. Point p = new Point(2,3);
  24. System.out.println(p.toString())//输出(2,3)

toString()方法前面有一个@Override,这表示toString()这个方法是重写的父类的方法

继承演示

  1. //父类,图形类
  2. public class Shape {
  3. private static final String DEFAULT_COLOR = "black";
  4. private String color;
  5. public Shape() {
  6. this(DEFAULT_COLOR);
  7. }
  8. public Shape(String color) {
  9. this.color = color;
  10. }
  11. public String getColor() {
  12. return color;
  13. }
  14. public void setColor(String color) {
  15. this.color = color;
  16. }
  17. public void draw(){
  18. System.out.println("draw shape");
  19. }
  20. }
  21. //子类-圆
  22. public class Circle extends Shape {
  23. //中心点
  24. private Point center;
  25. //半径
  26. private double r;
  27. public Circle(Point center, double r) {
  28. this.center = center;
  29. this.r = r;
  30. }
  31. @Override
  32. public void draw() {
  33. System.out.println("draw circle at " +center.toString()+" with r "+r
  34. +", using color : "+getColor());
  35. }
  36. public double area(){
  37. return Math.PI*r*r;
  38. }
  39. }
  40. //子类-直线
  41. public class Line extends Shape {
  42. private Point start;
  43. private Point end;
  44. public Line(Point start, Point end, String color) {
  45. super(color);
  46. //super(color)表示调用父类的带color参数的构造方法。调用父类构造方法时,super必须放在第一行。
  47. this.start = start;
  48. this.end = end;
  49. }
  50. public double length(){
  51. return start.distance(end);
  52. }
  53. public Point getStart() {
  54. return start;
  55. }
  56. public Point getEnd() {
  57. return end;
  58. }
  59. @Override
  60. public void draw() {
  61. System.out.println("draw line from "
  62. + start.toString()+" to "+end.toString()
  63. + ",using color "+super.getColor());
  64. //super.getColor()表示调用父类的getColor方法,
  65. //当然不写super.也是可以的,因为这个方法子类没有同名的,没有歧义,
  66. //当有歧义的时候,通过super.可以明确表示调用父类的方法
  67. }
  68. }
  69. //带箭头直线
  70. public class ArrowLine extends Line {
  71. private boolean startArrow;
  72. private boolean endArrow;
  73. public ArrowLine(Point start, Point end, String color,
  74. boolean startArrow, boolean endArrow) {
  75. super(start, end, color);
  76. this.startArrow = startArrow;
  77. this.endArrow = endArrow;
  78. }
  79. @Override
  80. public void draw() {
  81. super.draw();
  82. //super.draw()表示调用父类的draw()方法,这时候不带super.是不行的,因为当前的方法也叫draw()
  83. if(startArrow){
  84. System.out.println("draw start arrow");
  85. }
  86. if(endArrow){
  87. System.out.println("draw end arrow");
  88. }
  89. }
  90. }
  91. //图形管理器
  92. public class ShapeManager {
  93. private static final int MAX_NUM = 100;
  94. private Shape[] shapes = new Shape[MAX_NUM];
  95. private int shapeNum = 0;
  96. //,在addShape方法中,参数Shape shape,声明的类型是Shape,
  97. //而实际的类型则分别是Circle、Line和ArrowLine
  98. public void addShape(Shape shape){
  99. if(shapeNum<MAX_NUM){
  100. shapes[shapeNum++] = shape;
  101. }
  102. }
  103. //ShapeManager使用一个数组保存所有的shape,在draw方法中调用每个shape的draw方法。
  104. //ShapeManager并不知道每个shape具体的类型,也不关心,但可以调用到子类的draw方法。
  105. public void draw(){
  106. for(int i=0; i<shapeNum; i++){
  107. shapes[i].draw();
  108. }
  109. }
  110. }
  111. //图形管理器调用
  112. public static void main(String[] args) {
  113. ShapeManager manager = new ShapeManager();
  114. //子类对象赋值给父类引用变量,这叫向上转型,转型就是转换类型,向上转型就是转换为父类类型。
  115. manager.addShape(new Circle(new Point(4,4),3));
  116. manager.addShape(new Line(new Point(2,3),
  117. new Point(3,4),"green"));
  118. manager.addShape(new ArrowLine(new Point(1,2),
  119. new Point(5,5),"black",false,true));
  120. manager.draw();
  121. }

1)Java使用extends关键字表示继承关系,一个类最多只能有一个父类;

2)子类不能直接访问父类的私有属性和方法。比如,在Circle中,不能直接访问Shape的私有实例变量color;

3)除了私有的外,子类继承了父类的其他属性和方法。比如,在Circle的draw方法中,可以直接调用getColor()方法。

类执行顺序

  1. public static void main(String[] args) {
  2. Point center = new Point(2,3);
  3. //创建园,赋值给circle
  4. Circle circle = new Circle(center,2);
  5. //调用draw方法,会执行Circle的draw方法
  6. circle.draw();
  7. //输出园的面积
  8. System.out.println(circle.area());
  9. }
  10. //输出结果
  11. draw circle at (2,3) with r 2.0, using color : black
  12. 12.566370614359172

在new的过程中,父类的构造方法也会执行,且会优先于子类执行。在这个例子中,父类Shape的默认构造方法会在子类Circle的构造方法之前执行。color在父类的构造函数被赋值。

  • 直线

super关键字:可用于调用父类构造方法,访问父类方法和变量

1)在Line构造方法中,super(color)表示调用父类的带color参数的构造方法。调用父类构造方法时,super必须放在第一行。

2)在draw方法中,super.getColor()表示调用父类的getColor方法,当然不写super.也是可以的,因为这个方法子类没有同名的,没有歧义,当有歧义的时候,通过super.可以明确表示调用父类的方法。

3)super同样可以引用父类非私有的变量。可以看出,super的使用与this有点像,但super和this是不同的,this引用一个对象,是实实在在存在的,可以作为函数参数,可以作为返回值,但super只是一个关键字不能作为参数和返回值,它只是用于告诉编译器访问父类的相关变量和方法

  • 带直线的箭头

ArrowLine继承自Line,而Line继承自Shape, ArrowLine的对象也有Shape的属性和方法。

  • 图形管理器

使用继承的一个好处是可以统一处理不同子类型的对象

在绘制代码中,只需要将每个对象当作Shape并调用draw方法就可以了,系统会自动执行子类的draw方法

向上转型:子类对象赋值给父类引用变量,这叫向上转型,转型就是转换类型,向上转型就是转换为父类类型

多态:变量shape可以引用任何Shape子类类型的对象,这叫多态,即一种类型的变量,可引用多种实际类型对象

静态类型:类型Shape,父类

动态类型:类型Circle/Line/ArrowLine,我们称之为shape的动态类型,shape的子类

动态绑定:shapes[i].draw()调用的是其对应动态类型的draw方法,这称之为方法的动态绑定。


多态和动态绑定是计算机程序的一种重要思维方式,使得操作对象的程序不需要关注对象的实际类型,从而可以统一处理不同对象,但又能实现每个对象的特有行为

小结

  1. 每个类有且只有一个父类,没有声明父类的,其父类为Object,子类继承了父类非private的属性和方法,可以增加自己的属性和方法,以及重写父类的方法实现。
  2. new过程中,父类先进行初始化,可通过super调用父类相应的构造方法,没有使用super的情况下,调用父类的默认构造方法
  3. 子类变量和方法与父类重名的情况下,可通过super强制访问父类的变量和方法
  4. 子类对象可以赋值给父类引用变量,这叫多态;实际执行调用的是子类实现,这叫动态绑定。

继承的细节

  1. //父类
  2. public class Base {
  3. public Base(){
  4. test();
  5. }
  6. public void test(){
  7. }
  8. }
  9. //子类
  10. public class Child extends Base {
  11. private int a = 123;
  12. public Child(){
  13. }
  14. public void test(){
  15. System.out.println(a);
  16. }
  17. }
  18. //调用
  19. public static void main(String[] args){
  20. Child c = new Child();//先初始化父类,父类初始化调用test(),而test被子类重写,调用子类的test,a还没有被赋值,输出0
  21. c.test();//调用子类test,输出123
  22. }
  23. //输出结果
  24. 0
  25. 123

分析:第一次输出是在new过程中输出的,在new过程中,首先是初始化父类,父类构造方法调用test()方法,test()方法被子类重写了,就会调用子类的test()方法,子类方法访问子类实例变量a,而这个时候子类的实例变量的赋值语句和构造方法还没有执行,所以输出的是其默认值0。

在父类构造方法中调用可被子类重写的方法,是一种不好的实践,容易引起混淆,应该只调用private的方法

重名与静态绑定

实例变量、静态方法和静态变量可以重名,重名后实际上有两个变量或方法

private变量和方法只能在类内访问,访问的也永远是当前类的

即:在子类中访问的是子类的;在父类中访问的是父类的,它们只是碰巧名字一样而已,没有任何关系

public变量和方法,则要看如何访问它。

  • 在类内,访问的是当前类的,但子类可以通过super.明确指定访问父类的。

  • 在类外,则要看访问变量的静态类型:

    • 静态类型是父类,则访问父类的变量和方法;
    • 静态类型是子类,则访问的是子类的变量和方法。
  1. //基类代码
  2. public class Base {
  3. public static String s = "static_base";//静态变量
  4. public String m = "base";//实例变量
  5. public static void staticTest(){//静态方法
  6. System.out.println("base static: "+s);
  7. }
  8. }
  9. //子类代码
  10. //子类定义了和父类重名的变量和方法。对于一个子类对象,它就有了两份变量和方法,在子类内部访问的时候,访问的是子类的,或者说,子类变量和方法隐藏了父类对应的变量和方法
  11. public class Child extends Base {
  12. public static String s = "child_base";
  13. public String m = "child";
  14. public static void staticTest(){
  15. System.out.println("child static: "+s);
  16. }
  17. }
  18. //调用
  19. //静态变量和静态方法一般通过类名直接访问,但也可以通过类的对象访问
  20. public static void main(String[] args) {
  21. Child c = new Child();
  22. Base b = c;
  23. System.out.println(b.s);
  24. System.out.println(b.m);
  25. b.staticTest();
  26. System.out.println(c.s);
  27. System.out.println(c.m);
  28. c.staticTest();
  29. }
  30. //结果
  31. static_base
  32. base
  33. base static: static_base
  34. child_base
  35. child
  36. child static: child_base

当通过b(静态类型Base)访问时,访问的是Base的变量和方法,当通过c(静态类型Child)访问时,访问的是Child的变量和方法,这称之为静态绑定,即访问绑定到变量的静态类型

  • 静态绑定程序编译阶段即可决定
  • 动态绑定则要等到程序运行时

实例变量、静态变量、静态方法、private方法,都是静态绑定的。

重载和重写

重载:是指方法名称相同但参数签名不同参数个数、类型或顺序不同

重写:是指子类重写与父类相同参数签名的方法

当有多个重名函数的时候,在决定要调用哪个函数的过程中,首先是按照参数类型进行匹配的,换句话说,

  • 寻找在所有重载版本中最匹配的,(都一样先匹配子类的)

  • 然后才看变量的动态类型,进行动态绑定

父与子类型的转换

  • 一个父类的变量能不能转换为一个子类的变量,取决于这个父类变量的动态类型(即引用的对象类型)是不是这个子类或这个子类的子类。
  • 给定一个父类的变量能不能知道它到底是不是某个子类的对象,从而安全地进行类型转换呢?答案是可以,通过instanceof关键字
  • instanceof前面是变量,后面是类,返回值是boolean值,表示变量引用的对象是不是该类或其子类的对象。

继承访问权限protected

public表示外部可以访问,private表示只能内部使用

还有一种可见性介于中间的修饰符protected

  • 表示虽然不能被外部任意访问,但可被子类访问
  • 另外,protected还表示可被同一个包中的其他类访问,不管其他类是不是该类的子类。
  1. public class Base {
  2. protected int currentStep;
  3. protected void step1(){
  4. }
  5. protected void step2(){
  6. }
  7. public void action(){
  8. this.currentStep = 1;
  9. step1();
  10. this.currentStep = 2;
  11. step2();
  12. }
  13. }
  14. //子类
  15. public class Child extends Base {
  16. protected void step1(){
  17. System.out.println("child step " + this.currentStep);
  18. }
  19. protected void step2(){
  20. System.out.println("child step " + this.currentStep);
  21. }
  22. }
  23. //使用子类
  24. public static void main(String[] args){
  25. Child c = new Child();
  26. c.action();
  27. }
  28. //结果
  29. child step 1
  30. child step 2
  • 基类定义了表示对外行为的方法action
  • 并定义了可以被子类重写的两个步骤step1()和step2()
  • 以及被子类查看的变量currentStep,
  • 子类通过重写protected方法step1()和step2()来修改对外的行为

action方法就是一个模板方法,它定义了实现的模板,而具体实现则由子类提供

可见性重写

重写时,子类方法不能降低父类方法的可见性。不能降低是指,父类如果是public,则子类也必须是public,父类如果是protected,子类可以是protected,也可以是public,即子类可以升级父类方法的可见性但不能降低

原因:继承反映的是“is-a”的关系,即子类对象也属于父类,子类必须支持父类所有对外的行为,将可见性降低就会减少子类对外的行为,从而破坏“is-a”的关系,但子类可以增加父类的行为,所以提升可见性是没有问题的。

防止继承final

一个Java类,默认情况下都是可以被继承的,但加了final关键字之后就不能被继承了

  1. public final class Base {
  2. //主体代码
  3. }

一个非final的类,其中的public/protected实例方法默认情况下都是可以被重写的,但加了final关键字后就不能被重写了

  1. public class Base {
  2. public final void test(){
  3. System.out.println("不能被重写");
  4. }
  5. }

继承实现的原理

  1. public class Base {
  2. public static int s;
  3. private int a;
  4. static {
  5. System.out.println("基类静态代码块, s: "+s);
  6. s = 1;
  7. }
  8. {
  9. System.out.println("基类实例代码块, a: "+a);
  10. a = 1;
  11. }
  12. public Base(){
  13. System.out.println("基类构造方法, a: "+a);
  14. a = 2;
  15. }
  16. protected void step(){
  17. System.out.println("base s: " + s +", a: "+a);
  18. }
  19. public void action(){
  20. System.out.println("start");
  21. step();
  22. System.out.println("end");
  23. }
  24. }

Base包括一个静态变量s,一个实例变量a,一段静态初始化代码块,一段实例初始化代码块,一个构造方法,两个方法step和action。

  1. public class Child extends Base {
  2. public static int s;
  3. private int a;
  4. static {
  5. System.out.println("子类静态代码块, s: "+s);
  6. s = 10;
  7. }
  8. {
  9. System.out.println("子类实例代码块, a: "+a);
  10. a = 10;
  11. }
  12. public Child(){
  13. System.out.println("子类构造方法, a: "+a);
  14. a = 20;
  15. }
  16. protected void step(){
  17. System.out.println("child s: " + s +", a: "+a);
  18. }
  19. }

Child继承了Base,也定义了和基类同名的静态变量s和实例变量a,静态初始化代码块,实例初始化代码块,构造方法,重写了方法step

  1. public static void main(String[] args) {
  2. System.out.println("---- new Child()");
  3. Child c = new Child();
  4. System.out.println("\n---- c.action()");
  5. c.action();
  6. Base b = c;
  7. System.out.println("\n---- b.action()");
  8. b.action();
  9. System.out.println("\n---- b.s: " + b.s);
  10. System.out.println("\n---- c.s: " + c.s);
  11. }
  1. //执行结果
  2. ---- new Child()
  3. 基类静态代码块, s: 0
  4. 子类静态代码块, s: 0
  5. 基类实例代码块, a: 0
  6. 基类构造方法, a: 1
  7. 子类实例代码块, a: 0
  8. 子类构造方法, a: 10
  9. ---- c.action()
  10. start
  11. child s: 10, a: 20
  12. end
  13. ---- b.action()
  14. start
  15. child s: 10, a: 20
  16. end
  17. ---- b.s: 1
  18. ---- c.s: 10

类加载的过程

所谓类的加载是指将类的相关信息加载到内存。在Java中,类是动态加载的,当第一次使用这个类的时候才会加载,加载一个类时,会查看其父类是否已加载,如果没有,则会加载其父类

1)一个类的信息主要包括以下部分:

❑ 类变量(静态变量);

❑ 类初始化代码;

❑ 类方法(静态方法);

❑ 实例变量;

❑ 实例初始化代码;

❑ 实例方法;

❑ 父类信息引用。

2)类初始化代码包括:

❑ 定义静态变量时的赋值语句;

❑ 静态初始化代码块。

3)实例初始化代码包括:

❑ 定义实例变量时的赋值语句;

❑ 实例初始化代码块;

❑ 构造方法。

4)类加载过程包括:

❑ 分配内存保存类的信息;

❑ 给类变量赋默认值;

❑ 加载父类;

❑ 设置父子关系;

❑ 执行类初始化代码。

类初始化代码,是先执行父类的,再执行子类的

父类执行时,子类静态变量的值也是有的,是默认值

内存分为栈和堆,栈存放函数的局部变量,而堆存放动态分配的对象,

还有一个内存区,存放类的信息,这个区在Java中称为方法区

  1. 类信息内存布局

Java编程的逻辑 - 图7

class_init()来表示类初始化代码,用instance_init()表示实例初始化代码,实例初始化代码包括了实例初始化代码块和构造方法

对象创建的过程

创建对象过程包括:

1)分配内存

分配的内存包括本类和所有父类的实例变量,但不包括任何静态变量

2)对所有实例变量赋默认值

3)执行实例初始化代码

实例初始化代码的执行从父类开始,再执行子类的。但在任何类执行初始化代码之前,所有实例变量都已设置完默认值。


创建对象执行的优先原则:

  1. 静态 > 非静态,基类 > 子类
  2. 基类 > 子类
  3. 实例代码块 > 构造方法

每个对象除了保存类的实例变量之外,还保存着实际类信息的引用。

Child c = new Child();会将新创建的Child对象引用赋给变量c,

而Base b = c;会让b也引用这个Child对象。

创建和赋值后,内存布局如图

Java编程的逻辑 - 图8

引用型变量c和b分配在栈中,它们指向相同的堆中的Child对象。Child对象存储着方法区中Child类型的地址,还有Base中的实例变量a和Child中的实例变量a。

方法调用的过程

c.action()执行过程:

1)查看c的对象类型,找到Child类型,在Child类型中找action方法,发现没有,到父类中寻找

2)在父类Base中找到了方法action,开始执行action方法

3)action先输出了start,然后发现需要调用step()方法,就从Child类型开始寻找step()方法

4)在Child类型中找到了step()方法,执行Child中的step()方法,执行完后返回action方法

5)继续执行action方法,输出end。

寻找要执行的实例方法的时候,是从对象的实际类型信息开始查找的,找不到的时候,再查找父类类型信息。


方法调用优先次序:

  1. 自己的方法
  2. 没有则找父类对应方法

我们来看b.action(),这句代码的输出和c.action()是一样的,这称为动态绑定,而动态绑定实现的机制就是根据对象的实际类型查找要执行的方法,子类型中找不到的时候再查找父类。这里,因为b和c指向相同的对象,所以执行结果是一样的

如果继承的层次比较深,要调用的方法位于比较上层的父类,则调用的效率是比较低的,因为每次调用都要进行很多次查找。大多数系统使用一种称为虚方法表的方法来优化调用的效率。

虚方法表,就是在类加载的时候为每个类创建一个表,记录该类的对象所有动态绑定的方法(包括父类的方法)及其地址,但一个方法只有一条记录,子类重写了父类方法后只会保留子类的。对于本例来说,Child和Base的虚方法表如图

Java编程的逻辑 - 图9

对Child类型来说,action方法指向Base中的代码,toString方法指向Object中的代码,而step()指向本类中的代码。当通过对象动态绑定方法的时候,只需要查找这个表就可以了,而不需要挨个查找每个父类。

变量访问的过程

对变量的访问是静态绑定的,无论是类变量还是实例变量。代码中演示的是类变量:b.s和c.s,通过对象访问类变量,系统会转换为直接访问类变量Base.s和Child.s。

例子中的实例变量都是private的,不能直接访问;如果是public的,则b.a访问的是对象中Base类定义的实例变量a,而c.a访问的是对象中Child类定义的实例变量a。

继承是把双刃剑

继承破坏封装

封装就是隐藏实现细节,提供简化接口

继承可能破坏封装是因为子类和父类之间可能存在着实现细节的依赖。子类在继承父类的时候,往往不得不关注父类的实现细节,而父类在修改其内部实现的时候,如果不考虑子类,也往往会影响到子类。

封装如何被破坏

  1. public class Base {
  2. private static final int MAX_NUM = 1000;
  3. private int[] arr = new int[MAX_NUM];
  4. private int count;
  5. public void add(int number){
  6. if(count<MAX_NUM){
  7. arr[count++] = number;
  8. }
  9. }
  10. public void addAll(int[] numbers){
  11. for(int num : numbers){
  12. add(num);
  13. }
  14. }
  15. }
  16. public class Child extends Base {
  17. private long sum;
  18. @Override
  19. public void add(int number) {
  20. super.add(number);
  21. sum+=number;
  22. }
  23. @Override
  24. public void addAll(int[] numbers) {
  25. super.addAll(numbers);
  26. for(int i=0; i<numbers.length; i++){
  27. sum+=numbers[i];
  28. }
  29. }
  30. public long getSum() {
  31. return sum;
  32. }
  33. }
  34. //调用
  35. public static void main(String[] args) {
  36. Child c = new Child();
  37. c.addAll(new int[]{1,2,3});
  38. System.out.println(c.getSum());
  39. }
  40. //结果:12
  41. 使用addAll添加123,期望的输出是1+2+3=6,实际输出为12
  42. 为什么是12呢?查看代码不难看出,同一个数字被汇总了两次。
  43. 子类的addAll方法首先调用了父类的add-All方法,而父类的addAll方法通过add方法添加,由于动态绑定,子类的add方法会执行,子类的add也会做汇总操作。

子类和父类之间是细节依赖,子类扩展父类,仅仅知道父类能做什么是不够的,还需要知道父类是怎么做的,而父类的实现细节也不能随意修改,否则可能影响子类。

子类需要知道父类的可重写方法之间的依赖关系,具体到上例中,就是add和addAll方法之间的关系,而且这个依赖关系,父类不能随意改变。但即使这个依赖关系不变,封装还是可能被破坏

父类不能随意增加公开方法,因为给父类增加就是给所有子类增加,而子类可能必须要重写该方法才能确保方法的正确性。总结一下:对于子类而言,通过继承实现是没有安全保障的,因为父类修改内部实现细节,它的功能就可能会被破坏;而对于基类而言,让子类继承和重写方法,就可能丧失随意修改内部实现的自由。

继承没有反映is-a关系

比如,绝大部分鸟都会飞,可能就想给鸟类增加一个方法fly()表示飞,但有一些鸟就不会飞,比如企鹅。在is-a关系中,重写方法时,子类不应该改变父类预期的行为,但是这是没有办法约束的。还是以鸟为例,你可能给父类增加了fly()方法,对企鹅,你可能想,企鹅不会飞,但可以走和游泳,就在企鹅的fly()方法中,实现了有关走或游泳的逻辑。

继承是应该被当作is-a关系使用的,但是,Java并没有办法约束,父类有的属性和行为,子类并不一定都适用,子类还可以重写方法,实现与父类预期完全不一样的行为。

但对于通过父类引用操作子类对象的程序而言,它是把对象当作父类对象来看待的,期望对象符合父类中声明的属性和行为。如果不符合,结果是什么呢?混乱。

如何应对继承的双面性

避免使用继承

❑ 使用final关键字;

❑ 优先使用组合而非继承;

❑ 使用接口。

  • 使用final关键字
    给方法加final修饰符,父类就保留了随意修改这个方法内部实现的自由,使用这个方法的程序也可以确保其行为是符合父类声明的。给类加final修饰符,父类就保留了随意修改这个类实现的自由,使用者也可以放心地使用它,而不用担心一个父类引用的变量,实际指向的却是一个完全不符合预期行为的子类对象。
    给类加final修饰符,父类就保留了随意修改这个类实现的自由,使用者也可以放心地使用它,而不用担心一个父类引用的变量,实际指向的却是一个完全不符合预期行为的子类对象

  • 优先使用组合而非继承
    使用组合可以抵挡父类变化对子类的影响,从而保护子类,应该优先使用组合

  1. public class Child {
  2. private Base base;
  3. private long sum;
  4. public Child(){
  5. base = new Base();
  6. }
  7. public void add(int number) {
  8. base.add(number);
  9. sum+=number;
  10. }
  11. public void addAll(int[] numbers) {
  12. base.addAll(numbers);
  13. for(int i=0; i<numbers.length; i++){
  14. sum+=numbers[i];
  15. }
  16. }
  17. public long getSum() {
  18. return sum;
  19. }
  20. }

这样,子类就不需要关注基类是如何实现的了,基类修改实现细节,增加公开方法,也不会影响到子类了。但组合的问题是,子类对象不能当作基类对象来统一处理了。

正确使用继承

使用继承大概主要有三种场景:

1)基类是别人写的,我们写子类;

2)我们写基类,别人可能写子类;

3)基类、子类都是我们写的。

  • 基类是别人写的,我们写子类

基类主要是Java API、其他框架或类库中的类,在这种情况下,我们主要通过扩展基类,实现自定义行为,这种情况下需要注意的是:

❑ 重写方法不要改变预期的行为;

❑ 阅读文档说明,理解可重写方法的实现机制,尤其是方法之间的依赖关系;

❑ 在基类修改的情况下,阅读其修改说明,相应修改子类。

  • 我们写基类,别人可能写子类;

需要注意的是:

❑ 使用继承反映真正的is-a关系,只将真正公共的部分放到基类;

❑ 对不希望被重写的公开方法添加final修饰符;

❑ 写文档,说明可重写方法的实现机制,为子类提供指导,告诉子类应该如何重写;

❑ 在基类修改可能影响子类时,写修改说明。

类的扩展

双方对象并不直接互相依赖,它们只是通过接口间接交互

定义接口

Java编程的逻辑 - 图10

  1. public interface MyComparable {
  2. int compareTo(Object other);
  3. }

定义接口的代码解释如下:

1)Java使用interface这个关键字来声明接口,修饰符一般都是public。

2)interface后面就是接口的名字MyComparable。

3)接口定义里面,声明了一个方法compareTo,但没有定义方法体,Java 8之前,接口内不能实现方法。接口方法不需要加修饰符,加与不加相当于都是public abstract

再来解释compareTo方法:

1)方法的参数是一个Object类型的变量other,表示另一个参与比较的对象。

2)第一个参与比较的对象是自己。

3)返回结果是int类型,-1表示自己小于参数对象,0表示相同,1表示大于参数对象。

实现接口

Point类代码:实现了MyComparable

  1. public class Point implements MyComparable {
  2. private int x;
  3. private int y;
  4. public Point(int x, int y) {
  5. this.x = x;
  6. this.y = y;
  7. }
  8. public double distance(){
  9. return Math.sqrt(xx+yy);
  10. }
  11. @Override
  12. public int compareTo(Object other) {
  13. if(! (other instanceof Point)){
  14. throw new IllegalArgumentException();
  15. }
  16. Point otherPoint = (Point)other;
  17. double delta = distance() - otherPoint.distance();
  18. if(delta<0){
  19. return -1;
  20. }else if(delta>0){
  21. return 1;
  22. }else{
  23. return 0;
  24. }
  25. }
  26. @Override
  27. public String toString() {
  28. return "("+x+", "+y+")";
  29. }
  30. }

代码解释如下:

1)Java使用implements这个关键字表示实现接口,前面是类名,后面是接口名。

2)实现接口必须要实现接口中声明的方法,Point实现了compareTo方法。

再来解释Point的compareTo实现。

1)Point不能与其他类型的对象进行比较,它首先检查要比较的对象是否是Point类型,如果不是,使用throw抛出一个异常,异常将在下一章介绍,此处可以忽略。

2)如果是Point类型,则使用强制类型转换将Object类型的参数other转换为Point类型的参数otherPoint。

3)这种显式的类型检查和强制转换是可以使用泛型机制避免的。

一个类可以实现多个接口,表明类的对象具备多种能力,各个接口之间以逗号分隔,语法如下所示:

  1. public class Test implements Interface1, Interface2 {
  2. // 主体代码
  3. }

使用接口

与类不同,接口不能new,不能直接创建一个接口对象,对象只能通过类来创建。但可以声明接口类型的变量,引用实现了接口的类对象

  1. MyComparable p1 = new Point(2,3);
  2. MyComparable p2 = new Point(1,2);
  3. System.out.println(p1.compareTo(p2));

p1和p2是MyComparable类型的变量,但引用了Point类型的对象,之所以能赋值是因为Point实现了MyComparable接口。

如果一个类型实现了多个接口,那么这种类型的对象就可以被赋值给任一接口类型的变量

实际执行时,执行的是具体实现类的代码

在一些程序中,代码并不知道具体的类型,这才是接口发挥威力的地方

  1. public class CompUtil {
  2. public static Object max(MyComparable[] objs){
  3. if(objs==null||objs.length==0){
  4. return null;
  5. }
  6. MyComparable max = objs[0];
  7. for(int i=1; i<objs.length; i++){
  8. if(max.compareTo(objs[i])<0){
  9. max = objs[i];
  10. }
  11. }
  12. return max;
  13. }
  14. public static void sort(MyComparable[] objs){
  15. for(int i=0; i<objs.length; i++){
  16. int min = i;
  17. for(int j=i+1; j<objs.length; j++){
  18. if(objs[j].compareTo(objs[min])<0){
  19. min = j;
  20. }
  21. }
  22. if(min!=i){
  23. MyComparable temp = objs[i];
  24. objs[i] = objs[min];
  25. objs[min] = temp;
  26. }
  27. }
  28. }
  29. }
  30. //对Point类型进行操作
  31. Point[] points = new Point[]{
  32. new Point(2,3), new Point(3,4), new Point(1,2)
  33. };
  34. System.out.println("max: " + CompUtil.max(points));
  35. CompUtil.sort(points);
  36. System.out.println("sort: "+ Arrays.toString(points));
  37. //结果
  38. max: (3,4)
  39. sort: [(1,2), (2,3), (3,4)]

针对接口而非具体类型进行编程,是计算机程序的一种重要思维方式。接口很多时候反映了对象以及对对象操作的本质。它的优点有很多,首先是代码复用,同一套代码可以处理多种不同类型的对象,只要这些对象都有相同的能力,如CompUtil

接口更重要的是降低了耦合,提高了灵活性

使用接口的代码依赖的是接口本身,而非实现接口的具体类型,程序可以根据情况替换接口的实现,而不影响接口使用者

接口的细节

❑ 接口中的变量

❑ 接口的继承

❑ 类的继承与接口

❑ instanceof

  • 接口中的变量
  1. public interface Interface1 {
  2. public static final int a = 0;
  3. }

这个变量可以通过“接口名.变量名”的方式使用,如Interface1.a

  • 接口的继承
  1. public interface IBase1 {
  2. void method1();
  3. }
  4. public interface IBase2 {
  5. void method2();
  6. }
  7. public interface IChild extends IBase1, IBase2 {
  8. }

IChild有IBase1和IBase2两个父类,接口的继承同样使用extends关键字,多个父接口之间以逗号分隔

  • 类的继承与接口
  1. public class Child extends Base implements IChild {
  2. //主体代码
  3. }

类可以在继承基类的情况下,同时实现一个或多个接口

关键字extends要放在implements之前

  • instanceof

与类一样,接口也可以使用instanceof关键字,用来判断一个对象是否实现了某接口

  1. Point p = new Point(2,3);
  2. if(p instanceof MyComparable){
  3. System.out.println("comparable");
  4. }

使用接口替代继承

继承至少有两个好处:

  • 一个是复用代码;

  • 另一个是利用多态和动态绑定统一处理多种不同子类的对象

注意:使用接口替代继承,针对接口编程,可以实现统一处理不同类型的对象,但接口没有代码实现,无法复用代码

解决方法:将组合和接口结合起来替代继承,就既可以统一处理,又可以复用代码了

Java8和Java9对接口的增强

在Java 8之前,接口中的方法都是抽象方法,都没有实现体

Java 8允许在接口中定义两类新方法:静态方法和默认方法,它们有实现体

  1. public interface IDemo {
  2. void hello();
  3. //test()就是一个静态方法,可以通过IDemo.test()调用
  4. public static void test() {
  5. System.out.println("hello");
  6. }
  7. default void hi() {
  8. System.out.println("hi");
  9. }
  10. }

hi()是一个默认方法,用关键字default表示。

默认方法与抽象方法都是接口的方法,

不同在于,

默认方法有默认的实现,实现类可以改变它的实现也可以不改变

引入默认方法主要是函数式数据处理的需求,是为了便于给接口增加功能

在没有默认方法之前,

  • Java是很难给接口增加功能的
  • 因为有太多非Java JDK控制的代码实现了该接口,如果给接口增加一个方法,则那些接口的实现就无法在新版Java上运行,必须改写代码,实现新的方法,这显然是无法接受的

在Java 8中,静态方法和默认方法都必须是public的

Java 9去除了这个限制,它们都可以是private的,引入private方法主要是为了方便多个静态或默认方法复用代码

  1. public interface IDemoPrivate {
  2. private void common() {
  3. System.out.println("common");
  4. }
  5. default void actionA() {
  6. common();
  7. }
  8. default void actionB() {
  9. common();
  10. }
  11. }

actionA和actionB两个默认方法共享了相同的common()方法的代码

小结

  • 针对接口编程是一种重要的程序思维方式,这种方式不仅可以复用代码,还可以降低耦合,提高灵活性,是分解复杂问题的一种重要工具
  • 接口不能创建对象,没有任何实现代码(Java 8之前),而之前介绍的类都有完整的实现,都可以创建对象

抽象类

抽象类就是抽象的类。抽象是相对于具体而言的,一般而言,具体类有直接对应的对象,而抽象类没有,它表达的是抽象概念

抽象方法和抽象类

只有子类才知道如何实现的方法,一般被定义为抽象方法。

抽象方法是相对于具体方法而言的,具体方法有实现代码,而抽象方法只有声明,没有实现

抽象方法和抽象类都使用abstract这个关键字来声明

  1. public abstract class Shape {
  2. //其他代码
  3. public abstract void draw();
  4. }

定义了抽象方法的类必须被声明为抽象类,不过,抽象类可以没有抽象方法

抽象类和具体类一样,可以定义具体方法、实例变量等,它和具体类的核心区别是:

  • 抽象类不能创建对象(比如,不能使用new Shape()),而具体类可以
  • 抽象类不能创建对象,要创建对象,必须使用它的具体子类。一个类在继承抽象类后,必须实现抽象类中定义的所有抽象方法除非它自己也声明为抽象类
  1. public class Circle extends Shape {
  2. //其他代码
  3. @Override
  4. public void draw() {
  5. //主体代码
  6. }
  7. }

圆实现了draw()方法。与接口类似,抽象类虽然不能使用new,但可以声明抽象类的变量,引用抽象类具体子类的对象

  1. Shape shape = new Circle();
  2. shape.draw();

为什么需要抽象类

  • 使用抽象方法而非空方法体,子类就知道它必须要实现该方法,而不可能忽略,若忽略Java编译器会提示错误。
  • 使用抽象类,类的使用者创建对象的时候,就知道必须要使用某个具体子类,而不可能误用不完整的父类。

每个人都可能会犯错,减少错误不能只依赖人的优秀素质,还需要一些机制,使得一个普通人都容易把事情做对,而难以把事情做错。抽象类就是Java提供的这样一种机制。

抽象类和接口

  • 抽象类和接口有类似之处:都不能用于创建对象,接口中的方法其实都是抽象方法

  • 但抽象类和接口根本上是不同的,接口中不能定义实例变量,而抽象类可以

  • 一个类可以实现多个接口,但只能继承一个类

抽象类和接口是配合而非替代关系,它们经常一起使用,接口声明能力,抽象类提供默认实现,实现全部或部分方法,一个接口经常有一个对应的抽象类

在Java类库中,有:

❑ Collection接口和对应的AbstractCollection抽象类。

❑ List接口和对应的AbstractList抽象类。

❑ Map接口和对应的AbstractMap抽象类。

对于需要实现接口的具体类而言,有两个选择:

  • 一个是实现接口,自己实现全部方法;
  • 另一个则是继承抽象类,然后根据需要重写方法。

继承的好处是复用代码,只重写需要的部分即可,需要编写的代码比较少,容易实现。不过,如果这个具体类已经有父类了,那就只能选择实现接口了

前面引入了IAdd接口,我们实现一个抽象类AbstractAdder

  1. public abstract class AbstractAdder implements IAdd {
  2. @Override
  3. public void addAll(int[] numbers) {
  4. for(int num : numbers){
  5. add(num);
  6. }
  7. }
  8. }

这个抽象类提供了addAll方法的实现,它通过调用add方法来实现,而add方法是一个抽象方法

这样,对于需要实现IAdd接口的类来说

  • 它可以选择直接实现IAdd接口
  • 或者从AbstractAdder类继承,如果继承,只需要实现add方法就可以了
  1. //继承抽象类
  2. public class Base extends AbstractAdder {
  3. private static final int MAX_NUM = 1000;
  4. private int[] arr = new int[MAX_NUM];
  5. private int count;
  6. @Override
  7. public void add(int number){
  8. if(count<MAX_NUM){
  9. arr[count++] = number;
  10. }
  11. }
  12. }

小结

抽象类能使程序更为清晰,可以减少误用。

抽象类和接口经常相互配合,接口定义能力,而抽象类提供默认实现,方便子类实现接口。

类的扩展

内部类的本质

一个类还可以放在另一个类的内部,称之为内部类,相对而言,包含它的类称之为外部类

  • 好处:内部类与包含它的外部类有比较密切的关系,而与其他类关系不大,定义在类内部,可以实现对外部完全隐藏,可以有更好的封装性,代码实现上也往往更为简洁

不过,内部类只是Java编译器的概念,对于Java虚拟机而言,它是不知道内部类这回事的,每个内部类最后都会被编译为一个独立的类,生成一个独立的字节码文件

在Java中,根据定义的位置和方式不同,主要有4种内部类。

❑ 静态内部类。

❑ 成员内部类。

❑ 方法内部类。

❑ 匿名内部类。

方法内部类是在一个方法内定义和使用的;

匿名内部类使用范围更小,它们都不能在外部使用;

成员内部类和静态内部类可以被外部使用,不过它们都可以被声明为private,这样,外部就不能使用了。

静态内部类

  1. public class Outer {
  2. private static int shared = 100;
  3. public static class StaticInner {
  4. public void innerMethod(){
  5. System.out.println("inner " + shared);
  6. }
  7. }
  8. public void test(){
  9. StaticInner si = new StaticInner();
  10. si.innerMethod();
  11. }
  12. }

静态内部类可以访问外部类的静态变量和方法,如innerMethod直接访问shared变量,但不可以访问实例变量和方法。在类内部,可以直接使用内部静态类,如test()方法所示。

public静态内部类可以被外部使用,只是需要通过“外部类.静态内部类”的方式使用

  1. Outer.StaticInner si = new Outer.StaticInner();
  2. si.innerMethod();

静态内部类示例的内部实现

  1. public class Outer {
  2. private static int shared = 100;
  3. public void test(){
  4. Outer$StaticInner si = new Outer$StaticInner();
  5. si.innerMethod();
  6. }
  7. static int access$0(){
  8. return shared;
  9. }
  10. }
  11. public class Outer$StaticInner {
  12. public void innerMethod() {
  13. System.out.println("inner " + Outer.access$0());
  14. }
  15. }

内部类访问了外部类的一个私有静态变量shared,而我们知道私有变量是不能被类外部访问的,Java的解决方法是:自动为Outer生成一个非私有访问方法access$0,它返回这个私有静态变量shared

静态内部类的使用场景是很多的,如果它与外部类关系密切,且不依赖于外部类实例,则可以考虑定义为静态内部类

比如,一个类内部,如果既要计算最大值,又要计算最小值,可以在一次遍历中将最大值和最小值都计算出来,但怎么返回呢?可以定义一个类Pair,包括最大值和最小值,但Pair这个名字太普遍,而且它主要是类内部使用的,就可以定义为一个静态内部类

Java API中使用静态内部类的例子:

❑ Integer类内部有一个私有静态内部类IntegerCache,用于支持整数的自动装箱。

❑ 表示链表的LinkedList类内部有一个私有静态内部类Node,表示链表中的每个节点。

❑ Character类内部有一个public静态内部类UnicodeBlock,用于表示一个Unicode block。

成员内部类

与静态内部类相比,成员内部类没有static修饰符,少了一个static修饰符

  1. public class Outer {
  2. private int a = 100;
  3. public class Inner {
  4. public void innerMethod(){
  5. System.out.println("outer a " +a);//直接访问外部类的私有实例变量a
  6. Outer.this.action();//引用外部类的(实例变量和)方法
  7. }
  8. }
  9. private void action(){
  10. System.out.println("action");
  11. }
  12. public void test(){
  13. Inner inner = new Inner();
  14. inner.innerMethod();
  15. }
  16. }

Inner就是成员内部类,与静态内部类不同,

除了静态变量和方法,成员内部类还可以直接访问外部类的实例变量和方法

成员内部类还可以通过“外部类.this.xxx”的方式引用外部类的实例变量和方法

如Outer.this. action(),这种写法一般在重名的情况下使用,如果没有重名,那么“外部类.this. ”是多余的

在外部类内,使用成员内部类与静态内部类是一样的,直接使用即可

与静态内部类不同,成员内部类对象总是与一个外部类对象相连的,在外部使用时,它不能直接通过new Outer.Inner()的方式创建对象,而是要先将创建一个Outer类对象

  1. Outer outer = new Outer();
  2. Outer.Inner inner = outer.new Inner();
  3. inner.innerMethod();

创建内部类对象的语法是“外部类对象.new 内部类()”,如outer.new Inner()。

  • 成员内部类中不可以定义静态变量和方法(final变量例外,它等同于常量)
  • 这些内部类是与外部实例相连的,不应独立使用,而静态变量和方法作为类型的属性和方法,一般是独立使用的,在内部类中意义不大,而如果内部类确实需要静态变量和方法,那么也可以挪到外部类中。

实现原理

成员内部类示例的内部实现

  1. public class Outer {
  2. private int a = 100;
  3. private void action() {
  4. System.out.println("action");
  5. }
  6. public void test() {
  7. Outer$Inner inner = new Outer$Inner(this);
  8. inner.innerMethod();
  9. }
  10. //access$0用于访问变量a, access$1用于访问方法action。
  11. static int access$0(Outer outer) {
  12. return outer.a;
  13. }
  14. static void access$1(Outer outer) {
  15. outer.action();
  16. }
  17. }
  18. public class Outer$Inner {
  19. final Outer outer;//outer指向外部类的对象
  20. public Outer$Inner(Outer outer){//在构造方法中被初始化
  21. ths.outer = outer;
  22. }
  23. public void innerMethod() {
  24. System.out.println("outer a " + Outer.access$0(outer));
  25. Outer.access$1(outer);
  26. }
  27. }

OuterJava编程的逻辑 - 图11Inner对象时给它传递当前对象,由于内部类访问了外部类的私有变量和方法,外部类Outer生成了两个非私有静态方法:access$0用于访问变量a, access$1用于访问方法action。

应用场景

  • 如果内部类与外部类关系密切需要访问外部类的实例变量或方法,则可以考虑定义为成员内部类

  • 外部类的一些方法的返回值可能是某个接口,为了返回这个接口,外部类方法可能使用内部类实现这个接口,这个内部类可以被设为private,对外完全隐藏

例子:在Java API的类LinkedList中,它的两个方法listIterator和descendingIterator的返回值都是接口Iterator,调用者可以通过Iterator接口对链表遍历,listIterator和descend-ingIterator内部分别使用了成员内部类ListItr和DescendingIterator,这两个内部类都实现了接口Iterator

方法内部类

示例:

  1. public class Outer {
  2. private int a = 100;
  3. public void test(final int param){
  4. final String str = "hello";
  5. class Inner {
  6. public void innerMethod(){
  7. System.out.println("outer a " +a);
  8. System.out.println("param " +param);
  9. System.out.println("local var " +str);
  10. }
  11. }
  12. Inner inner = new Inner();
  13. inner.innerMethod();
  14. }
  15. }