Java 编程思想
第1章 对象的概念
封装
将对象保护起来,让调用对象的人,不能轻易的修改对象内部的实现细节,防止在调用过程中,对象被无意中破坏;
函数式编程,貌似不存在这种担心;为什么 OO 会出现这种情况呢?猜测是因为在内部存储状态造成的,它的实现变得复杂了,对状态有依赖,导致变得脆弱,从而需要提供某种保护;因此,有可能的情况下,应尽量避免出现状态依赖,让对象更加纯粹,这样就无须提供保护;
后来发现真正的目的并不是保护,而是为了方便后续更改实现;只要调用者没有访问内部的实现细节,那么内部就拥有更改的灵活性;
protected 和 private 区别
private 是类和调用者之间的屏障,如果非法调用,则编译时会报错;子类不继承父类的 private 成员;
protected 跟 private 只有一个点不同,即子类可以调用父类 protected 的属性或方法;
default:默认同一个包中的类,都可以相互访问;
组合和聚合的区别
区别在于生命周期,组合对象被删除时,各成员中的子对象也会被删除,但聚合不会;
is-a 和 is-like-a 的区别
如果派生类没有添加新的办法,只是覆盖基类的方法,那么基类和派生类之间是 is-a 的关系,二者可以完美替代;
如果派生类添加了新的方法,则跟基类是 is-like-a 的关系,因为存在新方法,所以不能无脑调用;
后期绑定
感觉 Java 的后期绑定有点像 js 等动态语言中的回调,函数做为某个对象的属性,约定共同的名称,例如 callback,届时按规则进行调用即可;甚至动态语言还可以更加灵活,在执行调用地方,传入任意函数做为参数的一部分,以方便调用者后续可以执行该函数;
OO 通过动态绑定来实现多态,即编译器在编译时,并不知道某个函数会收到什么样的参数,但由于这些参数有共同的方法命名绑定,因此,只需要按名称调用方法,则可以实现预期的调用;
这种动态的特性,在动态语言中是天生的,开发者完全注意不到,也完全没有想到,它在静态语言中,会是一个问题;事实上,Java 也同时是一门解释型的语言,因此才能够拥有这种动态语言的特性;
单继承结构
Java 中所有的对象都继承自基类 Object,它的好处是让整个语言极大降低了复杂度,也有利于垃圾回收。 C++ 为了兼容 C,所以会更加复杂;
集合
集合是编写代码过程中,非常常用的一种数据结构。相比 Java 的原始数组,集合实现了一些常用的方法,并且能够动态的扩展大小,要方便很多;Java 5 引入泛型后,让集合的使用更加方便了;
垃圾回收
Java 使用单继承结构,并要求所有对象都必须存放在堆中,这个设计极大的方便了内存的回收管理;因为只要创建对象,就可以统一调用基类 Object 的方法,触发内存管理;
异常处理
拥有异常处理机制是很重要的,因为如果没有,一旦出错,就意味着程序死掉了,无法从错误中恢复。这非常不利于程序对外提供稳定的服务,而异常处理机制可以让问题局限于某个范围,不至于影响全局;
第3章 万物皆对象
对象引用
1 |
|
对象创建
1 |
|
数据存储
基本类型的存储
由于对象保存在堆中,有时候一些简单变量可考虑使用基本类型,这样它们会存储在栈中,无需创建对象,性能会好一些
不过说实话,个人感觉这点微乎其微的性能提升几乎没有意义;
高精度数值
BigInteger 和 BigDecimal 两个类型用来存储高精度数值,它们也是包装类型,但没有对应的基本类型;
BigDecimal 常用于货币运算,以避免计算过程中出现精度丢失;
数组的存储
在 C/C++ 中,数组是手工分配的内存块,不小心会越界读取,带来不可预测的行为;Java 通过牺牲一些内存空间和计算性能,换取了使用数组的安全和方便性;
对象清理
作用域
1 |
|
类的创建
方法名 + 参数类型 的组合构成了方法(函数)的唯一标识(估计是为了实现重载);
方法可以返回任何类型的数据,也可以不返回任何数据(需要用关键字 void 标识);
命名冲突
为了避免类的命名冲突,Java 使用倒序域名来作为包的名称,同时将类放在包中,以尽可能减少重名的情况;
static 关键字
可以通过 static 关键字定义静态变量或静态方法;所谓的静态,意味着它们是静态存在的,不需要动态创建,也就是说,无须通过 new 创建对象来访问这些变量或方法,只需通过类名,即可访问它们,而且它们也不会在内存中重复创建,只会在加载类的时候,创建一次;
1 |
|
第4章 运算符
字面值常量
Long 类型的数值,结尾需要使用字母 L(大小写都可以)来表示;建议用大写,免得混淆;
16进制有个前缀 0x
8 进制的前缀为 0
2 进制的前缀为 0b
Java 使用 %n 来统一 window 和 unix 两个平台不同的换行符格式;唯一的例外是在 println 函数,仍然使用 /n 作为换行;
第5章 控制流
if-else, while, do-while, for-in, for
逗号运算符:可用来定义多个类型相同的变量
1 |
|
第6章 初始化和清理
利用构造器保证初始化
Java 和 C++ 一样,强制通过构造器来初始化对象,这样可以避免 C 语言中容易出现的问题,即调用者忘了做初始化的工作,导致出错;
1 |
|
重载
重载的一个使用场景是让类拥有多个不同的构造器,以便接受不同的参数,完成不同的初始化;
无参构造器
当定义了一个类,如果没有在类中定义构造函数,编译器会自动给它添加一个无参构造器;如果有在类中定义了有参数的构造器,那么编译器将不再自动创建无参构造器了;此时创建对象必须传参,不然会报错;
this 关键字
当在方法中 return this 时,可用来构造链式调用
1 |
|
另外 this 也用于将当前对象作为参数,传给另外一个函数;
当形参的命名跟内部变量同名时,可以使用 this.field 来处理;
垃圾回收器
Java 的内存回收不是实时的,而是要等到内存使用达到某个临界点时,才会触发回收;这会带来一个问题是当程序运行的越久,一方面其占用的内存会变得越来越多;另一方面,清理的工作会变得很大,会占用较多的 CPU 时间,导致短时间内出现性能下降;
回收前,虚拟机需要先标记仍然存活的对象,一种常见的方法是根搜索机制,即将某些对象标记为根对象,然后从这些对象出发,一层一层的往下遍历,直接没有找不到对象为止;凡是能找到的,就标记为”存活“;之后将存活的对象从一个堆块,复制到另外的新堆块,一方面这样可以排列紧凑,避免空档;另一方面可以将旧堆块标记为可用,实现清空;
垃圾回收有很多种机制,不同的虚拟机使用不同的机制,一种常见的机制如下:
- 将堆分成五块,分别是新生块、中生块1、中生块2、老年块、永生块;
- 永生块主要用于存储类库,一般不会回收;(java1.8 之后永生块改成了元数据区)
- 新生块、中生块1、中生块2、老年块的空间占比分别是 32 : 4 : 4 : 60;
- 所有新增的对象一般先放到新生块中,如果很大,则直接进入老年块,避免频繁触发回收;
- 当新生块满了后,触发回收;遍历一遍,没有引用的标记删除;有引用的,代数加1,挪到中生块1;
- 中生块1 如果满了,触发回收;删除死的,存活代数加1,复制到中生块2;
- 中生块2 如果满了,触发回收;删除死的,存活代数加1,复制到中生块1;
- 如果某个对象在中生块的存活代数达到门槛,例如8代,将其挪到老年块;
JIT: Just In Time,及时处理,意思是到了运行的时候,再将字节码编译成机器码;理论上字节码是解释运行的,并没有编译成机器码存储起来;为了提高运行速度,虚拟机可以将一些热点代码,提前编译成机器码,这样就不需要每次调用时,都进行解释了;
成员初始化
当一个类被加载时,首先会初始化静态变量,之后分配内存,为内部属性(如有)设置默认值;之后调用构建函数,为、内部属性再次赋值(如有);
1 |
|
数组初始化
Object 类默认的 toString 方法是打印类名和对象地址;
枚举类型
1 |
|
1 |
|
1 |
|
第7章 封装
之所以要做访问控制,主要出现在类库的开发场景,即所开发的类库会被他人调用。如果不限制访问某些属性,任意由调用者访问,会导致后续难以重构该类库的某些功能实现,因为里面有些属性可能被旧代码访问。因此重构后,可能会导致旧的代码无法正常工作。类库作者因此被绑住了手脚,无法实施进一步的迭代升级;
包的概念
包是一种组织管理源代码文件的方式,以减少命名冲突,让文件更加结构化,方便理解和查找;
.java 后缀的文件被称为源文件,每个源文件中,只能允许有一个 public 类,但可能有多个非 public 类;同时 public 类的名称需要与源文件名相同;
当源文件位于某个包中时,需要在源文件的头部写 “package <包名>”
访问权限修饰符
通常情况下,默认权限已经够用,但更推荐将不公开的成员设置为 private,例如它可以用来限制调用构造函数,强制要求调用静态方法;
当然,如果整个包都是自用的,没有被外部调用,那么 default 权限一般就够用了;
1 |
|
接口和实现分离
访问权限带来了接口和实现的分离,即调用者只能调用 public 方法,不能访问细节,因此内部的实现可以根据需要修改;
类访问权限
通常类的访问权限是 public,但是也可以是 default;当它是 default 时,它将不能在包外的位置进行初始化;此时它内部的方法即便是 public 也是无法访问的,调用的位置在编译时会报错;default 的类是无法被包外的使用者进行调用的,仅限包类使用;
正常文件中的类至少有一个是 public,但也可以没有 public;通常它们是一些辅助类,仅限在包中使用,不想给外部的人员调用;
类只有两种访问权限,要么是 public,要么是 default;
1 |
|
1 |
|
Java 的访问权限更适用于编写类库供外部调用的场景,如果不是这种场景,例如一个人编写程序,或者每个人编写各自的程序,无须相互调用,那么一般 default 权限就够用了;
第8章 复用
在 Java 中有两种复用代码的就去,一种是组合,一种是继承;
组合语法
定义新类时,将旧类作为新类的成员;
继承语法
1 |
|
如果某个类设计出来后,是要被包外的使用者进行继承,那么这个类的方法需要是 public 或 protected;private 方法无法被继承,因此也无法被重写;
初始化基类
Java 会在派生类的构造函数中,自动插入调用基类构造函数的代码;这意味着,每次创建一个派生类的对象时,也有一个基类的对象被创建出来了;这个基类对象会隐式的包含在派生类的对象中;
这意味着,如果继承的层级很深的话,实例化一个对象时,其实有一大堆对象被创建了出来;
1 |
|
当父类的构造函数需要参数时,那么需要手工调用父类的构造函数,并给它传参;
1 |
|
委托
还有一种复用代码的方法是使用委托,虽然 Java 并不直接支持委托,但可以借助第三方工具,来生成委托的方法;所谓的委托,其实就是将方法重新包装一下。可以根据需要,选择性的包装,用不到的就不包装;
感觉有点像是代理模式或者桥接模式
1 |
|
结合组合与继承
继承的同时,组合一些东西进来,很常用;
通常情况下,对象的销毁是由垃圾回收器完成的。但是它什么时候回收是不确定的,有时候我们需要立即回收,例如画图软件的场景,需要销毁一些画好的图形。此时我们需要手工写一些销毁的代码。此时一般会用到 try…finally 语句,以便确保销毁的工作会被执行。同时每个类中,也需要定义自己的销毁方法,以便可以调用,进行销毁;
@Override 注解虽然不写也不会影响代码的运行,但是写了后有个好处,编译器可以用它来检查重载是否成功。因为有可能返回类型或者参数类型写错,导致没有重载成功;
组合与继承的选择
认真思考新类和旧类的关系,到底是 is-a 还是 has-a 的关系;通常优先使用组合;
protected
对外部隐藏,但允许派生类的成员访问;
向上转型
继承的本质,是想表明某个新类,是旧类的某种特殊类型;
所谓的向上转型,是指将子类对象,当成父类对象来使用;向上转型是安全的,但向下转型就不太安全了;
继承虽然是 Java 的一个特色,但实际上它很少用。除非有明确的证据表明继承能够让问题变得简单化。判断的标准即是向上转型,即是否存在需要将派生类当作父类来使用的场景?如果有,那么可以考虑使用继承;
final 关键字
final 关键字通常表明这个东西是不能改变的;final 可用在三个地方,分别是数据、方法、类;
final 数据常用于表示常量;
final 参数用来表示不能改变该参数指向的对象或变量;
1 |
|
final 方法表示该方法不可被子类覆盖;
如果某个方法在基类中是 private,那么它是隐式 final 的,子类对该方法的覆盖,并非真的覆盖,只是一个同名方法而已;如果子类没有覆盖,甚至连同名方法都没有;只有非 private 的方法,才能够被重写;
final 类表示该类无法被继承;
类初始化和加载
类仅在被用到时,才会被 JVM 加载并初始化;当某个子类被用到时,JVM 会先去加载基类,完成基类的初始化后,再来处理子类的初始化;
第9章 多态
多态本质是一种动态绑定,在运行时,基于输入对象的类型,判断要调用的正确方法;在编译时,编译器是不知道要传入什么类型的参数的,所以只能在运行时进行判断;
陷阱:重写 private 方法
如果某个方法或属性在基类中声明为 private,则该方法或属性对派生类是隐藏的,在内存中存在,但无法直接调用,相当于没有继承;但是可以间接访问,即调用父类的方法进行访问;
在派生类中可以起同名的方法;但是这里有个陷阱,当向上转型,即将派生类做类型转换成父类时,调用的同名方法,将会是父类的方法,而不是子类的;
陷阱:属性与静态方法
只有方法是可以多态的,属性不能多态;如果在代码中直接访问一个属性,那么在编译时,这个属性的值就已经确定下来了,不是动态绑定的;不过这种情况很少见,有两方面的原因:
- 一般会将属性默认设置为 private,因此该属性在子类中是不可见的,默认访问父类的属性值;
- 如果子类真的有个属性取值不同,一般也会起个不同的属性名称,以免产生混淆;
1 |
|
如果方法是 static 静态的,那么它也没有多态;
构造器和多态
构造器有点像是隐式声明的 static 方法,因此不具有多态的特征;
构造器调用顺序
在初始化子类对象时,会向上溯源,逐级调用父类的构造器;如果父类没有无参构造器,而子类也没有显式调用父类的有参构造器,那么编译时会报错;
调用顺序:
- 加载子类
- 加载父类
- 初始化父类属性
- 运行父类构造器
- 初始化子类属性
- 运行子类构造器
继承和清理
通常不需要手工清理对象,但是有些特殊情况下可能需要。当需要手工清理时,需要给每个类添加清理的方法,并且清理的顺序应该跟构造顺序相反;每个子类在清理的时候,需要记得调用父类的清理方法,逐级向上传递清理指令;
有时候某个成员对象可能被多个其他对象共享,此时可能需要引入引用计数来处理;可考虑使用 static 变量来记录引用的次数;
构造器中的多态方法
当基类在构造器中调用某个方法时,如果派生类覆盖了该方法,那么在实例化派生类对象时,会优先选择子类的方法来执行。但是此时子类的属性还没有初始化赋值,因此如果读取属性,会得到一个类型默认值;为了避免这种情况,基类构造器中调用的方法,最好是自身的 final 方法,这样可确保不会被覆盖,以免产生预想不到的结果;
1 |
|
协变返回类型
子类覆盖父类的方法的方法,返回类型可以跟父类方法相同,也可以是父类方法返回的类型的子类型;
使用继承设计
组合比继承更加灵活,除非有确切的证据证明使用继承会让事情变得更加简单,否则优先使用组合;因为组合不仅简单,而且使用起来更加灵活,更容易应对需求变化;
由子类转成父类永远是安全的,因为父的方法不会比子类多;但是反过来就不安全了,因为子类可能存在父类没有新方法;当出现这类型的向下转换时,JVM 在解释代码并运行时,会检查代码中是否调用了父类没有的方法,如果是的话,运行时会报错;
第10章 接口
接口或者抽象类,提供了一种将接口与实现分离的结构化方法;
抽象类和方法
创建一个抽象类的目的,是为了对外展示一组通用的接口,建立某种接口规范,方便通用;
1 |
|
对于抽象类的派生类,它需要为基类的方法提供具体实现;如果不提供,该派生类只能当作一个抽象类,必须添加 abstract 标记,不然编译器会报错;
即便一个类中没有任何抽象方法,也可以将这个类声明为抽象类。 这样做的好处是可以避免调用者实例化这个类;
1 |
|
接口创建
interface 更像是用来建立类之间的使用协议,即告知需要实现的方法名称
接口的典型应用是给类添加一个形容词,例如 Runnable, Serializable 等;
1 |
|
多继承
Java 早期是不允许多继承的,因为多继承让 C++ 变得很复杂;但是 default 接口的引入,让 Java 实际上拥有了多继承的功能;差别在于只能继承方法,不能继承属性;所有属性仍然只能来自基类或者抽象类;
接口中的静态方法
接口中的方法默认是 public,但接口中也允许添加静态方法;或许可用来放置一些工具方法;
1 |
|
抽象类和接口
创建一个新类时,只能继承自一个抽象类,但是可以继承多个接口;
通常情况下,普通类已经够用了。因此尽量不要使用接口,也不要使用抽象类,除非有明确证据证明好处很大;当要用时,也尽量使用接口,少用抽象类;
完全解耦
多接口结合
一个派生类可以继承多个接口,反过来,它也可以向上转型为任意一个继承的接口;这样做是安全的;
如果某个类已知要做为基类,那么优先将其定义为接口
使用继承扩展接口
继承多个接口时,可能会出现命名冲突;此时没有特别好的办法,只能是起个不同的名字;
接口适配
策略设计模式:编写一个方法,接收一个方法做为参数;传入的对象,只要遵循这个接口,就是安全的
接口字段
在没 enum 类型之前,接口是放置常量的好地方;因为它的成员默认都是 final 和 public 的;
接口嵌套
可以将接口定义在类的内部;
接口和工厂方法模式
通常情况下,初始化一个对象是调用其构造器,而所谓的工厂方法,是调用其内部用于创建对象的方法,返回一个对象;这种做法的好处是将接口与实现进行分离。
使用接口背后的原因必须是真正存在不同的实现。但是一开始并非如此,所以第一时间使用接口 + 工厂方法是一种过度设计,会带来没有必要的复杂性。只有当真正出现不同实现的那么,才需要考虑是否重构和使用接口;
第11章 内部类
第12章 集合
第13章 函数式编程
第14章 流式编程
流支持
Java 早期是严格面向对象的,后来设计者为其添加流式编程范式,为了不破坏现在的接口,通过在接口中引入 default 巧妙的解决了这一问题;
Java.util 的集合类都添加了 stream() 方法,可以很方便的将集合转成流;
每个基本类型都有一个包装类,二者的相互转换叫做 box 和 unbox,即装箱和拆箱;
流创建
Stream.of() 可将一组元素转换成流
1 |
|
每个集合都可以调用 stream 方法来生成一个流
1 |
|
随机数流
Random 类提供了一个生成随机数流的方法
1 |
|
中间操作
Optional 类
当某个计算结果可能返回空值时,可以将其包装在 Optional 类中,然后通过 empty 方法判断是否为空;
便利函数
可用来处理当 Optinal 中的值不存在的情况,例如 ifPresent, orElse, 等;
1 |
|
第19章 类型信息
RTTI,在运行时判断类型信息
面向对象编程的一个基本目的是只需操作基类,然后通过多态,即可得到预期结果,提高代码的适用性,减少代码的变更;
第25章 设计模式
所谓的设计模式,相当于解决某类问题的巧妙办法的经验总结,它的核心目的是想隔离变化。也就是说当外界出现变化时,代码需要更改的东西尽量少,降低维护的难度;将易变的事物,和不易变的事物,隔离开来;
模式可分为三大类:
- 创建型:如何创建对象;
- 结构型:处理对象与其他对象连接方式,以便外部出现更改时,这部分连接无须更改;
- 行为型:封装一些通用的行为;例如迭代器
创建型
模板方法模式
这模式属于行为型,基本原理是在父类定义了整个框架,然后允许子类自定义修改其中少数几个方法,以实现使用者预期的效果;相当于设计者定了个模板,然后使用者微调一些局部,以实现想要的效果;
工厂方法模式
通常情况下,使用构造函数来创建对象;工厂方法的意思是,在类里面单独定义一个创建对象的方法,只允许调用者通过该方法创建对象,而不是直接调用构造函数来创建,而是由这个工厂方法自己去调用构造函数;这样做的好处是,子类可以改写这个方法,不同的子类,可以生产出不同类型的对象;
当初始化对象很简单,使用构造函数是没有问题的;但是如果初始化很复杂,由于构造函数不能继承,每次都需要单独编写,这有可能导致多个子类都在重复的代码;为了便于维护,可通过工厂模式,将初始化对象的代码抽离出来,实现复用,方便维护;
工厂方法还有一个好处是可以给方法编写更容易理解的名字,而不需要像构造函数一样必须跟类名相同;但感觉这点貌似有点牵强,因为子类一般名字也是有意义的;
对于调用者来说,只需要考虑根据自身需要,创建不同的子类对象,并调用它的通用方法即可;至于这些对象背后如何实现自己的方法,调用者可以不用关系;
动态工厂:使用反射,传入类型名称,查找类,加载,调用类的构建函数;这样做的好处是可以在使用的时候才加载,不用提前加载;
抽象工厂
类似工厂方法,唯一的不同是有多个工厂,不同的工厂生产不同类型的东西;
生成器模式
也叫建造者模式,builder
不使用构造函数来创建复杂的对象,而是设计一个单独的生成器函数,以便可以根据需要组合起对象;个人感觉这个东西很像是没有参数默认值的一种变通;
原型模式
为了实现克隆,但由于对象内部有些属性可能是私有的,所以从外部克隆不可行;可以让对象自己实现一个克隆的方法,供别人调用,这样就可以避开私有的问题了;
结构型
代理模式和桥接模式
代理模式顾名思义,就是创建一个二房东出来,将一房东封装起来,二房东自己跟一房东谈,其他房客都跟二房东谈;这样的好处是如果房东有什么变化,变化只局限于两个房东之间,房客不需要变更;
行为型
状态模式
某个场景可能拥有多种状态,在不同状态下,需要采取不同的行为表现;传统的方式是写一堆 if else 来做条件判断,这是可行的。只是如果状态真的很多的话,维护起来会比较麻烦;
状态模式的解决方案,是将每种状态和关联的行为单独抽象出来成为一个对象,然后场景根据当前的状态,关联不同状态对象;场景调用该对象的方法,实现不同的行为表现;为了让场景的调用变得简单,这些对象需要遵循接口规范,需要方法的命名都一样,这样才方便场景的调用;调用后,如果状态发生了改变,那么状态对象还会改变场景的状态值,以便让其关联新的状态对象;
这个模式非常适用于有限状态机的场景,但在实际的业务中,貌似还没有遇到过;
策略模式
感觉跟状态模式差不同,唯一的区别是策略模式不改变上下文的状态;
责任链模式
将一大堆处理拆分一下,每个处理步骤单独抽象成一个对象,该对象只负责一小部分职责;如果通过,就将任务传递给后面步骤的对象;
适配器模式
不同的数据来源,用一个适配器,将它们转换成统一的格式,再传递给后面的对象处理;
外观模式
整合很多复杂的功能和接口,将它们统一起来,对外只提供一个简单的接口,隐藏后端的复杂性,个人感觉跟 HTTP API 接口的理念类似;
观察者模式
就是 pub/sub,整个列表,将感兴趣的对象添加进去;当事件发生时,逐个通知这些对象;
访问者模式
添加新功能的一种办法,是给对象添加新的方法;还有一种方法是将对象做为参考传递给一个新方法,让新方法能够访问对象中的数据就可以了;