读书笔记
《Java编程思想》读书笔记(一)
很早之前就买了《Java编程思想》这本书,初学时看这本书看的云里雾里的,实在费劲,就放在一边垫桌底了。感觉这本书是适合C/C++程序员转行到Java学习的一本书,并不适合零基础的初学者去看这本书,毕竟当初花了一百多买了这本书,现在还是把它倒腾出来看一下吧,当作是巩固Java基础知识,本文会把自己感兴趣的知识点记录一下,相关实例代码:https://gitee.com/reminis_com/thinking-in-java
第一章:对象导论
这一章主要是帮助我们了解面向对象程序设计的全貌,更多是介绍的背景性和补充性的材料。其实萌新应该跳过这一章,因为这章并不会去讲语法相关的知识,当然可以在看完这本书后续章节后,再来回看这一章,这样有助于我们了解到对象的重要性,以及怎样使用对象进行程序设计。
Alan Kay曾经总结了第一个成功的面向对象语言、同时也是Java所基于的语言之一的Smalltalk的五个基本特性,这些特性表现了一种纯粹的面向对象的程序设计方式:
- 万物皆为对象。理论上讲,你可以抽取待求解问题的任何概念化构件(狗、建筑物、服务等),将其表示为程序中的对象。
- 程序是对象的集合,它们通过发送消息来告知彼此所要做的。要想请求一个对象,就必须对该对象发送一条消息。更具体的说,可以把消息想象为对某个特定对象的方法的调用请求。
- 每个对象都有自己的由其它对象所构成的存储。换句话说,可以通过创建包含现有对象的方式来创建新类型的对象。
- 每个对象都拥有其类型。按照通用的说法,“每个对象都是某个类(class)的一个实例(instance)”,每个类最重要的区别与其他类的特性就是“可以发送什么样的消息给它”。
- 某一特定类型的所有对象都可以接受同样的消息。
第二章:一切都都是对象
用引用操纵对象
每种编程语言都有自己操作内存中元素的方式。有时候,程序员必须注意将要处理的数据是什么类型,你是直接操纵元素,还是用某种特殊语法的间接表示(例如C/C++里得指针)来操作对象?
所有这一切在Java里都得到了简化。一切都被视为对象,因此可采用单一固定的语法。尽管一切都看作对象,但操纵的标识符实际上是对象的一个"引用"(reference)。可以将这情形想像成用遥控器(引用)来操纵电视机(对象)。只要握住这个遥控器,就能保持与电视机的连接。当有人想改变频道或者减小音量时,实际操控的是遥控器(引用),再由遥控器来调控电视机(对象)。如果想在房间里四处走走,同时仍能调控电视机,那么只需携带遥控器(引用)而不是电视机(对象)。 此外,即使没有电视机,遥控器亦可独立存在。也就是说,你拥有一个引用,并不一定需要有一个对象与它关联。
存储到什么地方
程序运行时,对象是怎么进行放置安排的呢?特别是内存是怎样分配的呢?对这些方面的了解会对你有很大的帮助。有五个不同的地方可以存储数据∶ 1)寄存器。这是最快的存储区,因为它位于不同于其他存储区的地方——处理器内部。但是寄存器的数量极其有限,所以寄存器根据需求进行分配。你不能直接控制,也不能在程序中感觉到寄存器存在的任何迹象(另一方面,C和C++允许您向编译器建议寄存器的分配方式)。 2)堆栈。位于通用RAM(随机访问存储器)中,但通过堆栈指针可以从处理器那里获得直接支持。堆栈指针若向下移动,则分配新的内存;若向上移动、则释放那些内存。这是一种快速有效的分配存储方法,仅次于寄存器。创建程序时,Java系统必须知道存储在堆栈内所有项的确切生命周期,以便上下移动堆栈指针。这一约束限制了程序的灵活性,所以虽然某些Java 数据存储于堆栈中--特别是对象引用,但是Java对象并不存储于其中。 3)堆。一种通用的内存池(也位于RAM区),用于存放所有的Java对象。堆不同于堆栈的好处是∶编译器不需要知道存储的数据在堆里存活多长时间。因此,在堆里分配存储有很大的灵活性。当需要一个对象时,只需用new写一行简单的代码,当执行这行代码时、会自动在堆里进行存储分配。当然,为这种灵活性必须要付出相应的代价∶用堆进行存储分配和清理可能比用堆栈进行存储分配需要更多的时间(如果确实可以在Java中像在C++中一样在栈中创建对象)。 4)常量存储。常量值通常直接存放在程序代码内部,这样做是安全的,因为它们永远不会被改变。有时,在嵌入式系统中,常量本身会和其他部分隔离开,所以在这种情况下,可以选择将其存放在ROM(只读存储器)中。 5)非RAM存储。如果数据完全存活于程序之外,那么它可以不受程序的任何控制,在程序没有运行时也可以存在。其中两个基本的例子是流对象和持久化对象。在流对象中,对象转化成字节流,通常被发送给另一台机器。在"持久化对象"中,对象被存放于磁盘上,因此,即使程序终止,它们仍可以保持自己的状态。这种存储方式的技巧在于∶把对象转化成可以存放在其它媒介上的事物,在需要时,可恢复成常规的、基于RAM的对象。java提供了对轻量级持久化的支持,而诸如JDBC和Hibernate这样的机制提供了更加复杂的对在数据库中存储和读取对象信息的支持。
第三章:操作符
本章的内容比较基础,主要讲了赋值、算数操作符、关系操作符、逻辑操作符、按位操作符、移位操作符、三元操作符等基础知识。本章只是记录下递增和递减的相关知识。
自动递增和递减
递增和递减操作符不仅改变了变量,并且以变量的值作为生成的结果。这两个操作符各有两种使用方式,通常称为前缀式和后缀式,对于前缀递增和前缀递减(假设a是一个int值,如++a或--a),会先执行运算,再生成值,而对于后缀递增和后缀递减(如a++或a--),会先生成值,在执行运算,下面是一个例子:
public class AutoInc {
public static void main(String[] args) {
int i = 1;
System.out.println("i: " + i); // 1
System.out.println("++i: " + ++i); // 执行完运算后才得到值,故输出2
System.out.println("i++: " + i++); // 运算执行之前就得到值,故输出2
System.out.println("i: " + i); // 3
System.out.println("--i: " + --i); // 执行完运算后才得到值,故输出2
System.out.println("i--: " + i--); // 运算执行之前就得到值,故输出2
System.out.println("i: " + i); // 1
}
}
总结:对于前缀形式,我们在执行完运算后才得到值。但对于后缀形式,则是在运算执行之前就得到值。
第四章:控制执行流程
本章介绍了大多数编程语言都具有的基本特性:运算、操作符优先级、类型以及选择和循环等。例如布尔表达式、循环如while、do-While、for、分支判断如if-else以及选择语句switch-case-break等。由于本章的内容都是非常基础的语法知识,这里不再赘述。
第五章:初始化和清理
在Java中,通过提供构造器,类得设计者可以确保每个对象都会得到初始化。创建对象时,如果其类具有构造器,Java就会在用户有能力操作对象之前自动调用相应的构造器,从而保证了初始化的进行。对于不再使用的内存资源,Java提供了垃圾回收器机制,垃圾回收器会自动地将其释放。
- 为什么不能以返回值区分重载方法?
比如下面两个 方法,虽然他们有同样的方法名称和形参列表,但却很容易区分它们:
public void f(int i);
public int f(int i) { return i; }
只要编译器可以根据语境明确判断出语义,比如在 int x = f(1)
中,那么的确可以据此区分重载方法。不过,有时我们并不关心方法的返回值,我们想要的是方法调用的其它效果(这通常被称为“为了副作用而调用”),这时你可能会调用方法而忽略其返回值,如这样调用方法:f(1)
,此使Java如何才能判断你调用的哪一个f(int i)
方法呢?因此,根据方法的返回值来区分重载是行不通的。
- 静态数据的初始化 无论你创建多少个对象,静态数据都只占用一份存储区域。static关键字不能应用于局部变量,因此它只能作用于域。如果一个域是静态的基本类型域,且没有对他进行初始化,那么它就会获得基本类型的标准初始值,如果它是一个对象引用,那么它的默认初始值就是null。
静态数据初始化示例如下:
public class StaticInitialization {
public static void main(String[] args) {
System.out.println("Creating new Cupboard() in main");
new Cupboard();
System.out.println("Creating new Cupboard in main");
new Cupboard();
table.f2(1);
cupboard.f3(1);
}
static Table table = new Table();
static Cupboard cupboard = new Cupboard();
}
class Bowl {
Bowl(int marker) {
System.out.println("Bowl(" + marker + ")");
}
void f1(int marker) {
System.out.println("f1(" + marker + ")");
}
}
class Table {
static Bowl bowl1 = new Bowl(1);
Table() {
System.out.println("Table()");
bowl2.f1(1);
}
void f2(int marker) {
System.out.println("f2(" + marker + ")");
}
static Bowl bowl2 = new Bowl(2);
}
class Cupboard {
Bowl bowl3 = new Bowl(3);
static Bowl bowl4 = new Bowl(4);
Cupboard() {
System.out.println("Cupboard");
bowl4.f1(2);
}
void f3(int marker) {
System.out.println("f3(" + marker + ")");
}
static Bowl bowl5 = new Bowl(5);
}
/* Output:
Bowl(1)
Bowl(2)
Table()
f1(1)
Bowl(4)
Bowl(5)
Bowl(3)
Cupboard
f1(2)
Creating new Cupboard() in main
Bowl(3)
Cupboard
f1(2)
Creating new Cupboard in main
Bowl(3)
Cupboard
f1(2)
f2(1)
f3(1)
*/
总结一下对象的创建过程,假设有个名为Dog的类:
- 即使没有显示地使用static关键字,构造器实际上也是静态方法。因此,当首次创建类型为Dog的对象时(构造器可以看成静态方法),或者Dog类得静态方法/静态域首次被访问时,Java解释器必须查找类路径,以定位Dog.class文件。
- 然后载入Dog.class,有关静态初始化的所有动作都会执行,因此,静态初始化只在Class对象首次被加载的时候进行一次。
- 当用new Dog()创建对象的时候,首先将在堆上为Dog对象分配足够的存储空间。
- 这块存储空间会被清零,这就自动地将Dog对象中的所有基本类型数据都设置成了默认值,而引用则被设置成了null
- 执行所有出现于字段定义处的初始化动作
- 执行构造器
3.finalize()
的用途何在? 无论对象是如何创建的,垃圾回收器都会负责释放对象占据的所有内存,这将对finalize()
的需求限制到一种特殊情况,即通过某种创建对象方式以外的方式为对象分配了存储空间,但Java中一切皆为对象,那这种特殊情况是怎么回事呢?
看来之所以要有finalize()
方法,是由于在分配内存时可能采用了类似C语言中的做法,而非Java中的通常做法,这种情况主要发生在“本地方法”的情况下,本地方法是一种在Java中调用非Java代码的方式,本地方法目前只支持C和C++,但它们可以调用其他语言写的代码,所以实际上可以调用任何代码。在非Java代码中,也许会调用C的malloc()
函数系列来分配存储空间,而且除非调用了free()
函数,否则存储空间将永远得不到释放,从而造成内存泄漏,当然,free()
是C和C++中的函数,所以需要在finalize()
中用本地方法调用它。
记住,无论是“垃圾回收”还是“终结”,都不保证一定会发生,如果Java虚拟机(JVM)并未面临内存耗尽的情形,它是不会浪费时间去执行垃圾回收以恢复内存的。
如下例,示范了finalize()可能的使用方式:
public class TerminationCondition {
public static void main(String[] args) {
Book novel = new Book(true);
// proper cleanup
novel.checkIn();
// Drop the reference, forget to clean up
new Book(true);
// 强制进行终结动作,并调用finalize()
System.gc();
}
}
class Book {
boolean checkOut = false;
Book(boolean checkOut) {
this.checkOut = checkOut;
}
void checkIn() {
checkOut = false;
}
@Override
protected void finalize() {
if (checkOut) {
System.out.println("Error: checked out");
// 你应该总是假设基类的finalize()也要做某些重要的事情,因此要用super来调用它
// super.finalize();
}
}
}
本例的总结条件是:所有的Book对象在被当作垃圾回收前都应该被签入(check in),但在main()方法中,由于程序员的错误,有一本书未被签入,要是没有finalize()来验证终结条件,将很难发现这种缺陷。
第六章:访问权限控制
本章讨论了类是如何被构建成类库的:首先,介绍了一组类是如何被打包到一个类库中的;其次,类是如何控制对其成员访问的。在Java中,关键字package、包的命名模式和关键字import,可以使你对名称进行完全的控制,因此名称冲突的问题是很容易避免的。
控制对成员的访问权限有两个原因:第一是为了使用户不要碰触那些他们不该碰触的部分,这些部分对于类内部的操作是必要的,但是它并不属于客户端程序员所需接口的一部分。因此将方法和域指定为private,对客户端程序员而言是一种服务。二是为了让类库设计者可以更改类的内部工作方式,而不必担心这样会对客户端程序员产生重大的影响。
第七章:复用类
在本章介绍了两种代码重用机制,分别是组合和继承。在新的类中产生现有类的对象,由于新的类是由现有类的对象组成,所以这种方法称为组合。该方法只是复用了现有程序代码的功能。第二种方式则是按照现有类的类型来创建新类,无需改变现有类的形式,采用现有类的形式并在其中添加新的代码,这种方式称为继承。 在使用继承时,由于导出类具有基类接口,因此它可以向上转型至基类,这对多态来说至关重要。
final关键字
可能使用到final的三种情况:属性,方法和类。
- final属性:对于基本类型,final使数值恒定不变;而用于对象引用,final使引用恒定不变。一但引用被初始化指向一个对象,就无法再把它改为指向另外一对象,然而,对象其自身却是可以被修改的。
- final方法:把方法锁定,以防任何继承类修改它的含义。(类中所有的private方法都是隐式地指定为是final的,由于无法取用private方法,所以也就无法在导出类中覆盖它。当然你可以对private方法添加final修饰,但这并不能给该方法增加任何额外的意义)
- final类:当将某个类的整体定义为final时,就表明了你不打算继承该类,而且也不允许别人这么做 。换句话说,出于某种考虑,你对该类的设计永不需要做任何变动,或者出于安全的考虑,你不希望它有子类。(由于final类禁止继承,所以final类中的所有方法都隐式指定为是final的,因为无法覆盖他们。在final类中可以给方法添加final修饰词,但这并不会增添任何意义。)
第八章:多态
“封装”通过合并特征和行为来创建新的数据类型。“实现隐藏”则通过将细节“私有化”把接口和实现分离开来。多态的作用则是消除类型之间的耦合关系,由于继承允许将对象视为他自己本身的类型或其基类型来加以处理,因此它允许将许多种类型(从同一基类导出的)视为同一类型来处理,而同一份代码也就可以毫无差别地运行在这些不同类型之上了。
方法调用绑定
将一个方法调用 同 一个方法主体关联起来被称作绑定。若在程序执行前进行绑定,就叫做前期绑定(面向过程语言的默认绑定方式)。若在程序运行时根据对象的类型进行绑定就叫做后期绑定(也叫动态绑定和运行时绑定)。
Java中除了static方法和final方法(private方法属于final方法)之外,其他的所有方法都是后期绑定。由于Java中所有方法都是通过动态绑定来实现多态,我们就可以编写只与基类打交道的程序代码,并且这些代码对所有的导出类都可以正确运行。或者换一种说法,发送消息给某个对象,让该对象去断定应该做什么事。
构造器和多态
基类的构造器总是在导出类的构造过程中被调用,而且按照继承层次逐渐向上链接,以使每个基类的构造器都能得到调用,这样做是有意义的,因为构造器具有一项特殊任务:检查对象是都被正确构造。导出类只能访问它自己的成员,不能访问基类中的成员(基类成员通常是private类型)。只有基类的构造器才具有恰当的知识和权限来对自己的元素进行初始化。因此,必须令所有的构造器都得到调用,否咋就不能可能正确构造完整对象。这正是编译器为什么要强制每个导出类部分都必须调用构造器的原因。
让我们来看看下面这个例子,他展示了组合、继承以及多态在构建顺序上的作用:
public class Sandwich extends PortableLunch{
private Bread b = new Bread();
private Cheese c = new Cheese();
private Lettuce l = new Lettuce();
Sandwich() {
System.out.println("sandwich()");
}
public static void main(String[] args) {
new Sandwich();
}
}
class Meal {
Meal() {
System.out.println("Meal()");
}
}
class Bread {
Bread() {
System.out.println("Bread()");
}
}
class Cheese {
Cheese() {
System.out.println("Cheese()");
}
}
class Lettuce {
Lettuce() {
System.out.println("Lettuce()");
}
}
class Lunch extends Meal {
Lunch() {
System.out.println("Lunch()");
}
}
class PortableLunch extends Lunch {
PortableLunch() {
System.out.println("PortableLunch()");
}
}
/* Output:
Meal()
Lunch()
PortableLunch()
Bread()
Cheese()
Lettuce()
sandwich()
*/
复杂对象调用构造器要遵照如下顺序:
- 调用基类的构造器。这个步骤会不断地反复递归下去,首先是构造这种层次结构的根,然后是下一层导出类,等等,直到最底层的导出类。
- 按声明顺序调用成员的初始化方法
- 调用导出类的构造器主体
构造器内部的多态方法的行为:构造器调用的层次结构带来了一个有趣的两难问题,如果在一个构造器的内部调用正在构造的对象的某个动态绑定方法,那会发生什么情况呢?一个动态绑定的方法调用会向外深入到继承层次结构内部,它可以调用导出类里的方法。如果我们是在构造器内部这样做,那么就可能会调用某个方法,而这个方法所操作的成员变量可能还未进行初始化——这肯定会招致灾难,如下例:
public class PolyConstructors {
public static void main(String[] args) {
new RoundGlyph(5);
}
}
class Glyph{
void draw() {
System.out.println("Glyph.draw()");
}
Glyph() {
System.out.println("Glyph() before draw()");
draw();
System.out.println("Glyph() after draw()");
}
}
class RoundGlyph extends Glyph {
private int radius = 1;
RoundGlyph(int r) {
this.radius = r;
System.out.println("RoundGlyph.RoundGlyph(), radius = " + radius);
}
@Override
void draw() {
System.out.println("RoundGlyph.draw(), radius = " + radius);
}
}
/* Output:
Glyph() before draw()
RoundGlyph.draw(), radius = 0
Glyph() after draw()
RoundGlyph.RoundGlyph(), radius = 5
*/
由该示例可以看出,上面说的初始化顺序并不完整,初始化实际过程的第一步应该是:在其它任何事物发生之前,将分配给对象的存储空间初始化成二进制的零。
构造器的编写准则:用尽可能简单的方法使对象进入正常状态,如果可以的话,避免调用其他方法。在构造器内唯一能够安全调用的那些方法就是基类中的final方法(也适用于private方法)。
第九章:接口
接口也可以包含域,但是这些域隐式地是static和final的(因此接口就成为了一种很便捷的用来创建常量组的工具)。你可以选择在接口中显示地将方法声明为public的,但即使你不这么做,它们也是public的。因此,当要实现一个接口时,在接口中被定义的方法必须被定位为是public的;否则,它们将只能得到默认的包访问权限,这样在方法被继承的过程中,其可访问权限就降低了,这是Java编译器所不允许的。
如果要从一个非接口的类继承,那么只能从一个类去继承。其余的基本元素都必须是都必须是接口。需要将所有的接口名都置于implements关键字之后,用逗号将它们一一隔开。可以继承任意多个接口,并可以向上转型为每个接口,因为每一个接口都是一个独立类型。下面这个例子展示了一个具体类组合数个接口之后产生了一个新类。
interface CanFight {
void fight();
}
interface CanSwim {
void swim();
}
interface CanFly {
void fly();
}
class ActionCharacter {
public void fight() {}
}
/**
* 当通过这种方式将一个具体类和多个接口组合在一起时,这个具体类必须放在前面,
* 后面跟着的才是接口(否则编译器会报错)
*/
class Hero extends ActionCharacter
implements CanFight, CanFly, CanSwim {
@Override
public void swim() { }
@Override
public void fly() { }
}
public class Adventure {
public static void t(CanFight x) { x.fight(); }
public static void f(CanFly x) { x.fly(); }
public static void s(CanSwim x) { x.swim(); }
public static void a(ActionCharacter x) { x.fight(); }
public static void main(String[] args) {
Hero h = new Hero();
t(h);
f(h);
s(h);
a(h);
}
}
该例也展示了使用接口的两个核心原因:
- 为了能够向上转型为多个基类型(以及由此而带来的灵活性)
- 防止客户端程序员创建该类的对象,并确保这仅仅是建立一个接口
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· 写一个简单的SQL生成工具
· AI 智能体引爆开源社区「GitHub 热点速览」
· C#/.NET/.NET Core技术前沿周刊 | 第 29 期(2025年3.1-3.9)