UML2.5 统一建模语言
UML
是一种开放的方法,用于说明、可视化、构建和编写一个正在开发的、面向对象的、软件密集系统的制品的开放方法。UML展现了一系列最佳工程实践,这些最佳实践在对大规模,复杂系统进行建模方面,特别是在软件架构层次已经被验证有效。
UML
是一种开放的方法,用于说明、可视化、构建和编写一个正在开发的、面向对象的、软件密集系统的制品的开放方法。UML展现了一系列最佳工程实践,这些最佳实践在对大规模,复杂系统进行建模方面,特别是在软件架构层次已经被验证有效。
SOLID
设计原则并非单纯的一个原则,它实际上包含5个设计原则:单一职责原则、开闭原则、里氏替换替换原则,接口隔离原则和依赖反转原则。
SRP
)SRP(Single Responsibility Principle)
这个原则的意思是一个类或者一个模块只负责完成一个功能。所以,这里有两种理解方式,一种理解是把模块看做比类更加抽象的概念,类也可以看做是模块。另一种理解是把类看做是比类更加粗粒度的代码块,模块中包含多个类。
单一职责原则定义非常简单,不难理解。一个类只负责完成一个职责或者功能,也就是说,不要设计大而全的类,要设计粒度小、功能单一的类。换个角度来讲就是,一个雷包含了两个或者两个以上业务不相干的功能,那我们就说它职责不够单一,应该将它拆分成多个功能更加单一、粒度更细的类。举例来讲,如果一个类包含了用户和订单的一些操作,而用户和订单又是独立的业务领域模型,我们将它们放到一起就违反了单一职责原则,我们就需要进行拆分。
不同的应用场景、不同阶段的需求背景、不同的业务层面,对于同一个类的职责是否单一,可能会有不用的判定结果。实际上,一些侧面的判断指标更具有指导意义和可执行性,比如,代码行数过度,函数或者属性过多都可能是违反单一职责原则的表象。
例如,下面的 UserInfo
类,这个类里面除了用户的基本信息,还有地址信息。或许一个观点是都属于用户的基本信息应该放在一起,另一个观点是可以拆分出 UserAddress
类,UserInfo
只保留除 Address
之外的其他信息,拆分之后两个类的职责更加单一。是否应该拆分,取决于具体情况,如果实际中地址信息和基本信息总是同时出现,那放在一起没有问题。但是如果地址信息单独在其他模块中使用,就应该单独抽象成 UserAddress
:
1 | public class UserInfo { |
单一职责原则指导设计粒度较小的类,职责清晰的类,类的依赖以及被依赖的其他类也很会变少,从而降低代码的耦合性,实现高内聚、低耦合。但是如果拆分的过细,可能会适得其反,影响代码的可维护性。
几年前在面试的时候,还经常被面试官问 OOP
的四个特征是什么以及他们背后代表的意思,几年过去了,除了不支持面向对象的语言之外,面向对象编程思想已经深入到了每个开发者的灵魂,只是做的好与不好罢了。
面向对象编程中有两个非常基础的概念,类和对象,面向对象编程是一种编程范式或者说编程风格,它以类或者对象作为组织代码的基本单元,并将封装,继承,抽象,多态作为代码设计和实现的基石,不像面向过程编程语言,以函数为程序中的基本单元。
面向对象编程只是一种编程思想,可以用不同的语言进行实现,即使我们用面向对象语言,也完全可以写出面向过程风格的代码。至于什么是面向对象编程语言,并没有严格的定义,只要它能实现 OOP
的四大特性,那它就是面向对象编程语言,例如:Rust
,C++
,GO
,Java
,Python
以及 PHP
等,
面向对象编程的前提是面向对象分析(OOA
)和面向对象设计(OOD
),这样才能进行面向对象编程(OOP
),具备完整的面向对象编程的思维。面向对象分析和设计两个阶段的产物应该是类的设计,包括应用程序应该被分为哪些类,每个类该有哪些属性和方法,类与类之间如何交互等等,它们比较贴近代码,非常具体,容易落地实现。
在 OOA
和 OOD
的过程中,我们会经常用到 UML(Unified Model Language)
工具辅助我们进行工作。UML
是一种比较复杂的工具,除了包括我们常见的类图,还有用例图,顺序图,活动图,状态图,组件图等,即使是类图,类之间的关系就有泛化,实现,关联,聚合,组合以及依赖等,熟练掌握难度比较大,即便你掌握了,你同事不一定掌握,沟通成本依然很高,大多时候,我们会用草图实现我们的设计过程。
设计模式是软件设计中常见问题的典型解决方案。它们就像能根据需求进行调整的预制蓝图,可用于解决代码中反复出现的设计问题。
设计模式与方法或库的使用方式不同,很难直接在自己的程序中套用某个设计模式。模式并不是一段特定的代码,而是解决特定问题的一般性概念。可以根据模式来实现符合自己程序实际所需的解决方案。
人们常常会混淆模式和算法, 因为两者在概念上都是已知特定问题的典型解决方案。但算法总是明确定义达成特定目标所需的一系列步骤,而模式则是对解决方案的更高层次描述,同一模式在两个不同程序中的实现代码可能会不一样。
算法更像是菜谱:提供达成目标的明确步骤。而模式更像是蓝图:可以看到最终的结果和模式的功能,但需要自己确定实现步骤。
设计模式从分类上来讲,可以分为创建型、结构型和行为型。
如果我们正在开发一个聊天室,并且使用线程处理每个连接,我们的代码可能看起来像下面这个样子:
1 | use std::{net, thread}; |
对于每个新连接,这都会产生一个运行 serve
函数的新线程,该线程能够专注于管理单个连接的处理。
这很好用,但是如果突然用户达到成千上万时,线程堆栈增长到 100 KiB
或这更多时,这可能要花费几个GB
的内存。线程对于在多个处理器之间分配工作是非常好的一种形式,但是它们的内存需求使得我们在使用时要非常小心。
不过可以使用 Rust
异步任务在单个线程或工作线程池上并发运行许多独立活动。异步任务类似于线程,但创建速度更快,并且内存开销比线程少一个数量级。在一个程序中同时运行数十万个异步任务是完全可行的。当然,应用程序可能仍会受到网络带宽、数据库速度、计算或工作固有内存要求等其他因素的限制,但内存开销远没有线程那么多。
一般来说,异步 Rust
代码看起来很像普通的多线程代码,除了涉及到的 I/O
操作,互斥锁等阻塞操作需要稍微的不同处理。之前代码的异步版本如下所示:
1 | use async_std::{net, task}; |
这使用 async_std
的net
和task
模块,并在可能阻塞的调用之后添加 .await
。但整体结构与基于线程的版本相同。
本节的目标不仅是帮助编写异步代码,而且还以足够详细的方式展示它的工作原理,以便可以预测它在应用程序中的表现,并了解它最有价值的地方。
为了展示异步编程的机制,我们列出了涵盖所有核心概念的最小语言特性集:futures
、异步函数、await
表达式、task
以及 block_on
和 spawn_local
executor
;
然后我们介绍异步代码块和 spawn executor
。这些对于完成实际工作至关重要,但从概念上讲,它们只是我们刚刚提到的功能的变体。在此过程中,我们会可能会遇到一些异步编程特有的问题,但是需要学习如何处理它们;
为了展示所有这些部分的协同工作,我们浏览了聊天服务器和客户端的完整代码,前面的代码片段是其中的一部分;
为了说明原始 futures
和 executors
是如何工作的,我们提供了 spawn_blocking
和 block_on
的简单但功能性的实现;
最后,我们解释了 Pin
类型,它在异步接口中不时出现,以确保安全使用异步函数和 futures
;
Unicode
和 ASCII
匹配所有 ASCII
字符,从 0
到 0x7f
。例如,都将字符 *
分配给码点 42
。类似地,Unicode
将 0
到 0xff
分配给与 ISO/IEC 8859-1
字符集相同的字符,用于西欧语言的 8
位 ASCII
超集。Unicode
将此码点范围称为 Latin-1
代码块。
因为 Unicode
是 Latin-1
的超集,所以从 Latin-1
转换到 Unicode
是完全允许的:
1 | fn latin1_to_char(latin1: u8) -> char { |
假设码点在 Latin-1
范围内,反向转换也很简单:
1 | fn char_to_latin1(c: char) -> Option<u8> { |
Rust
中 String
和 str
类型都是使用 UTF-8
编码格式,它是一种变长编码,使用1
到4
个字节对字符进行编码。有效的 UTF-8
序列有两个限制。首先,对于任何给定码点,只有最短的编码被认为是有效的,也就是不能花费4
个字节来编码一个适合3
个字节的码点。 此规则确保给定代码点只有一个 UTF-8
编码。其次,有效的 UTF-8
不得编码为 0xd800
到 0xdfff
或超过 0x10ffff
的数字:这些数字要么保留用于非字符目的,要么完全超出 Unicode
的范围。
Rust 标准库包含几个集合,用于在内存中存储数据的泛型类型。我们已经在前面使用了集合,例如 Vec
和 HashMap
。在本章中,我们将详细介绍这两种类型的方法,以及其他6
个标准集合。
Rust
一共有8
个标准集合类型,它们都是泛型:
Vec<T>
:一个可增长的、堆分配的 T
类型值数组;
VecDeque<T>
:与 Vec<T>
类似,但更适合用作先进先出队列,它支持在列表的前面和后面有效地添加和删除值;
BinaryHeap<T>
:一个优先队列,BinaryHeap
中的值是有组织的,所以它总是有效地找到并删除最大值;
HashMap<K, V>
:键值对表,通过键查找值很快,item
以任意顺序存储;
BTreeMap<K, V>
:与 HashMap<K, V>
类似,但它保持entries
按键排序。 BTreeMap<String, i32>
以字符串比较顺序存储其entries
。除非需要entries
保持排序,否则 HashMap
更快;
HashSet<T>
:一组 T
类型的值。添加和删除值很快,查询给定值是否在集合中也很快;
BTreeSet<T>
:与 HashSet<T>
类似,但它保持元素按值排序。 同样,除非需要对数据进行排序,否则 HashSet
更快;
Rust
用于输入和输出的标准库功能围绕三个Trait
组织:Read
、BufRead
和 Write
:
实现 Read
的值具有面向字节的输入的方法,他们被称为 Reader
;
实现 BufRead
的值是缓冲读取器,它们支持 Read
的所有方法,以及读取文本行等的方法;
实现 Write
的值支持面向字节和UTF-8
文本输出,它们被称为 Writer
;
在本节中,将解释如何使用这些Trait
及其方法,涵盖图中所示的读取器和写入器类型,并展示与文件、终端和网络交互的其他方式。
Readers
、Writers
Readers
是内容输入源,可以从哪里读取字节。例如:
使用 std::fs::File::open
打开的文件;
可以从 std::net::TcpStream
代表的网络连接中读取数据;
可以从 std::io::stdin()
标准输入读取数据;
std::io::Cursor<&[u8]>
和 std::io::Cursor<Vec<u8>>
值,它们是从已经在内存中的字节数组或vector
中“读取”的读取器;
Writers
是那些你可以把值写入的地方,例如:
使用 std::fs::File::create
创建的文件;
基于网络连接 std::net::TcpStream
传输数据;
std::io::stdout()
和 std::io:stderr()
可以用于向标准输出和标准错误写入内容;
std::io::Cursor<Vec<u8>>
类似,但允许读取和写入数据,并在vector
中寻找不同的位置;
std::io::Cursor<&mut [u8]>
和上面的类似,但是不能增长内部的 buffer
,因为它仅仅是已存在的字节数组的引用;
由于Reader
和Writer
有标准的 Trait
(std::io::Read
和 std::io::Write
),编写适用于各种输入或输出通道的通用代码是很常见的。 例如,这是一个将所有字节从任何读取器复制到任何写入器的函数:
1 | use std::io::{self, ErrorKind, Read, Write}; |
这是 Rust
标准库 std::io::copy()
的实现,因为它是泛型的,所以可以把数据从 File
复制到 TcpStream
,或者从 Stdin
到内存中的 Vec<u8>
。
Rust 提供了一种非常好的并发使用方法,它不强制所有程序采用单一风格,而是通过安全地支持多种风格,并由编译器强制执行。我们将介绍三种使用 Rust
线程的方法:
Fork-join
并行;Chanel
);在此过程中,将使用到目前为止所学的有关 Rust
语言的所有内容,Rust
对引用、可变性和生命周期的关注在单线程程序中足够有价值,但在并发编程中,这些规则的真正意义变得显而易见。
Fork-Join Parallelism
最简单的用于多线程的案例是处理互不相干的任务,例如,我们要处理大量的文档,可能会这样写:
1 | fn process_files(filenames: Vec<String>) -> io::Result<()> { |
Rust
语言支持宏,如我们之前使用的 assert_eq!
,println!
等。宏做了函数不能做的一些事情,例如,assert_eq!
当一个断言失败时,assert_eq!
生成包含断言的文件名和行号的错误消息,普通函数无法获取这些信息,但宏可以,因为它们的工作方式完全不同。
宏是一种简写,在编译期间,在检查类型和生成任何机器代码之前,每个宏调用都会被扩展。也就是说,它被一些 Rust
代码替换。assert_eq!
调用扩展为大致如下:
1 | match (&gcd(6, 10), &2) { |
panic!
也是一个宏,它本身扩展为更多的 Rust
代码。该代码使用到了另外两个宏:file!()
和 line!()
。 一旦 crate
中的每个宏调用都被完全展开,Rust
就会进入下一个编译阶段。
在运行时,断言失败看起来像这样:
thread 'main' panicked at 'assertion failed: `(left == right)`, (left: `17`, right: `2`)', gcd.rs:7
如果熟悉 C++
,可能对宏有过一些不好的体验。但是 Rust
宏采用不同的方法,类似于 Scheme
的语法规则。与 C++
宏相比,Rust
宏可以更好地与语言的其余部分集成,因此更不容易出错。宏调用总是标有感叹号 !
,因此在阅读代码时它们会比较突出,所以不会意外调用它们。Rust
宏从不插入不匹配的括号或圆括号,并且 Rust
宏带有模式匹配,使得编写既可维护又易于使用的宏变得更加容易。
在本节中,我们将通过几个简单的例子来展示如何编写宏。但与 Rust
的大部分内容一样,理解宏需要下很大功夫。在这里将介绍一个很复杂的宏的设计,它可以将 JSON
文字直接嵌入到我们的程序中。但是宏的内容涵盖的非常多,因此这里将提供一些进一步研究的建议,包括我们在此处展示的高级技术,以及称为过程宏的更强大的工具。