01 编程基础-编程语言概述
计算机硬件、编译器、源代码
编译和执行
编译器的结构
- 编译、执行(C)
- 直接解释执行(Basic)
- 编译成字节码、解释执行(Java)
代码与语言
命令式编程
命令式编程(Imperative Programming)是一种描述计算机如何完成任务的编程范式。这种范式强调明确的指令和步骤,程序员需要详细地描述程序的控制流,即程序应当如何做。这种范式接近于计算机的底层运作方式,强调的是“怎么做”。
特点:
- 状态变化:程序通过改变变量的状态来进行操作。
- 控制流:包括顺序执行、循环和分支语句(如
for
、while
、if
等)。 - 细节控制:程序员需要管理内存和资源。
示例(Python):
1 | total = 0 |
声明式编程
声明式编程(Declarative Programming)是一种描述程序应该完成什么任务的编程范式,而不是描述如何完成任务。这种范式强调程序的逻辑而不是控制流,即程序员告诉计算机“做什么”,而不是“怎么做”。
特点:
- 关注结果:程序员定义的是期望的结果,而不是具体的步骤。
- 没有状态变化:通常没有显式的状态变化,函数式编程和逻辑编程是声明式编程的两种常见形式。
- 简洁和抽象:程序更简洁,更易于理解和维护。
示例(SQL):
1 | SELECT SUM(value) FROM numbers WHERE value BETWEEN 1 AND 10; |
示例(Python,使用列表推导和函数式编程):
1 | total = sum([i for i in range(1, 11)]) |
对比总结
命令式编程更接近于机器语言,需要程序员管理每一个细节和步骤,适合对性能要求较高的场景。
声明式编程则更抽象,程序员更多地关注业务逻辑和结果,而不必关心具体的实现细节,适合开发效率和代码可维护性要求较高的场景。
过程式和面向对象式
02 编程基础-可计算性
可计算性
逻辑发展简史
罗素悖论
停机问题
哥德尔不完备性定理
邱奇-图灵论题
不可计算
可计算模型
μ-递归函数
图灵机
Register Machine
03 编程基础-Lambda演算
1. Lambda 演算的定义
-
形式化定义
:通过标识符(identifier)集合 {a, b, c, …, x, y, z, x1, x2, …} 表达所有的 lambda 表达式。
- BNF范式定义:
<表达式> ::= <标识符>
<表达式> ::= (λ<标识符> .<表达式>)
<表达式> ::= (<表达式> <表达式>)
- 示例:
(λ x. 2x)
(λ x y. x + y) a b
(科里化为(((λ x. (λ y. (y + x))) a) b)
)
- BNF范式定义:
这些内容描述的是 Lambda 演算中的语法规则,使用了巴科斯-诺尔形式(Backus-Naur Form, BNF)来定义 Lambda 表达式的构成。这种形式化定义有助于明确说明如何构建合法的 Lambda 表达式。具体含义如下:
Lambda 演算的语法规则
基本术语
- 标识符(Identifier):一个变量名或符号,例如 x, y, z 等。
- 表达式(Expression):Lambda 演算中的一个合法语句,可以是一个标识符、一个 Lambda 表达式或两个表达式的组合。
语法规则
-
标识符:
1
<表达式> ::= <标识符>
- 解释:一个合法的表达式可以是一个标识符。比如,
x
是一个合法的表达式。 - 例子:
x
,y
,z
。
- 解释:一个合法的表达式可以是一个标识符。比如,
-
Lambda 抽象:
1
<表达式> ::= (λ<标识符> .<表达式>)
- 解释:一个合法的表达式可以是一个 Lambda 抽象。Lambda 抽象表示一个匿名函数,其格式为
λ
后跟一个标识符(即函数参数),再后跟一个点和一个表达式(即函数体)。 - 例子:
λx. x
表示一个接收参数x
并返回x
的函数。λx. x + 1
表示一个接收参数x
并返回x + 1
的函数。
- 解释:一个合法的表达式可以是一个 Lambda 抽象。Lambda 抽象表示一个匿名函数,其格式为
-
应用:
1
<表达式> ::= (<表达式> <表达式>)
- 解释:一个合法的表达式可以是两个表达式的组合,表示函数应用。第一个表达式通常是一个函数,第二个表达式是该函数的参数。
- 例子:
(λx. x) y
表示将y
作为参数传递给函数λx. x
,结果是y
。(λx. x + 1) 5
表示将5
作为参数传递给函数λx. x + 1
,结果是5 + 1
,即6
。
结合示例
使用这些规则,可以构建更复杂的 Lambda 表达式。例如:
1 | (λx. (λy. x + y)) 3 |
- 这是一个应用,其中
λx. (λy. x + y)
是一个函数,3
是参数。 - 进一步简化,得到
λy. 3 + y
,表示一个接收参数y
并返回3 + y
的函数。
再举一个复杂的例子:
1 | (λx. (λy. x + y) 2) 3 |
- 这是一个应用,其中
λx. (λy. x + y) 2
是一个函数,3
是参数。 - 首先内部的应用
(λy. x + y) 2
被求值,得到x + 2
。 - 然后外部应用
λx. x + 2
被求值,传递参数3
,得到3 + 2
,即5
。
通过这些语法规则和例子,可以看到 Lambda 演算是如何表达计算过程的。这些规则提供了强大的工具,用于描述和操作函数和数据。
2. 演算公理系统
- alpha 变换:
- 改变变量名称,但不改变表达式的含义。
- 例子:
λ xy. x + y => λ ab. a + b
- beta 规约:
- 将函数应用到参数上并进行简化。
- 例子:
(λ xy. x + y) a b => a + b
3. 算术计算与逻辑谓词
- 自然数模拟:
ZERO = λ f. λ x. x
SUCC = λ n. λ f. λ x. f (n f x)
- 示例:
ONE = SUCC ZERO
TWO = SUCC ONE
- 算术运算:
PLUS = λ m. λ n. m SUCC n
MULT = λ m. λ n. λ f. m (n f)
POW = λ b. λ e. e b
- 逻辑运算:
TRUE = λ x. λ y. x
FALSE = λ x. λ y. y
- 示例:
AND = λ p. λ q. p q p
OR = λ p. λ q. p p q
NOT = λ p. λ a. λ b. p b a
- 谓词:
ISZERO = λ n. n (λ x. FALSE) TRUE
LEQ = λ m. λ n. ISZERO (SUB m n)
EQ = λ m. λ n. AND (LEQ m n) (LEQ n m)
4. 递归与 Y Combinator
- 定义递归函数:
- 阶乘例子:
FACT = λ n. IF (ISZERO n) ONE (MULT n (FACT (PRED n)))
- 使用 Y 组合子解决递归定义问题:
Y = λ g. (λ x. g (x x)) (λ x. g (x x))
FACT = Y FACT1
- 阶乘例子:
5. 有序对
- 定义有序对:
- 构建有序对的基本函数是
CONS
,定义为λx.λy.λf. f x y
。 CAR
和CDR
分别用于获取有序对的第一个和去除第一个元素。分别定义为λp.p TRUE
和λp.p FALSE
。NIL
表示空的有序对,定义为λx. TRUE
。- 谓词⽤于判断⼀个有序对是否为空
NULL
定义为\p.p (\x.\y.FALSE)
- 构建有序对的基本函数是
- 基本验证:
- 通过一系列 Lambda 表达式,可以验证
CAR
和CDR
是否正确提取了有序对中的元素,并检查有序对是否为空。 - 示例:
- 通过一系列 Lambda 表达式,可以验证
1 | CONS a (CONS b (CONS c NIL)) |
- 长度函数:
- 定义了
LENGTH
函数计算一个有序对的长度:
- 定义了
1 | LENGTH = Y (\\g.\\c.\\x. NULL x c (g (SUCC c) (CDR x))) ZERO |
以上总结展示了有序对的基本定义、验证及其长度计算,反映了有序对在Lambda演算中的实现及其应用。
04 编程基础-程序的结构
-
代码是用来读的:
- 强调代码的可读性。代码不仅是给计算机运行的,更是给人阅读和维护的。
- 提供了如何通过使用合适的变量名和结构化代码来提高代码可读性的建议。
-
降低复杂度:
- 通过分解与抽象的方法,减少程序的复杂性。
- 分解:将复杂问题分解成小的、可管理的部分。
- 抽象:通过定义接口和实现来隐藏复杂细节,简化使用。
- 层次性:分解与抽象的并⽤和层次性
-
程序=算法+数据结构:
- 强调算法和数据结构是编程的基础。
- 提供了一些简单的算法和数据结构的案例,如线性表、数组插入和链表插入。
-
算法建模:
- 介绍了基本表达式、分解(组合)和抽象的三种基本机制。
- 提供了递归与迭代的对比,通过具体的例子展示了两种方法的使用场景,如计算阶乘和斐波那契数列。
-
数据建模:
- 讨论了如何通过基础数据类型、数据组合和数据抽象来建模数据。
- 提供了有序对、结构体、对象等数据结构的案例,讨论了如何通过这些结构来表达复杂的数据关系。
-
模块化:
- 强调模块化的重要性,通过模块来组织代码,减少耦合度,提高代码的可维护性和重用性。
- 讨论了模块间的交互、求值环境以及全局变量和局部变量的使用。
-
数据处理:
- 介绍了如何通过输入、处理和输出来进行数据处理。
-
用日志记录数据
-
强调日志记录的重要性,确保在数据处理过程中能够跟踪和记录操作。
-
05 编程基础-函数式编程范式
- 避免重复:
- 通过减少代码重复,提高代码的可读性和维护性。
- 使用循环和函数抽象来消除重复代码。
- 递归和迭代:
- 递归和迭代是解决问题的两种主要方法。
- 示例包括加法、Fibonacci数列和最大公约数的计算。
- 函数式编程特点:
- 函数作为头等公民:函数可以作为参数传递、作为返回值、赋值给变量或存储在数据结构中。
- 无副作用:函数调用不会产生除返回值之外的其他影响。
- 无状态变化:函数式编程中,状态不能保存在变量中,而是通过函数参数来传递,递归是一个典型的例子。
- Python中的函数式编程:
- 使用基本函数如
map()
、reduce()
、filter()
以及lambda
表达式进行函数式编程。
- 使用基本函数如
- 消除重复:
- 通过抽象函数来消除代码中的重复部分,提高代码的复用性和简洁性。
- 函数式编程的应用:
- 通过实际例题展示了命令式实现与函数式实现的对比,如统计长单词和处理名称列表。
- 证明程序正确性:
- 介绍了如何通过数学方法证明程序的正确性,包括枚举法和归纳法。
- 动态与静态实现的对比:
- 比较了逻辑实现与物理实现,动态存储与静态存储的区别。
这些知识点提供了函数式编程的基本概念和应用方法,有助于理解和实践这种编程范式。
06 结构化编程-思想
知识点
- 结构化方法思想(核心思想、模型)
- 数据流图(世界观、图例、语法规则、画图流程)
- 结构图(图例)
- 数据流图向结构图的转换
- 流程图(图例)
结构化方法
- 思想
- 自顶向下逐步求精
- 算法+数据结构
- 模型
- 数据流图
- 结构图
- 流程图
结构化思想
- 结构化编程思想
- 思想和模型
- 数据流图
- 结构图
- 流程图
数据流图的世界观
输入 -> 计算系统 -> 输出
所有的计算系统都是信息的处理和转换。
过程与数据
- 将系统看做是过程的集合;
- 过程就是对数据的处理:
- 接收输入,进行数据转换,输出结果
- 代表数据对象在穿过系统时如何被转换
- 可能需要和软件系统外的实体尤其是人进行交互
- 数据的变化包括:
- 被转换、被存储、或者被分布
课程表案例分析
- 显示
- 输出
- 生成输出内容
- 文件输出
- 控制台输出
- 数据流
- 命令
- 数据存储
- 课程表数据
- 文件地址
结构图(Structured Chart)
结构图
结构图转换为代码示例
1 | def read_and_validate(): |
流程图(Flowchart)
基本符号
- Terminal: 椭圆形符号表示程序逻辑的开始、停止和暂停。
- Input/Output: 平行四边形表示输入/输出操作。
- Processing: 矩形表示算术指令。
- Decision: 菱形表示决策点。
- Connectors: 圆形表示连接器,用于复杂或跨页的流程图。
- Flow lines: 箭头表示指令执行的顺序和控制流的方向。
流程图示例
1 | // Java program to find largest of two numbers |
总结:结构化编程
- 行为视角
- 首先根据行为来分解
- 接着设计数据来配合行为
- 全局数据
07 结构化编程- 变量
知识点
- 变量的意义
- 赋值的代价
- 函数式 vs 命令式(P35)
- 类型、值、变量
- 强类型语言、弱类型语言、动态语言、静态语言
- 生存期和作用域
- 变量常用角色
变量的概念
物理、逻辑、语义
- 物理的角度:计算机的数据存储单元
- 逻辑的角度:软件模型中的抽象数据单元
- 语义的角度:类的属性,方法的状态
1 | int sum = 0; |
-
现实对象的状态 -> 计算对象的状态 -> 用程序设计语言常规的符号名字来模拟
变量的属性
Types of Java
- Primitive types
- numeric types
- integral types
- byte, short, int, long and char
- floating types
- float and double
- boolean type
- Reference types
- class types
- interface types
- array types
- A special type
- null
JavaScript vs Java
- JavaScript 是动态类型,弱类型语言
- 例如:
var test = '666' / 3
,test 的值变成了 222,因为发生了隐式转换
- 例如:
- Java 是静态类型,强类型语言
- 例如:
int[] arr = new int[10]; arr[0] = '666' / 3;
会在编译时期得到一个语法错误
- 例如:
Java命名规则
- 变量(Variables)
- 除了变量名外,所有实例,包括类,类常量,均采用大小写混合的方式,第一个单词的首字母小写,其后单词的首字母大写。变量名不应以下划线或美元符号开头,尽管这在语法上是允许的。
- 例如:
char c; int i; float myWidth;
常量
- 类常量的声明,应该全部大写,单词间用下划线隔开。
- 例如:
1 | static final int MIN_WIDTH = 4; |
变量的位置
- 地址
- 变量的地址是与这个变量相关联的地址。
变量的生存期
- 局部变量(栈空间)的生存期
- 变量和方法栈的生存期一致
- 成员变量(堆空间)的生存期
- 引用变量的声明与对象创建
垃圾回收
- 垃圾回收机制
作用域
- 程序变量的作用域是语句的一个范围,在这个范围之内,变量为可见的。
- 如果一个变量在一条语句中可以被引用,这个变量即在这条语句中为可见的。
- 如果一个变量声明于一个子程序单元或程序块之内,那么它就是这个子程序单元或程序块内的局部变量。程序单元或程序块的非局部变量是指不在这个程序单元或程序块中声明、但又对其可见的变量。
示例代码
1 | int num = 0, i, j; |
变量的行为
Operators
- Assignment
- Arithmetic Operators
- Unary Operators
- Equality and Relational Operators
- Conditional Operators
- Bitwise and Bit Shift Operators
常用变量用法
变量类型
- 局部变量
- 方法内部
- Stack
- 属性字段(成员变量)
- 对象内部
- Heap
- 静态变量
- 全局(类)
- Code区
访问
- 直接访问
1 | x = 10; |
- 间接访问
1 | position.x = 10; |
角色
- 收集器 result
- 计数 count
- 元素 each
- 解释 top left
1 | int top = ...; |
- 复用
1 | for (Clock each : getClocks()) { |
Flag案例
- 我们可以设置一个标签去判断是否已经有headview 和 footview了
1 | if (页数 > 1) { |
Component案例
1 | class Segment { |
08 结构化编程 - 方法
知识点
- 方法的概念
- ⽅法的属性
- ⽅法的声明
- 类图
- ⽅法的调⽤
- 重载
- 常用的方法
- Linux逻辑内存地址空间
- JVM运行时数据区
- C语言方法调用的栈帧
- JVM方法的执行(栈架构)
- 如何开发一个类
方法的概念
概念
- 物理的角度:指令块
- 逻辑的角度:抽象指令单元
- 语义的角度:行为
- 分类:
- 类的行为:静态方法
- 对象的行为:成员方法
方法对象的属性
-
名字
-
所有者
-
地址
-
接口
-
实现
-
运行期
-
可见性
方法对象的行为
执行被调用
方法的实现
-
成员方法的声明
-
成员方法的调用机制
-
方法的结构
- 顺序(表达式,语句,块)
- 选择
- 循环
方法的常见用法
重载
在⼀个类⾥⾯,不同的⽅法,恰好名字相同
内存和虚拟机
- Linux内存
- 数据段:全局和静态变量
- BSS段:未初始化的数据
- 堆
- 栈
- 代码段
- Java虚拟机内存
- pc寄存器
- Java虚拟机栈
- 堆
- 方法区
- 运行时常量池
- 本地方法栈
JVM方法调用时栈帧变化示例代码
1 | public class TestDemo { |
栈帧变化过程
-
main方法调用
- 首先检查
main
的访问标志,描述符描述的返回类型和参数列表,确定可以访问后进入Code
属性表执行命令。 - 读取栈深度建立符合要求的操作数栈,读取局部变量大小建立符合要求的局部变量表。
- 根据参数数目向局部变量表中依序加入参数(第一个参数是引用当前对象的
this
,所以空参数列表的参数数也是1),然后开始根据命令正式执行。
- 首先检查
-
指令执行过程
-
0: iconst_5
- 将栈顶整数值存入局部变量表的`slot1`(`slot0`是参数`String[] args`指向的对象的地址)。1
2
3
4
5
- 将整数5压入栈顶。
- ```
1: istore_1 -
2: iload_1
- 调用静态方法`minus`,参数根据常量池中已转换为直接引用的常量,找到方法区中的地址,向其中加入的参数为栈顶的值。1
2
3
4
5
- 将`slot1`压入栈顶。
- ```
3: invokestatic #2 // Method minus:(I)I -
6: istore_2
- 将返回地址中存储的PC地址返回到PC,栈帧恢复到调用前。1
2
3
4
5
- 将栈顶整数存入局部变量的`slot2`。
- ```
7: return
-
-
minus方法调用
- 检查
minus
函数的访问标志,描述符描述的返回类型和参数列表,确定可以访问后进入Code
属性表执行命令。 - 读取栈深度建立符合要求的操作数栈,读取局部变量大小建立符合要求的局部变量表。
- 根据参数数目向局部变量表中依序加入参数,然后开始根据命令正式执行。
- 检查
-
指令执行过程
- 0: iload_0
- 将
slot0
压入栈顶,也就是传入的参数。
- 将
- 1: ine
- 将栈顶的值弹出取负后压回栈顶。
- 2: ireturn
- 将返回地址中存储的PC地址返回到PC,栈帧恢复到调用前。
- 0: iload_0
09 面向对象编程-思想
结构化编程的问题
- 可读性差
- 维护困难
具体表现
- 全局变量的使用导致可读性差
- 实现变更和需求增加导致维护困难
面向对象思想
基本概念
- 对象:表示现实世界中的具体事物,具有属性和方法
- 类:描述对象的定义,通过类的方法来定义对象的行为
类和对象的关系
- 类是对象的定义
- 对象是类的实例
- 类包含对象的名称、方法、属性和事件
类的职责
- 每个类应当只有单一职责
- 通过属性和方法来体现职责
- 通过类的方法来操作对象的状态
面向对象分析
寻找对象
- 找名词:类(对象)与属性
- 找动词:行为
- 根据名词和动词来确定对象和职责
创建类的原因
- 对现实世界中的对象建模
- 降低复杂度,隔离复杂度
- 隐藏实现细节,限制变化的影响范围
类的定义和实例化
- 类是对某个对象的定义
- 通过类创建对象实例,实例有自己的属性值和方法
面向对象编程的优点
- 通过封装、继承、多态等特性,提高代码的可维护性和可读性
- 使代码更具扩展性和复用性
案例分析:课程表应用
输入输出操作
- 输入命令:通过控制台或文件输入课程信息的命令
- 处理命令:解析命令并生成输出
- 输出结果:通过控制台或文件输出课程信息
应用面向对象思想
- 将数据和操作封装在一起
- 通过类和对象来实现课程表的各项功能
例子
1 | public class Course { |
10 面向对象编程-封装
类的职责与封装
- 数据职责
- 表征对象的本质特征
- 行为(计算)所需要的数据
- 例子:教务系统中学生对象计算年龄,税务系统中纳税人计算所得税
- 行为职责
- 表征对象的本质行为
- 拥有数据所应该体现的行为
- 例子:出生年月,个人收入
数据职责与行为职责“在一起”
类
类三部曲
- 对象声明、创建和赋值的三步曲:
Duck myDuck = new Duck();
静态方法
-
数学方法从不使用实例变量:
- 例子:
int x = Math.round(42.2f);
,int y = Math.min(56,12);
,int z = Math.abs(-343);
- 例子:
-
静态方法不能使用非静态(实例)变量。
静态变量
-
静态变量:所有实例的值相同。
-
静态变量在类加载时进行初始化:
- 类加载是由JVM决定的,当JVM认为是时候加载某个类时,它就会被加载。
- 静态初始化有两个保证:
- 类中的静态变量在任何该类的对象被创建之前进行初始化。
- 类中的静态变量在类的任何静态方法运行之前进行初始化。
-
静态只读是常量
1
public static final double PI = 3.141592653589793
静态方法的使用原因
- 锁定方法以防止继承类修改方法的行为,确保方法行为在继承中保持不变并且不会被重载。
- 提高效率,允许编译器将方法调用转为内嵌调用。
final
- 修饰变量:
final
修饰的变量表示一旦初始化,其值就不能再改变。- 通常用于声明常量。
- 例如:
public static final double PI = 3.141592653589793;
- 修饰方法:
final
修饰的方法不能被子类重写(override)。- 用于确保方法的行为在继承中保持不变。
- 例如:
final void printMessage() { ... }
- 修饰类:
final
修饰的类不能有子类(即不能被继承)。- 用于防止类被继承和修改。
- 例如:
public final class MyFinalClass { ... }
构造方法
- 构造方法的关键特性是在对象分配给引用之前运行。
- 重载构造方法:
- 如果没有构造方法,编译器会创建一个无参数的构造方法。
- 如果有参数的构造方法存在,必须自己写无参数的构造方法。
对象
对象初始化
变量在调用任何方法之前就被初始化,即使是构造函数也是如此;
- 首先初始化静态数据,然后是非静态数据;
- 静态数据被初始化和执行初始化块按照文本顺序。
示例
1 | public class StaticOrder { |
1 | public class StaticTest { |
1 | public class StaticTest { |
垃圾回收机制
分代回收
Java 垃圾回收机制的基础是分代回收。内存区域被划分为不同的世代,对象根据其存活时间被保存在对应世代的区域中。
内存世代划分
内存通常被划分为三个世代:
- 年轻世代:用于存放新创建的对象。大部分新对象都会在年轻世代中分配。
- 年老世代:存放生命周期较长的对象。
- 永久世代:存放类元数据等。
年轻世代的细分
年轻世代的内存区域进一步划分为:
- 伊甸园(Eden):主要用于新对象的内存分配。
- 两个存活区(Survivor space):用于存放从伊甸园复制过来的存活对象。两个存活区中始终有一个是空的。
垃圾回收过程
- 标记:找出当前存活的对象并进行标记。
- 清除:遍历内存区域,找出需要回收的区域。
- 压缩:把存活对象的内存移动到内存区域的一端,使得另一端成为连续的空闲区域,方便内存分配和复制。
年轻世代的垃圾回收
年轻世代的垃圾回收算法针对生命周期较短的对象,效率较高。回收过程中,伊甸园和一个非空存活区中存活的对象会被复制到另一个空白存活区或年老世代中。
年老世代和永久世代的垃圾回收
年老世代和永久世代采用标记-清除-压缩(Mark-Sweep-Compact)算法:
- 标记:标记所有存活的对象。
- 清除:删除未标记的对象。
- 压缩:将存活对象移动到内存区域的一端,使得内存空间变得连续。
垃圾回收器的职责
Java的垃圾回收器负责完成以下任务:
- 分配内存。
- 确保被引用的对象的内存不被错误回收。
- 回收不再被引用的对象的内存空间。
垃圾回收的影响
垃圾回收是一个复杂且耗时的操作。当垃圾回收器进行回收操作时,应用的执行会暂时中止(stop-the-world),需要更新应用中所有对象引用的实际内存地址。
示例代码
1 | void go() { |
以上代码展示了垃圾回收在对象引用变化时的行为。当对象指向自己的最后引用消失时,对象可以被垃圾回收器回收。
11 面向对象编程-协作
类的职责
基本问题求解的原则 :分解与抽象
⾯向对象⽅法的原则 :职责与协作
⾯向对象⽅法的三要素 :封装、继承、多态
- ⼀个对象
- 维护其⾃身的状态需要对外公开⼀些⽅法
- ⾏使其职能也要对外公开⼀些⽅法
类之间的动态协作
协作:一组对象共同协作履行整个应用软件的责任
设计的焦点是从发现对象及其责任
转移到对象之间如何通过互相协作来履⾏责任
职责分配
分与聚
从⼩到⼤,将对象的⼩职责聚合形成⼤职责;
从⼤到⼩,将⼤职责分配给各个⼩对象。
类之间的静态关系
General Relationship
-
类之间的关系:依赖
Instance Level Relationship
- 连接(External Links)
- Relationship among objects
- A link is an instance of an association
- 关联(Association)
- 逻辑关系(run-time relationship between instances of classifiers)
- 关联的分类
- 普通关联
- 可导航关联
- 聚合(Aggregation)
- 组合(Composition)
强弱关系:依赖 < 普通关联 < 聚合 < 组合
Class Level Relationship
- Generalization
- 继承(extends)
- Realization
- 实现(implements)
依赖
-
关系:“… uses a …”
-
所谓依赖就是某个对象的功能依赖于另外的某个对象,而被依赖的对象只是作为一种工具在使用,而并不持有对它的引用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27class Printer {
public void print(Document doc) {
// 打印文档内容
System.out.println(doc.getContent());
}
}
class Document {
private String content;
public Document(String content) {
this.content = content;
}
public String getContent() {
return content;
}
}
// 使用示例
public class Main {
public static void main(String[] args) {
Document doc = new Document("Hello, World!");
Printer printer = new Printer();
printer.print(doc); // Printer依赖于Document
}
}
关联
-
关系:“… has a …”
-
所谓关联就是某个对象会长期的持有另一个对象的引用,而二者的关联往往也是相互的。关联的两个对象彼此间没有任何强制性的约束,只要二者同意,可以随时解除关系或是进行关联,它们在生 命期问题上没有任何约定。被关联的对象还可以再被别的对象关联,所以关联是可以共享的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33class Teacher {
private String name;
public Teacher(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
class Student {
private Teacher teacher;
public Student(Teacher teacher) {
this.teacher = teacher;
}
public Teacher getTeacher() {
return teacher;
}
}
// 使用示例
public class Main {
public static void main(String[] args) {
Teacher teacher = new Teacher("Mr. Smith");
Student student = new Student(teacher);
System.out.println("Student's teacher: " + student.getTeacher().getName()); // 关联关系
}
}
聚合
-
关系:" … owns a …"
-
聚合是强版本的关联。它暗含着一种所属关系以及生命周期关系。被聚合的对象还可以再被别的对象关联,所以被聚合对象是可以共享的。虽然是共享的,聚合代表的是一种更亲密的关系。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43class Department {
private String name;
public Department(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
class Company {
private List<Department> departments;
public Company() {
this.departments = new ArrayList<>();
}
public void addDepartment(Department department) {
departments.add(department);
}
public List<Department> getDepartments() {
return departments;
}
}
// 使用示例
public class Main {
public static void main(String[] args) {
Department hr = new Department("Human Resources");
Department it = new Department("IT");
Company company = new Company();
company.addDepartment(hr);
company.addDepartment(it);
for (Department dept : company.getDepartments()) {
System.out.println("Department: " + dept.getName()); // 聚合关系
}
}
}
组合
-
关系:" … is a part of …"
-
组合是关系当中的最强版本,它直接要求包含对象对被包含对象的拥有以及包含对象与被包含对象生命周期的关系。被包含的对象还可以再被别的对象关联,所以被包含对象是可以共享的,然而绝 不存在两个包含对象对同一个被包含对象的共享。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31class Engine {
private String type;
public Engine(String type) {
this.type = type;
}
public String getType() {
return type;
}
}
class Car {
private Engine engine;
public Car(String engineType) {
this.engine = new Engine(engineType); // Car组合了Engine
}
public Engine getEngine() {
return engine;
}
}
// 使用示例
public class Main {
public static void main(String[] args) {
Car car = new Car("V8");
System.out.println("Car's engine type: " + car.getEngine().getType()); // 组合关系
}
}
12 软件工程建模
数学建模
软件工程师的思考问题
- What to develop?
- Why to develop?
- How to develop?
思维的演化
- 数学 -> 计算机 -> 软件工程
问题求解
- 问题空间 -> 解决方案 -> 解空间
- 例子:求1到100的和等于多少?结果是5050
数学建模步骤
- 明确自己的武器(数学框架、整数、加法、乘法)
- 审题(提炼数学问题)
- 建立数学模型(对50对构造成和为101的数列求和)
- 制订解决方案
- 检查
- 实施
计算机建模
将数学建模结合计算机实现进行建模
软件工程建模
软件开发生命周期模型
- 需求分析(审题)
- 设计(建立计算机模型)
- 构造(制订解决方案)
- 软件测试(检验)
- 移交和演化(实施)
软件工程的思维
- 问题空间 -> 软件工程的模型 -> 解空间
- 目标是满足真实的需求,利用过程、方法和工具管理时间、金钱、人力
软件工程框架
- 技术
- 业务模型
- 分析模型
- 设计模型
- 过程
用例分析
- 用例是一个需求单元、分析单元、设计单元、开发单元、测试单元,甚至部署单元
- 参与者发起的操作必须是可观测和有意义的
软件测试
- Verification(验证):检查解决方案的有效性
- Validation(确认):检查是否解决了问题
移交和演化
- 在真实环境中运行
- 演化新的版本
软件开发活动
- 需求(What):SRS
- 设计(How):SDD
- 构造(Build):代码和可执行文件
- 测试(Test):测试报告
- 部署(Install):用户文档和系统文档,新版本软件
软件开发生命周期模型
软件工程建模分析案例
图书管理系统需求分析
- 系统管理员
- 身份:所有系统管理员采用相同的身份和权限
- 操作:
- 管理系统管理员:查询、添加、修改和删除系统管理员信息
- 管理借阅人:查询、添加、修改和删除借阅人信息
- 管理图书:查询、添加、修改和删除图书信息
- 借阅人
- 身份:分为本科生、研究生和教师三种身份,各种身份具有不同的权限
- 操作:
- 借阅图书:本科生最多可同时借阅5本,研究生最多10本,教师最多20本
- 请求图书:教师可以请求已被借空的图书,系统将自动通知借阅时间最长的本科生或研究生在7天内归还图书
- 查看已借图书:查看当前借阅的图书情况
- 续借图书:本科生和研究生可以续借1次,教师可以续借2次,超期的图书和被教师请求的图书不得续借
- 归还图书:归还本人借阅的图书
- 查询图书:根据图书基本信息对图书进行查询
- 查看消息:查看图书到期提醒、提前还书通知、请求图书到馆通知等
不可变类示例 - String性能分析详细内容
不可变类的优点
- 共享性:不可变对象的一个很大优点是可以被共享。由于其状态不可变,因此可以安全地在多个线程中共享。
- 安全性:不可变对象在多线程环境中不需要同步,因为它们的状态不会改变,从而避免了同步带来的开销和复杂性。
- 简化代码:使用不可变对象可以简化代码,因为不需要担心对象的状态变化。
Java对象在JVM中的存储
一般而言,Java对象在虚拟机的结构如下:
-
对象头(Object Header):8个字节
-
Java原始类型数据:如
1
2
3int
float
char等类型的数据,各类型数据占内存如下表:
int
: 4个字节float
: 4个字节char
: 2个字节
-
引用(Reference):4个字节
-
填充符(Padding)
String在JVM中的存储
然而,一个Java对象实际上还会占用些额外的空间,如:对象的class信息、ID、在虚拟机中的状态。在Oracle JDK的Hotspot虚拟机中,一个普通的对象需要额外8个字节。
如果对于String(JDK 6)的成员变量声明如下:
private final char value[];
private final int offset;
private final int count;
private int hash;
那么应该如何计算该String所占的空间?
- 首先计算一个空的char数组所占空间,在Java里数组也是对象,因此数组所占空间为对象头的空间加上数组长度,即8+4=12字节,经过填充后为16字节。
- 那么一个空String所占空间为:
- 对象头(8字节)+ char数组(16字节)+ 3个int(3x4=12字节)+ 1个char数组的引用(4字节)= 40字节。
因此一个实际的String所占空间的计算公式如下:
其中,n为字符串长度。char数组中一个char占用两个字节,默认使用Unicode作为编码。
String类的特殊处理
- 字符串池:Java为了提高效率,对String类型进行了特别处理,为String类型提供了字符串池。字符串池是一种特殊的内存区域,用于存储字符串字面值。
- 字符串字面值共享:当使用字面值定义字符串时,编译器会检查字符串池是否已经存在相同的字符串。如果存在,则直接返回池中的字符串引用;如果不存在,则将该字符串添加到池中。
定义String类型变量的两种方式
- 使用字面值:
1 | String name = "tom"; |
- 这种方式会使用字符串池。如果池中已经存在相同的字符串,则直接返回引用;否则,将该字符串添加到池中。
- 使用
new
关键字:
1 | String name = new String("tom"); |
- 这种方式不会使用字符串池,而是每次都会创建一个新的字符串对象。
性能分析
- 内存使用:
- 使用字面值定义字符串时,内存使用效率更高,因为相同的字符串只会在内存中存储一次。
- 使用
new
关键字定义字符串时,每次都会创建新的对象,因此会占用更多的内存。
- 速度:
- 使用字面值定义字符串时,速度更快,因为可以直接从字符串池中获取引用。
- 使用
new
关键字定义字符串时,速度较慢,因为每次都需要创建新的对象。
- 示例代码:
1 | public class StringTest { |
总结
- 使用字面值定义字符串时,能够提高内存使用效率和执行速度。
- 使用
new
关键字定义字符串时,每次都会创建新的对象,增加了内存开销和执行时间。 - 在实际开发中,建议优先使用字面值定义字符串,以充分利用字符串池的优势。
13 JVM与字节码
语言无关性
class文件结构
-
class文件结构
class文件是Java虚拟机执行的基本单位。它包含了类或接口的定义,包括字段、方法和字节码指令等信息。class文件的主要结构包括:
- 魔数:用于标识class文件的格式。
- 版本号:class文件的版本信息。
- 常量池:存储字符串、类和接口名、字段名和其他常量。
- 访问标志:描述类或接口的访问权限和属性。
- 类索引、父类索引和接口索引:标识类的继承关系和实现的接口。
- 字段表:类或接口中声明的字段。
- 方法表:类或接口中声明的方法。
- 属性表:附加的属性信息,如源文件名、行号表等。
运行时数据区
-
运行时数据区
JVM在执行Java程序时,会将数据和代码加载到内存中,主要包括以下几个部分:
- 方法区:存储已被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
- 堆:用于存储对象实例,是垃圾回收的主要区域。
- Java栈:每个线程都会有一个私有的Java栈,存储局部变量、操作数栈、动态链接和方法出口等信息。
- 程序计数器:记录当前线程执行的字节码指令的地址。
- 本地方法栈:为本地方法服务,存储本地方法调用的信息。
虚拟机栈与栈帧
虚拟机栈
-
虚拟机栈
:每个Java线程都有一个私有的虚拟机栈(也称为Java栈),它是线程生命周期的一部分。虚拟机栈用于存储局部变量、操作数栈、动态链接、方法出口等信息。
- 局部变量表:存储方法参数和局部变量。
- 操作数栈:用于执行字节码指令时的操作数临时存储。
- 动态链接:保存方法调用过程中的符号引用。
- 方法出口:记录方法返回地址等信息。
栈帧
-
栈帧
:每次方法调用时,都会在虚拟机栈中创建一个新的栈帧。栈帧是虚拟机执行方法调用和方法返回的基础数据结构。栈帧包含以下几部分:
- 局部变量表:用于存储方法的参数和局部变量。
- 操作数栈:用于执行字节码指令时的操作数临时存储。
- 动态链接:指向运行时常量池的方法引用。
- 方法返回地址:方法调用结束后,返回到调用方法的地址。
栈帧的生命周期
- 栈帧的创建:当一个方法被调用时,会在虚拟机栈中创建一个新的栈帧,并将其压入栈顶。
- 栈帧的执行:方法执行过程中,操作数栈和局部变量表会被频繁操作。
- 栈帧的销毁:方法执行完毕后,栈帧会从虚拟机栈中弹出并销毁。
示例
1 | public class StackFrameDemo { |
- 在上述示例中,方法
main
和add
各自对应一个栈帧。调用add
方法时,会在虚拟机栈中创建一个新的栈帧,并在该栈帧中执行加法操作。方法执行完毕后,栈帧会被销毁。
字节码指令集
-
字节码指令集
JVM字节码是一种中间语言,JVM通过解释或即时编译执行字节码。字节码指令集包括以下几类:
-
存储指令(例如:
aload_0
,istore
) -
算术与逻辑指令(例如:
ladd
,fcmpl
) -
类型转换指令(例如:
i2b
,d2i
) -
对象创建与操作指令(例如:
new
,putfield
) -
堆栈操作指令(例如:
swap
,dup2
) -
控制转移指令(例如:
ifeq
,goto
) -
方法调用与返回指令(例如:
invokespecial
,areturn
)
-
-
前/后缀操作数类型
- i 整数
- l 长整数
- s 短整数
- b 字节
- c 字符
- f 单精度浮点数
- d 双精度浮点数
- z 布尔值
- a 引用
字节码的执行
-
类的生命周期:
- 加载
- 连接
- 验证
- 准备
- 解析
- 初始化
- 使用
- 卸载
-
字节码的执行
:JVM通过解释器或即时编译器执行字节码。解释器逐行解释字节码并执行,而即时编译器将字节码编译成机器码后执行。执行过程包括:
- 类加载:将class文件加载到内存中。
- 字节码验证:确保字节码的安全性和正确性。
- 字节码解析:将字节码转换为JVM内部的数据结构。
- 字节码执行:通过解释或编译执行字节码指令。:
Java指令与字节码
示例代码 1 - EvalOrderDemo
1 | public class EvalOrderDemo { |
字节码
1 | // 左子树:数组下标 |
示例代码 2 - Demo
1 | public class Demo { |
字节码
1 | 0: iconst_1 |
示例代码 3 - Bootstrap
1 | public class Bootstrap { |
字节码
1 | 0: ldc #2 // String Louis |
示例代码 4 - greeting() 方法
1 | public static void greeting(String name) { |
字节码
1 | 0: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream; |
14 面向对象编程-继承与多态
继承
-
继承的由来
- 解决重复代码的问题。
- 通过分类和抽象的概念,student、teacher、doctor 等类都可以继承具有共性属性和方法的父类。
-
继承的概念
- 父类(基类、超类):包含更共性的属性和方法。
- 子类:包含更特殊的属性和方法,继承父类的所有成员变量和方法。
- 子类可以增加新的成员变量和方法,或者覆盖父类的方法,但不能覆盖父类的成员变量。
-
继承树
- 继承的层次结构,通过继承树来表示类之间的继承关系。
- 当您在对象引用上调用方法时,您调用的是该对象类型的最具体的方法版本。
- 继承树上的“最低”
-
IS-A 和 HAS-A
- IS-A:子类是父类的一种,继承关系。
- HAS-A:类与类之间的包含关系。
-
继承的好处
- 避免了代码的重复
- 但不能仅仅为了避免重复⽤继承
- ⽗类定义了⼀个所有⼦类必须遵循的契约
-
问题:⼦类继承了⽗类所有的成员变量和⽅法,包括⽗类的私有变量么?
是的,子类继承了父类的所有成员变量和方法,包括父类的私有变量。但是,子类无法直接访问父类的私有变量。私有变量只能在父类内部使用,子类无法继承或访问它们。这是为了确保封装性和安全性。如果子类需要访问父类的私有变量,可以通过公共方法(例如getter方法)来间接获取。
抽象类和抽象方法
- 抽象类
- 不能实例化,仅用于被子类继承。
- 抽象类本身没有⽤,除⾮他被继承,有 了⼦类。抽象类的⼦类可以实例化。
- 抽象类的子类可以实例化。
- 抽象方法
- 没有实现主体的方法,需要子类实现。
- 非抽象类中不可以有抽象方法
- 抽象类中可以有非抽象方法
- 实现抽象方法
- 抽象方法存在的目的是为了多态。
- 具体的子类必须实现所有父类的抽象方法,类似于方法覆盖。
多态
-
多态的概念
- 一个对象可以表示为其父类的类型,这样一个对象可以有多种形态。
- 通过引用变量的类型和实际对象类型的关系,动态决定方法的调用。
-
多态的代价
要通过类型强转来转化为需要的类的引用对象
关键两点:
- 编译时,编译器决定你是否能调⽤某个⽅法
- 依据引⽤变量的类型,⽽不是引⽤变量指向的对象的类型
- 执⾏时,JVM虚拟机决定实际哪个⽅法被调⽤
- 依据实际引⽤变量指向的对象的类型
-
多态的思想
- 分离“做什么”和“怎么做”:
- 多态通过将接口和实现分离,使得我们可以专注于对象应该做什么,而不必关心具体的实现细节。
- 这有助于消除不同类型之间的耦合关系,使代码更灵活、可维护和可扩展。
- 多态方法调用:
- 允许不同类型的对象表现出与其他相似类型之间的区别。
- 即使这些对象都是从同一个基类导出的,它们可以根据方法的不同行为来表现出差异。
- 在运行时,系统会动态地选择正确的计算方式。
- 多态的实现:
- 多态是指多个方法使用同一个名字,但有多种解释。
- 当我们使用这个名字调用方法时,系统会自动选择其中的一个方法(重载)。
- 多态关注的是对象应该做什么,而不关心具体的实现方式。
- 分离“做什么”和“怎么做”:
-
多态的实现
-
通过引用变量、参数和返回值实现多态。
-
引用变量
1
2
3
4
5
6
7
8Animal[] animals = new Animal[5];
animals[0] = new Dog();
animals[1] = new Cat();
// ... 其他类型的动物对象
for (int i = 0; i < animals.length; i++) {
animals[i].eat();
animals[i].roam();
} -
参数和返回值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15class Vet {
public void giveShot(Animal a) {
a.makeNoise();
}
}
class PetOwner {
public void start() {
Vet v = new Vet();
Dog d = new Dog();
Hippo h = new Hippo();
v.giveShot(d);
v.giveShot(h);
}
}
-
-
使用父类类型的数组可以存储不同子类的对象。
-
-
方法覆盖(Overriding)与方法重载(Overloading)
-
方法覆盖:子类覆盖父类的方法,同⼀个⽅法、⽅法名字相同
-
参数列表与被重写方法的参数列表必须完全相同。
-
返回类型可以不相同,但必须是父类返回值的派生类(Java 5 及更早版本返回类型要一样,Java 7 及更高版本可以不同)。
-
访问权限不能比父类中被重写的方法的访问权限更低。
-
陷阱:父类的私有方法不能被重写,如果重新写相当于写新的方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21class PrivateOverride {
private static Test monitor = new Test();
private void f() {
System.out.println("private f()");
}
public static void main(String[] args) {
PrivateOverride po = new Derived();
po.f();
monitor.expect(new String[] {});
}
}
class Derived extends PrivateOverride {
public void f() {
System.out.println("public f()");
}
}
// print private f()-
私有方法不能被重写的原因是因为 Java 中的私有方法具有以下特性:
- 自动为 final:
- 父类中的私有方法被自动视为
final
,即不可被子类重写。 final
方法表示它的实现是最终的,不允许子类修改。
- 父类中的私有方法被自动视为
- 隐藏性:
- 私有方法对于子类是隐藏的,无法被直接访问。
- 子类无法继承或重写父类的私有方法。
因此,私有方法在编译时就已经绑定到了父类,无法在运行时动态地重写。这是为了确保封装性和安全性,防止子类意外地修改父类的私有方法行为。
- 自动为 final:
-
-
-
方法重载:同一类中方法名相同,但参数不同。
-
返回值可以不同,但是不能只是返回值不同
-
参数不同(类型、个数、顺序)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17class Calculator {
public int add(int a, int b) {
return a + b;
}
public double add(double a, double b) {
return a + b;
}
}
public class TestCalculator {
public static void main(String[] args) {
Calculator calc = new Calculator();
System.out.println(calc.add(2, 3)); // 调用 int 参数版本
System.out.println(calc.add(2.5, 3.5)); // 调用 double 参数版本
}
}
-
-
方法调用的字节码
-
Java 虚拟机中的方法调用指令
invokestatic
:调用静态方法。invokespecial
:调用实例构造器、私有方法和父类方法。invokevirtual
:调用虚方法。invokeinterface
:调用接口方法。invokedynamic
:先在运⾏时动态解析出调⽤点限定符所引⽤的⽅法,然后再执⾏该⽅法。把如何查找⽬标⽅法的决定权从虚拟机转嫁到具体⽤户代码之中,让⽤户 (包含其他语⾔的设计者)有更⾼的⾃由度。
-
在 Java 字节码中,构造方法的第一个参数确实是类实例本身,即隐式传递的
this
引用。这个参数在字节码中的表示方式是局部变量编号为 0 的位置。-
示例:
-
假设我们有以下 Java 类:
1
2
3
4
5
6
7
8
9
10
11public class MyClass {
private int value;
public MyClass(int value) {
this.value = value;
}
public void printValue() {
System.out.println("Value: " + value);
}
}编译后的字节码中,构造方法的参数列表如下:
1
2
3
4
5
6
7
8
9
10
11
12public MyClass(int);
descriptor: (I)V
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: iload_1
2: putfield #1 // Field value:I
5: return
LineNumberTable:
line 3: 0
line 4: 5
-
-
注意:
aload_0
指令将this
引用加载到操作数栈上。putfield
指令将局部变量 1(即参数value
)的值存储到this.value
字段中。
-
-
invokevirtual
是一条 Java 字节码指令,用于调用所有虚方法。让我来详细解释一下:- 虚方法:在 Java 中,虚方法是指在运行时根据对象的实际类型来确定调用的方法。
invokevirtual
指令invokevirtual
用于调用对象的实例方法(即非静态方法)。- 它会在运行时动态地查找对象的实际类型,并调用该类型中的方法。
- 这个指令适用于除了接口方法(使用
invokeinterface
)、静态方法(使用invokestatic
)以及少数特殊情况(由invokespecial
处理)之外的所有方法。
总之,
invokevirtual
指令用于调用对象的虚方法,它会根据实际类型来动态选择要执行的方法。 -
方法调用的确定
-
编译期
- 静态绑定
- 多分派
- overloading
-
运⾏期
- 动态绑定
- 单分派
- overridding
-
都是invokevirtual指令
-
编译器静态绑定示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80package org.fenixsoft.polymorphic;
public class Overload {
public static void sayHello(Object arg) {
System.out.println("hello Object");
}
public static void sayHello(int arg) {
System.out.println("hello int");
}
public static void sayHello(long arg) {
System.out.println("hello long");
}
public static void sayHello(Character arg) {
System.out.println("hello Character");
}
public static void sayHello(char arg) {
System.out.println("hello char");
}
public static void sayHello(char... arg) {
System.out.println("hello char ...");
}
public static void sayHello(Serializable arg) {
System.out.println("hello Serializable");
}
public static void main(String[] args) {
sayHello('a');
}
}
/*
上⾯的代码运⾏后会输出:
hello char
1
1
这很好理解,‘a’是⼀个char类型的数据,⾃然会寻找参数类型为char的重载⽅法,如果注释掉sayHello(char arg) ⽅法,那输出会变为:
hello int
1
1
这时发⽣了⼀次⾃动类型转换,’a’除了可以代表⼀个字符串,还可以代表数字97 (字符,a,的Unicode数值为⼗进制数字97 ) , 因此参数类型为int的重载也是
合适的。我们继续注释掉sayHello(int arg)⽅法,那输出会变为:
hello long
1
1
这时发⽣了两次⾃动类型转换,’a’转型为整数97之后 ,进⼀步转型为⻓整数97L ,匹配了参数类型为long的重载。笔者在代码中没有写其他的类型如float、
double等的重载,不过实际上⾃动转型还能继续发⽣多次,按照char->int-> long-> float-> double的顺序转型进⾏匹配。但不会匹配到byte和short类型的重
载,因为char到byte或short的转型是不安全的。我们继续注释掉sayHello(long arg)⽅法,那输会变为:
hello Character
1
1
这时发⽣了⼀次⾃动装箱,’a’被包装为它的封装类型java.lang.Character ,所以匹配到了参数类型为Character的重载,
继续注释掉sayHello(Character arg) ⽅法,那输出会变为:
hello Serializable
1
1
这个输出可能会让⼈感觉摸不着头脑,⼀个字符或数字与序列化有什么关系?出现hello Serializable,是因为java.lang.Serializable是
java.lang.Character类实现的⼀个接⼝,当⾃动装箱之后发现还是找不到装箱类,但是找到了装箱类实现了的接⼝类型,所以紧接着⼜发⽣⼀
次⾃动转型。char可以转型成int,但是Character是绝对不会转型为Integer的 ,它只能安全地转型为它实现的接⼝或⽗类。Character还实
现了另外⼀个接⼝java.lang.Comparable<Character> , 如果同时出现两个参数分别为Serializable和Comparable<Character>的重载⽅
法,那它们在此时的优先级是⼀样的。编译器⽆法确定要⾃动转型为哪种类型,会提示类型模糊,拒绝编译。程序必须在调⽤时显式地指定
字⾯量的静态类型,如 : sayHello((Comparable<Character>)’a’) , 才能编译通过。下⾯继续注释掉sayHello(Serializable arg)⽅法 ,输出会
变为:
hello Object
1
1
这时是char装箱后转型为⽗类了,如果有多个⽗类,那将在继承关系中从下往上开始搜索 ,越接近上层的优先级越低。即使⽅法调⽤传⼊的
参数值为null时 ,这个规则仍然适⽤。 我们把sayHello(Object arg) 也注释掉,输出将会变为:
hello char ...
1
1
7个重载⽅法已经被注释得只剩⼀个了,可⻅变⻓参数的重载优先级是最低的,这时候字符’a’被当做了⼀个数组元素。笔者使⽤的是char类
型的变⻓参数,读者在验证时还可以选择int类型、Character类型、Object类型等的变⻓参数重载来把上⾯的过程重新演示⼀遍。但要注
意的是,有⼀些在单个参数中能成⽴的⾃动转型,如char转型为int ,在变⻓参数中是不成⽴的。
*/-
运行时动态绑定示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34package org.fenixsoft.polymorphic;
/**
* 方法动态分派演示
* @author zzm
*/
public class DynamicDispatch {
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
protected void sayHello() {
System.out.println("man say hello");
}
}
static class Woman extends Human {
protected void sayHello() {
System.out.println("woman say hello");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}当
invokevirtual
指令调用的方法不是签名多态(signature polymorphic)时,它会按照以下步骤进行查找:假设
C
是objectref
的类。要调用的实际方法由以下查找过程选择:- 如果
C
包含一个实例方法m
,该方法覆盖了已解析的方法(§5.4.5),则m
将被调用,查找过程终止。 - 否则,如果
C
有一个超类,将使用相同的查找过程递归地使用C
的直接超类;要调用的方法是此查找过程的递归调用的结果。 - 否则,抛出
AbstractMethodError
。
- 如果
-
继承中的成员变量
-
子类继承父类的成员变量
-
子类继承了父类的所有成员变量和方法,但不能覆盖父类的成员变量
-
成员变量能不能访问是跟随引⽤变量的类型。
-
实际调⽤也是引⽤变量的类型。
-
因为两个flag根本就是不同的变量
-
变量没有覆盖的说法 。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26class A2 {
public String flag = "Father";
public void showFlag() {
System.out.println(flag);
}
}
class B2 extends A2 {
public String flag = "Son";
public void showFlag() {
System.out.println(flag);
}
public static void main(String[] args) {
A2 a = new B2();
System.out.println(a.flag); // 输出:Father
A2 a2 = new A2();
System.out.println(a2.flag); // 输出:Father
B2 b = new B2();
System.out.println(b.flag); // 输出:Son
}
} -
15 面向对象编程-可修改性
可修改性
面向对象的三个特性
- 封装
- 继承
- 多态
结构化编程
- 自顶向下
- 数据流图、结构图、流程图、代码
可修改性分类
- 狭义可修改性:对已有实现的修改,不影响Client代码
- 可扩展性:对新实现的扩展,不影响Client代码
- 灵活性:实现的动态配置,不影响Client代码
实现修改示例
1 | class Dog extends Animal { |
新动物加入示例
1 | animal[4] = new Panda(); |
灵活性示例
1 | class Zoo { |
继承 vs 组合
继承和组合的选择
- 组合和继承都允许在新类中设置子对象,组合是显式的,而继承则是隐式的。
修改代码
- 增加新子类:继承使代码更容易更改
- 修改父类:对父类的一个小改动会影响到其他地方
组合和继承的比较
- 更改后端类(组合)的接口比更改超类(继承)的接口更容易。
- 更改前端类(组合)的接口比更改子类(继承)的接口更容易。
选择继承或组合的建议
- 确保继承为is-a关系建模
- 不要仅为了代码复用或多态性使用继承
- 组合技术用于在新类中使用现有类的功能而非接口
- 有时,允许类的用户直接访问新类中的组合成份是极具意义的;也就是说, 将成员对象声明为public。如果成员对象自身都实现了具体实现的隐藏,那么这种做法就是安全的。当用户能够了解到你在组装一组部件时,会使得端口更加易于理解。
示例
1 | class Engine { |
继承和构造方法
调用构造方法的注意事项
- 同一个类中调用另一个重载的构造函数。主要要点包括:
- this()的使用:可以在构造函数中使用
this()
来调用同一类中的另一个构造函数。 - 位置要求:
this()
调用必须是构造函数中的第一个语句。 - this()和super()的选择:在构造函数中可以调用
this()
或super()
,但不能同时调用两者。
- this()的使用:可以在构造函数中使用
- 调用父类的构造方法
super()
必须在构造函数的第一句(java22改了)
类的初始化
- 加载(Loading)
- 链接(Linking)
- 初始化(Initialization)
对象的初始化
- 在堆上分配存储空间并清零
- 初始化非静态成员变量
- 执行构造方法
- 如果有父类,则先递归的初始化父类成员,最后才是本类
初始化顺序示例
1 | class Characteristic { |
结果:
1 | "Creating Characteristic is alive", |
构造方法中的多态
- 如果在构造函数中调用正在构造的对象的动态绑定方法会发生什么?
- 被覆盖的方法将在对象完全构造之前被调用
示例
1 | class Base { |
类的初始化
触发类初始化的操作
- 创建一个Java类的实例对象
- 调用一个Java类的静态方法
- 为类或接口中的静态域赋值
- 访问类或接口中声明的静态域,并且该域的值不是常值变量
- 在一个顶层Java类中执行assert语句
- 调用Class类和反射API中进行反射操作
示例1
1 | class A { |
结果
1 | 类A初始化 |
示例2
1 | class StaticBlock { |
结果
1 | 3 |
示例3数组定义引用类
1 | class Const { |
结果
- 不输出任何信息,说明Const类没有被初始化。
- 但这段代码里触发了另一个名为“LLConst”的类的初始化,它是一个由 虚拟机自动生成的、直接继承于java.lang.Object 的子类,创建动作由 字节码指令newarray触发,很明显,这是一个对数组引用类型的初初 始化,而该数组中的元素仅仅包含一个对Const 类的引用,并没有对 其进行初始化。
接口的初始化
- 接口也有初始化过程,虽然不能使用
static{}
语句块,但编译器会为接口生成类构造器,用于初始化接口中定义的成员变量。 - 接口也有初始化过程,上面的代码中我们都是用静态语句块来输出初始化信息的 ,而在接口中不能使用“static{}”语句块,但编译器仍然会为接口生成类构造器, 用于初始化接口中定义的成员变量(实际上是static final 修饰的全局常量)。
- 二者在初始化时最主要的区别是:当一个类在初始化时,要求其父类全部已经初 始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化, 只有在真正使用到父接口的时候(如引用接口中定义的常量),才会初始化该父 接口。这点也与类初始化的情况很不同,回过头来看第2 个例子就知道,调用类 中的static final 常量时并不会触发该类的初始化,但是调用接口中的static final 常量时便会触发该接口的初始化。
16 面向对象编程-接口
接口概述
接口的定义
- 接口用于定义一组方法,这些方法可以被不同的类实现。
- 接口提供了一种实现多重继承的方式,可以解耦和模块化设计。
接口的使用场景
- 定义宠物行为的接口:
beFriendly()
和Play()
方法。 - 宠物商店程序中,不同宠物共享行为模型,但具体实现不同。
类 vs 接口
类和接口的区别
- 类锁定了具体的实现,丧失了可扩展性和灵活性。
- 接口增加了开发的可并行性,允许不同类实现相同的接口。
继承与实现
- 继承用于特殊实现和共性模板。
- 接口用于定义类的功能性。
invokevirtual vs invokeinterface
invokevirtual
可以通过方法表优化调用,而invokeinterface
需要搜索整个表。- 示例代码对比了两者的性能,
invokeinterface
速度较慢。
1 | public class InvokevirtualVsInvokeinterface { |
Java 8 的默认方法
默认方法的定义
- 接口可以包含默认方法,提供方法的默认实现。
- 解决了接口添加新方法时需要修改所有实现类的问题。
- 使用
.super
关键字可以指定调用某个接口的默认方法。
示例
1 | interface InterfaceA { |
多重继承中的默认方法
- 接口可以多重继承,默认方法可能会冲突。
- 解决冲突的规则:类的方法优先于接口默认方法,无论该方法是具体的还是抽象的。
1 | interface InterfaceA { |
接口和抽象方法不可相互替代
- 虽然Java 8 的接口的默认方法就像抽象类,能提供方法的实现,但是他们 俩仍然是不可相互代替的:
- 接口可以被类多实现(被其他接口多继承),抽象类只能被单继承。
- 接口中没有this 指针,没有构造函数,不能拥有实例字段(实例变量) 或实例方法,无法保存状态(state),抽象类中可以。
- 抽象类不能在java 8 的lambda 表达式中使用。
- 从设计理念上,接口反映的是“like-a” 关系,抽象类反映的是“is-a” 关系
5. 总结
- 接口提供了一种灵活的多重继承方式,解耦和模块化设计。
- Java 8 的默认方法解决了接口方法扩展的问题。
- 在方法调用优化上,
invokevirtual
比invokeinterface
更快。
17 异常
JavaSound API
- JavaSound 是从 Java 1.3 版本开始引入的一组类和接口。这些类和接口是标准 j2SE 类库的一部分。
- MIDI(Musical Instrument Digital Interface)是一种标准协议,用于使各种电子音频设备之间进行通信。
处理异常
异常处理的基本概念
- 处理异常比创建和抛出异常花费更多的时间。
- 当代码调用有风险的方法(声明异常的方法)时,该代码需要处理异常。
- 存在风险时,抛出异常。
- 异常是一个
exception
类型的对象。 - 编译器会忽视
RuntimeException
类型的异常。RuntimeException
类型的异常不需要被声明或者在try/catch
中处理。- NullPointerException(空指针引用异常):当调用null对象的实例方法、访问或修改null对象的字段、将null作为数组获得其长度或访问/修改其元素时抛出。
- ClassCastException(类型强制转换异常):试图将对象强制转换为不是实例的子类时抛出。需要使用
instanceof
进行检查。 - IllegalArgumentException(传递非法参数异常):当传递非法参数给方法时抛出。
- ArithmeticException(算术运算异常):例如除以零时抛出。
- ArrayStoreException(数据存储异常):向数组中存放与声明类型不兼容的对象时抛出。
- IndexOutOfBoundsException(下标越界异常):访问数组、集合或字符串的索引超出范围时抛出。
- NegativeArraySizeException(创建大小为负数的数组错误异常):创建大小为负数的数组时抛出。
- NumberFormatException(数字格式异常):例如将字符串转换为数字时格式不正确。
- SecurityException(安全异常):当违反安全规则时抛出。
- UnsupportedOperationException(不支持的操作异常):当调用不支持的操作时抛出,例如尝试修改不可变集合。
- 编译器只关心可检查异常,必须在代码中声明和处理。
- 方法体中用
throw
关键词抛出异常对象throw new Exception();
- 抛出可检查异常的方法必须声明异常
throws Exception
。 - 编译器会检查可检查异常是否被处理,即
try-catch
。 - 可以一直
throws
。
try/catch 块中的流程控制
-
finally
块 -
多个异常
-
异常是多态的
-
多个 catch 块必须按从小到大的顺序排列
-
异常类在继承树中的位置越高,能捕获的“篮子”就越大。
-
Exception
能够捕获所有的异常,包括运行时异常。
继承中重写方法时抛出异常的问题
- 子类重写父类方法要抛出与父类一致的异常或异常子类,或者不抛出异常。
- 子类重写父类方法所抛出的异常不能超过父类的范畴(仅指检查型异常)。
- 子类在重写父类的具有异常声明的方法的同时,又去实现了具有相同方法名称的接口且该接口中的方法也具有异常声明,则子类中的重写的方法,要么不抛出异常,要么抛出父类中方法声明异常与接口中方法声明的异常的交集。
代码示例
1 | class A { |
使用异常机制的建议
- 异常的声明是 API 的一部分
- 异常处理不能代替简单的测试
- 不要过分地细化异常
- 利用异常层次结构
- 不要只抛出
RuntimeException
异常,应该寻找更合适的子类或者创建自己的异常类
- 不要只抛出
- 不要压制异常
- 在检测错误时,“苛刻”要比放任更好
- 在出错的地方抛出一个
EmptyStackException
异常要比在后面抛出一个NullPointerException
异常更好
- 在出错的地方抛出一个
- 不要羞于传递异常
- 早抛出,晚捕获
创建自己的异常
- 精心设计异常的层次结构
- 异常类中包含足够的信息
- 异常与错误提示
Java 7 的异常处理新特性
-
一个 catch 子句捕获多个异常
- 每个异常类型之间使用“|”来分隔
- 在catch 子句中声明捕获的这些异常类,不能出现重复的类型,也不允许其中的某个异常是另外一个异 常的子类,否则会出现编译错误
-
更加精确的异常抛出
try-with-resources 语句
- 能够被 try 语句所管理的资源需要满足一个条件,即其 Java 类要实现
java.lang.AutoCloseable
接口,否则会出现编译错误。 - 当需要释放资源的时候,该接口的
close
方法会被自动调用。 - Java 类库中已有不少接口或类继承或实现了这个接口,使得它们可以用在 try 语句中。
- 与 I/O 相关的
java.io.Closeable
继承了AutoCloseable
,而与数据库相关的java.sql.Connection
、java.sql.ResultSet
和java.sql.Statement
也继承了该接口。
catch 中抛出异常
1 | public class ZeroTest { |
- 在 catch 中又抛出了异常,这时除了执行 finally 中的代码,往下的代码都不会执行了。