面向代码之丑

代码之丑

写在前面

​ 极客时间,启动!

  1. 好的命名,是体现业务含义的命名
  2. 编写符合英语语法规则的代码
  3. 不要重复自己,不要复制粘贴
  4. 把函数写短,越短越好
  5. 把类写小,越小越好
  6. 减小参数列表,越小越好
  7. 循环和选择语句,可能都是坏味道
  8. 构建模型,封装散落的代码
  9. 限制可变的数据
  10. 一次性完成变量的初始化
  11. 代码应该向着稳定的方向依赖
  12. 保持代码在各个层面上的一致性
  13. 不断学习“新”的代码风格,不断改善自己的代码

开篇

func approve(bookId int) {
	...
	book.setReviewStatus(APPROVED)
	...
}

func approve(bookId int) {
	...
  book.approve()
	...
}

​ 这个示例的主要逻辑是将作品的审核状态设置为通过,再存回去。

​ ⚠️ 这个审核的状态是作品的一个内部状态,为什么服务需要知道它呢?也就是说,通过setter将一个类的内部行为暴露出来,是破坏封装的做法。

​ setter 的出现,是对于封装的破坏,它把一个类内部的实现细节暴露了出来。面向对象的封装,关键点是行为,而使用 setter 多半只是做了数据的聚合,缺少了行为的设计,这段代码改写后的 approve 函数,就是这里缺少的行为。

​ “写代码”有两个维度:正确性和可维护性,不要只关注正确性。能把代码写对,是每个程序员的必备技能,但能够把代码写得更具可维护性,这是一个程序员从业余迈向职业的第一步

​ 有对代码坏味道的嗅觉,能够识别出坏味道,接下来,你才有机会去“重构(Refactoring)”,把代码一点点打磨成一个整洁的代码(Clean Code)。Linux 内核开发者 Linus Torvalds 在行业里有个爱骂人的坏名声,原因之一就是他对于坏味道的不容忍。

​ 推荐那些想要提高自己编程水平的人读《重构》,如果时间比较少,就去读第三章“代码的坏味道”。

​ 不过,《重构》中的“代码的坏味道”意图虽好,但却需要一个人对于整洁代码有着深厚的理解,才能识别出这些坏味道。否则,即使你知道有哪些坏味道,但真正有坏味道的代码出现在你面前时,你仍然无法认得它。

​ 比如,你可以看看 Info、Data、Manager 是不是代码库经常使用的词汇,而它们往往是命名没有经过仔细思考的地方。在很多人眼中,这些代码是没有问题的。正因如此,才有很多坏味道的代码才堂而皇之地留在你的眼皮底下。

​ 代码坏味道自查表:

  • 命名
    • 命名是否具有业务含义
    • 命名是否符合英语语法
  • 函数
    • 代码行是否超过()行
    • 参数列表是否超过()个
    • 类的字段是否超过()个
    • 类之间的依赖关系是否符合架构规则
  • 语句
    • 是否使用for循环
    • 是否使用else
    • 是否有重复的switch
    • 一行代码是否出现了连续的方法调用
    • 代码中是否出现了setter
    • 变量声明后是否有立即再赋值
    • 集合声明之后是否有立即添加元素
    • 返回值是否可以使用Optional

典型代码坏味道

一、 缺乏业务含义的命名

1、 不精准的命名
func processData() {
	dataService.getData();
	dataService.setDataState(TRANSLATING);
	dataService.save();
}

​ 乍一看没有问题,但是提问一下,这段代码是做什么的。 那么你需要认真阅读这段代码,理解逻辑后才能得到答案:这段代码是将data的状态改为翻译中

​ ⚠️ 为什么你需要阅读这段代码的细节,才能知道这段代码是做什么的?

​ 问题出于函数名,processData处理数据过于宽泛,换句话说很多场景都能够叫做处理数据。

命名过于宽泛,不能精准描述,这是很多代码在命名上存在的严重问题,也是代码难以理解的根源所在

​ 或许这么说你的印象还是不深刻,我们看看下面这些词是不是经常出现在你的代码里:

​ data、info、flag、process、handle、build、maintain、manage、modify 等等。

一个好的名字应该描述意图,而非细节

一个好的名字应该描述意图,而非细节

一个好的名字应该描述意图,而非细节

processData——>changeDataStateToTranslating——>startTranslation

2、 用技术术语命名
	bookMap := make(map[string]interface{})
	bookSet := make(map[string]struct{})

​ 这是一种不费脑子的命名方式,但是,这种命名却会带来很多问题,因为它是一种基于实现细节的命名方式。

​ 我们都知道,编程有一个重要的原则是面向接口编程,这个原则从另外一个角度理解,就是不要面向实现编程,因为接口是稳定的,而实现是易变的。虽然在大多数人的理解里,这个原则是针对类型的,但在命名上,我们也应该遵循同样的原则。为什么?我举个例子你就知道了。

​ 比如,如果我发现,我现在需要的是一个不重复的作品集合,也就是说,我需要把这个变量的类型从 List 改成 Set。变量类型你一定会改,但变量名你会改吗?这还真不一定,一旦出现遗忘,就会出现一个奇特的现象,一个叫 dataList 的变量,它的类型是一个 Set。这样,一个新的混淆就此产生了。

​ 那有什么更好的名字吗?我们需要一个更面向意图的名字。其实,我们在这段代码里真正要表达的是拿到了一堆书,所以,这个名字可以命名成 books。

	books := bookService.getBooks()

​ 虽然这里我们只是以变量为例说明了以技术术语命名存在的问题,事实上,在实际的代码中,技术名词的出现,往往就代表着它缺少了一个应有的模型。

	redisService.getData()
	cacheService.getData()

​ 通常来说,这里真正需要的是一个缓存。Redis 是缓存这个模型的一个实现

3、 用业务语言写代码

func approve(bookId int, userId int) {
	...
}

​ 业务逻辑: bookId 审核Id, userId 审核人id,后续可能用于审计等需求。

​ 但在这里,user充当的是一个审核人Reviewer角色,因此使用reviewerUserId更佳

​ 从某种意义上来说,这个坏味道也是一种不精准的命名,但它不是那种一眼可见的坏味道,而是需要在业务层面上再进行讨论,所以,它是一种更高级的坏味道。

坏味道:缺乏业务含义的命名

  • 错误命名:
    • 宽泛的命名
    • 用技术术语命名
  • 命名遵循的原则
    • 描述意图,而非细节
    • 面向接口编程,接口是稳定的,实现是易变的
    • 命名汇总出现技术名词,往往是它缺少一个模型
    • 是用业务语言

--------------------------------好的命名,是体现业务含义的命名------------------------------

二、 乱用英语

1、 违反语法规则的命名

​ 程序员的英语一定要多好,但最低限度的要求是写出来的代码要像是在用英语表达

一般来说,常见的命名规则是:

类名是一个名词,表示一个对象

而方法名则是一个动词,或者是动宾短语,表示一个动作

completedTranslate——>completeTranslation

2、 不准确的英语词汇

​ ChapterAuditStatus、BookReviewStatus

把 audit 和 review 同时放到了搜索引擎里查了一下。原来,audit 会有更官方的味道,更合适的翻译应该是审计,而 review 则有更多核查的意思,二者相比,review 更适合这里的场景。于是,章节的审核状态也统一使用了 review:

​ **在这种情况下,最好的解决方案还是建立起一个业务词汇表,千万不要臆想。**一般情况下,我们都可以去和业务方谈,共同确定一个词汇表,包含业务术语的中英文表达。这样在写代码的时候,你就可以参考这个词汇表给变量和函数命名。

​ 建立词汇表的另一个关键点就是,用集体智慧,而非个体智慧

3、 英语单词的拼写错误

​ sortFiled——sortField

坏味道:乱用英语

  • 英语使用不当
    • 违反语法规则
    • 不准确的英语词汇
    • 英语单词拼写错误
  • 解决方法
    • 制定代码规范
    • 建立团队词汇表
    • 经常性进行代码评审

--------------------------------编写符合英语语法规则的代码------------------------------

三、 重复代码

​ 复制粘贴是最容易产生重复代码的地方,所以,一个最直白的建议就是,不要使用复制粘贴。

真正应该做的是,先提取出函数,然后,在需要的地方调用这个函数。

​ 其实,复制粘贴的重复代码是相对容易发现的,但有一些代码是有类似的结构,这也是重复代码,有些人对这类坏味道却视而不见。

1、 重复的结构

​ 函数调用业务代码不同,但结构一致——> 提取结构,从面向对象的设计来说,就是提出一个接口

2、 做真正的选择

​ 写代码要有表达性。把意图准确地表达出来,是写代码过程中非常重要的一环。显然,这里的 if 判断区分的是参数,而非动作。所以,我们可以把这段代码稍微调整一下,会让代码看上去更容易理解:

if data.enabled {
	dataService.edit(bookId, true)
} else {
	dataService.edit(bookId, false)
}

enableFlag := data.enabled
dataService.edit(bookId, enableFlag)

​ 重复是一个泥潭,对于程序员来说,时刻提醒自己不要重复是至关重要的。在软件开发里,有一个重要的原则叫做 Don’t Repeat Yourself(不要重复自己,简称 DRY)

写代码要想做到 DRY,一个关键点是能够发现重复

坏味道:重复代码

  • 重复的代码
    • 复制粘贴的代码
    • 结构重复的代码
    • if和else代码块中的语句高度类似
  • 消灭重复代码的原则
    • Don’t Repeat Yourself(DRY)
    • 每一处知识都必须有单一、明确、权威地表述
  • 注意
    • 不要被“动词”上的差异迷惑
    • 结构重复,也是重复代码

--------------------------------不要重复自己,不要复制粘贴------------------------------

四、 长函数

平铺直叙的代码存在的两个典型问题:

  • 把多个业务处理流程放在一个函数里实现;
  • 把不同层面的细节放到一个函数里实现。

关注点越多越好,粒度越小越好。

​ **任何代码都经不起这种无意识的累积,每个人都没做错,但最终的结果很糟糕。**对抗这种逐渐糟糕腐坏的代码,我们需要知道“童子军军规”:

让营地比你来时更干净。

—— 童子军军规

​ Robert Martin 把它借鉴到了编程领域,简言之,我们应该看看自己对于代码的改动是不是让原有的代码变得更糟糕了,如果是,那就改进它。但这一切的前提是,你要能看出自己的代码是不是让原有的代码变得糟糕了,所以,学习代码的坏味道还是很有必要的。

坏味道:长函数

  • 产生
    • 以性能为由
    • 平铺直叙写代码
    • 一次增加一点点代码
  • 消灭长函数的原则
    • 定义好函数长度的标准
    • 做好“分离关注点”
    • 坚守“童子军军规”
  • 重构手法
    • 提取函数

--------------------------------把函数写短,越短越好------------------------------

五、 大类

​ 事实是,把代码写到一个文件里,一方面,相同的功能模块没有办法复用;另一方面,也是更关键的,把代码都写到一个文件里,其复杂度会超出一个人能够掌握的认知范围。简言之,一个人理解的东西是有限的,没有人能同时面对所有细节。

​ 人类面对复杂事物给出的解决方案是分而治之。所以,我们看到几乎各种程序设计语言都有自己的模块划分方案,从最初的按照文件划分,到后来,使用面向对象方案按照类进行划分,本质上,它们都是一种模块划分的方式。这样,人们面对的就不再是细节,而是模块,模块的数量显然会比细节数量少,人们的理解成本就降低了。

​ 好,你现在已经理解了,对程序进行模块划分,本质上就是在把问题进行分解,而这种做法的背后原因,就是人类的认知能力是有限的。

​ 理解了这一点,我们再回过头来看大类这个坏味道,你就知道问题出在哪了。

如果一个类里面的内容太多,它就会超过一个人的理解范畴,顾此失彼就在所难免了

type User struct {
	UserId int
	Name string
	NickName string
	Email string
	PhoneNumber string
	AuthorType model.AuthorType
	EditorType model.EditorType
}
1、 职责单一

想要破解“大类”的谜题,关键就是能够把不同的职责拆分开来

type Author struct {
	UserId int
	AuthorType model.AuthorType
}

type Editor struct {
	UserId int
	EditorType model.EditorType
}
2、 字段分组
type Contact struct {
	Email string
	PhoneNumber string
}

所谓的将大类拆解成小类,本质上在做的工作是一个设计工作

坏味道:大类

  • 产生原因
    • 职责不单一
    • 字段未分组
  • 软件设计原则
    • 单一职责原则

--------------------------------把类写小,越小越好------------------------------

六、长参数列表

1、 聚沙成塔

​ 一个典型的消除长参数列表的重构手法:将参数列表封装成对象

func createUser(userId int, name, nickName, email, phoneNumber string) {
	newUser := model.User{
		UserId: userId,
		Name: name,
		NickName: nickName,
		Email: email,
		PhoneNumber: phoneNumber
	}
}


func createUser(user User) {
  newUser := model.User{
		UserId: user.UserId,
		Name: user.Name,
		NickName: user.NickName,
		Email: user.Email,
		PhoneNumber: user.PhoneNumber
	}
}

​ 或许你还有个疑问,只是把一个参数列表封装成一个类,然后,用到这些参数的时候,还需要把它们一个个取出来,这会不会是多此一举呢

一个模型的封装应该是以行为为基础的。

func (u *User) newUser() {
	newUser := model.User{
		UserId: u.UserId,
		Name: u.Name,
		NickName: u.NickName,
		Email: u.Email,
		PhoneNumber: u.PhoneNumber
	}
}
2、 动静分离
func new(client base.HTTPClient, ctx context.Context, userId int) {
  ...
}


type PromxyClient struct {
	base.HTTPClient
  context.Context
}

func (p *PromxyClient) name(userId int)  {
	p.client.HttpGet()
	p.ctx.Value(userId)
}

​ 不同的数据变动方向也是不同的关注点。这里表现出来的就是典型的动数据(bookId)和静数据(HTTPClient 和 Context),它们是不同的关注点,应该分离开来。

这些参数属于一个类,有相同的变化原因。

3、 告别标记
func edit(bookId int, title string, approved bool) {
	...
}

func edit(bookId int, title string) {
	...
}
func editWithApproval(bookId int, title string){
	...
}

​ 标记参数在代码中存在的形式很多,有的是布尔值的形式,有的是以枚举值的形式,还有的就是直接的字符串或者整数。无论哪种形式,我们都可以通过拆分函数的方式将它们拆开。在重构中,这种手法叫做移除标记参数(Remove Flag Argument)。

坏味道:长参数

  • 消除长参数
    • 变化频率相同,封装成一个类
    • 变化频率不同
      • 静态不变的,成为结构的一部分
      • 多个变化频率的,封装成几个类
  • 标记参数导致的长函数
    • 根据标记参数,将函数拆分成多个函数
  • 重构手法
    • 将参数列表封装成对象
    • 移除标记参数 Remove Flag Argument

--------------------------------减小参数列表,越小越好------------------------------

七、 滥用控制语句

1、 if和else
func actBooks(bookId int) {
	books := getBooks(bookId)
	for _, book := range books {
		if book.isValid{
				registered := getRegister(book)
				if registered {
					xxx
				}
		}
	}
}
// ------------------------------------------

func actBooks(bookId int) {
	books := getBooks(bookId)
	for _, book := range books {
    actBook(book)
	}
}

func actBook(book Book) {
  if book.isValid{
			registered := getRegister(book)
			if registered {
				xxx
			}
	}
}
// ------------------------------------------

func actBooks(bookId int) {
	books := getBooks(bookId)
	for _, book := range books {
    actBook(book)
	}
}

func actBook(book Book) {
  if !book.isValid{
		return
	}
  registered := getRegister(book)
  if !registered {
    return
  }
  xxx
}

​ 这是一种典型的重构手法:

以卫语句取代嵌套的条件表达式(Replace Nested Conditional with Guard Clauses)

else 也是一种坏味道,这是挑战很多程序员认知的

func getGoat(score int) string {
	var goat string
	if score >= 90 {
		goat = "top1"
	} else if score > =80 {
		goat = "top2"
	} else if score >= 70 {
		goat = "top3"
	} else if score >= 60 {
		goat = "top4"
	} else {
		goat = "bad"
	}
	return goat
}
// ------------------------------------------

func getGoat(score int) string {
	if score >= 90 {
		return "top1"
	} 
  if score > =80 {
		return "top2"
	} 
  if score >= 70 {
		return "top3"
	} 
  if score >= 60 {
		return "top4"
	} 
	return "bad"
}

在软件开发中,有一个衡量代码复杂度常用的标准,叫做圈复杂度(Cyclomatic complexity,简称 CC),圈复杂度越高,代码越复杂,理解和维护的成本就越高。在圈复杂度的判定中,循环和选择语句占有重要的地位。

2、 重复的switch
func getBookPrice(user User, book Book) {
	price := book.getPrice()
	switch user.Level {
		case SILVER:
			return price * 0.9
		case GOLD:
			return price * 0.8
		case PLATINUM:
			return price * 0.75
		default:
			return price
	}
}

func getEpubPrice(user User, epub Epub) {
	price := epub.getPrice()
	switch user.Level {
		case SILVER:
			return price * 0.95
		case GOLD:
			return price * 0.85
		case PLATINUM:
			return price * 0.8
		default:
			return price
	}
}
// ------------------------------------------

type User struct {
	Level UserLevel
}

type UserLevel interface {
	getDiscount(product Product) float64
}

type SILVER struct{}
type GOLD struct{}
type PLATINUM struct{}

type Product interface {
	getPrice() float64
}

type Book struct {
	price float64
}

type Epub struct {
	price float64
}

func (b Book) getPrice() float64 {
	return b.price
}

func (e Epub) getPrice() float64 {
	return e.price
}

func (s SILVER) getDiscount(product Product) float64 {
	switch product.(type) {
	case Book:
		return 0.9
	case Epub:
		return 0.95
	default:
		return 1
	}
}

func (g GOLD) getDiscount(product Product) float64 {
	switch product.(type) {
	case Book:
		return 0.8
	case Epub:
		return 0.85
	default:
		return 1
	}
}

func (p PLATINUM) getDiscount(product Product) float64 {
	switch product.(type) {
	case Book:
		return 0.75
	case Epub:
		return 0.8
	default:
		return 1
	}
}

func getPrice(user User, product Product) float64 {
	price := product.getPrice()
	discount := user.Level.getDiscount(product)
	return price * discount
}

​ 重复的 switch 本质上是缺少了一个模型,可以使用多态取代条件表达式,引入缺少的模型,消除重复的 switch。

​ 重构的手法是:以多态取代条件表达式(Relace Conditional with Polymorphism)

坏味道:滥用控制语句

  • 呈现形态
    • 嵌套的代码
    • else语句
    • 重复的switch
    • 循环语句
  • 编程规则
    • 函数至多有一层缩进
    • 不要使用else关键字
  • 重构手法
    • 以卫语句取代嵌套的条件表达式
    • 多态取代条件表达式

--------------------------------循环和选择语句,可能都是坏味道------------------------------

八、 缺乏封装

​ 在程序设计中,一个重要的观念就是封装,将零散的代码封装成一个又一个可复用的模块。任何一个程序员都会认同封装的价值,但是,具体到写代码时,每个人对于封装的理解程度却天差地别,造成的结果就是:写代码的人认为自己提供了封装,但实际上,我们还是看到许多的代码散落在那里。

1、 火车残骸
	name := book.getAuthor().getName()

​ 这段代码表达的是“获得一部作品作者的名字”。作品里有作者信息,想要获得作者的名字,通过“作者”找到“作者姓名”,这就是很多人凭借直觉写出的代码,不过它是有问题的。

​ 你可以想一想,如果你想写出上面这段代码,是不是必须得先了解 Book 和 Author 这两个类的实现细节?也就是说,我们必须得知道,作者的姓名是存储在作品的作者字段里的。这时你就要注意了:当你必须得先了解一个类的细节,才能写出代码时,这只能说明一件事,这个封装是失败的。

​ Martin Fowler 在《重构》中给这种坏味道起的名字叫过长的消息链(Message Chains),而有人则给它起了一个更为夸张的名字:火车残骸(Train Wreck),形容这样的代码像火车残骸一般,断得一节一节的。

​ 解决这种代码的重构手法叫隐藏委托关系(Hide Delegate),说得更直白一些就是,把这种调用封装起来:

func (b *Book) getAuthorName() {
	return b.author.getName()
}

​ **要想摆脱初级程序员的水平,就要先从少暴露细节开始。**声明完一个类的字段之后,请停下生成 getter 的手,转而让大脑开始工作,思考这个类应该提供的行为。

​ 在软件行业中,有一个编程的指导原则几乎就是针对这个坏味道的,叫做迪米特法则(Law of Demeter),这个原则是这样说的:

  • 每个单元对其它单元只拥有有限的知识,而且这些单元是与当前单元有紧密联系的;
  • 每个单元只能与其朋友交谈,不与陌生人交谈;
  • 只与自己最直接的朋友交谈。

​ 最后,还有一个问题我要提醒你一下。有些内部 DSL 的表现形式也是连续的函数调用,但 DSL 是声明性的,是在说做什么(What),而这里的坏味道是在说怎么做(How),二者的抽象级别是不同的,不要混在一起。

2、 基本类型偏执
func (e Epub) getPrice() float64 {
	return e.price
}

我们在数据库中存储价格的时候,就是用一个浮点数,这里用 double 可以保证计算的精度,这样的设计有什么问题吗?

确实,这就是很多人使用基本类型(Primitive)作为变量类型思考的角度。但实际上,这种采用基本类型的设计缺少了一个模型

虽然价格本身是用浮点数在存储,但价格和浮点数本身并不是同一个概念,有着不同的行为需求。比如,一般情况下,我们要求商品价格是大于 0 的,但 double 类型本身是没有这种限制的。

type Books *list.List

type Books struct {
	books *list.List
}

​ 之所以有人把 Books 写成了继承,因为在代码作者眼中,Books 就是一个书的集合;而有人用 double 做价格的类型,因为在他看来,价格就是一个 double。这里的误区就在于,一些程序员只看到了模型的相同之处,却忽略了差异的地方。Books 可能不需要提供 List 的所有函数,价格的取值范围与 double 也有所差异。

​ 但是,Books 的问题相对来说容易规避,因为产生了一个新的模型,有通用的设计原则帮助我们判断这个模型构建得是否恰当,而价格的问题却不容易规避,因为这里没有产生新的模型,也就不容易发现这里潜藏着问题。

​ 这种以基本类型为模型的坏味道称为基本类型偏执(Primitive Obsession)。这里说的基本类型,不限于程序设计语言提供的各种基本类型,像字符串也是一个产生这种坏味道的地方。

封装之所以有难度,主要在于它是一个构建模型的过程,而很多程序员写程序,只是用着极其粗粒度的理解写着完成功能的代码,根本没有构建模型的意识;还有一些人以为划分了模块就叫封装,所以,我们才会看到这些坏味道的滋生。

​ 这里我给出的坏味道,其实也是在挑战一些人对于编程的认知:那些习以为常的代码居然成了坏味道。而这只是一个信号,一个起点,告诉你这段代码存在问题,但真正要写好代码,还是需要你对软件设计有着深入的学习。

坏味道:缺乏封装

  • 呈现形态
    • 火车残骸/过长的消息链
    • 基本类型偏执
  • 编程规则
    • 遵循迪米特法则
    • 封装所有的基本类型和字符串
    • 使用一流的集合
  • 重构的手法
    • 隐藏委托关系
    • 以对象取代基本类型

--------------------------------构建模型,封装散落的代码------------------------------

九、 可变的数据

1、 满天飞的setter

​ 对于程序,最朴素的一种认知是“程序 = 数据结构 + 算法”,所以,数据几乎是软件开发最核心的一个组成部分。在一些人的认知中,所谓做软件,就是一系列的 CRUD 操作,也就是对数据进行增删改查。再具体一点,写代码就把各种数据拿来,然后改来改去。我们学习编程时,首先学会的,也是给变量赋值,写出类似 a = b + 1之类的代码。

func approve(bookId int) {
	...
	book.setReviewStatus(APPROVED)
	...
}

​ setter 往往是缺乏封装的一种做法。对于缺乏封装的坏味道,我们上节课已经用了一讲的篇幅在说,我提到,很多人在写代码时,写完字段就会利用 IDE 生成 getter,实际情况往往是,生成 getter 的同时,setter 也生成了出来。setter 同 getter 一样,反映的都是对细节的暴露。

​ 这就意味着,你不仅可以读到一个对象的数据,还可以修改一个对象的数据。相比于读数据,修改是一个更危险的操作

​ 简言之,你不知道数据会在哪里被何人以什么方式修改,造成的结果是,别人的修改会让你的代码崩溃。与之相伴的还有各种衍生出来的问题,最常见的就是我们常说的并发问题。

​ 可变的数据是可怕,但是,比可变的数据更可怕的是,不可控的变化,而暴露 setter 就是这种不可控的变化。把各种实现细节完全交给对这个类不了解的使用者去修改,没有人会知道他会怎么改,所以,这种修改完全是不可控的。

func approve(bookId int) {
	...
  book.approve()
	...
}

​ 作为这个类的使用者,你并不需要知道这个类到底是怎么实现的。更重要的是,这里的变化变得可控了。虽然审核状态这个字段还是会修改,但你所有的修改都要通过几个函数作为入口。有任何业务上的调整,都会发生在类的内部,只要保证接口行为不变,就不会影响到其它的代码。

​ setter 破坏了封装,相信你对这点已经有了一定的理解。不过,有时候你会说,我这个 setter 只是用在初始化过程中,而并不需要在使用的过程去调用,就像下面这样:

book := newBook()
book.setBookId(bookId)
book.setTitle(title)

​ 实际上,对于这种只在初始化中使用的代码,压根没有必要以 setter 的形式存在,真正需要的是一个有参数的构造函数:

book := newBook(bookId int, title string)

​ 消除 setter ,有一种专门的重构手法,叫做移除设值函数(Remove Setting Method)

2、 可变的数据

​ 我们反对使用 setter,一个重要的原因就是它暴露了数据,我们前面说过,暴露数据造成的问题就在于数据的修改,进而导致出现难以预料的 Bug。在上面的代码中,我们把 setter 封装成一个个的函数,实际上是把不可控的修改限制在一个有限的范围内。

​ 那么,这个思路再进一步的话,如果我们的数据压根不让修改,犯下各种低级错误的机会就进一步降低了。没错,在这种思路下,可变数据(Mutable Data)就成了一种坏味道

​ 函数式编程的不变性,其中的关键点就是设计不变类。Java 中的 String 类就是一个不变类,比如,如果我们把字符串中的一个字符替换成另一个字符,String 类给出的函数签名是这样的:

解决可变数据,还有一个解决方案是编写不变类。

// ReplaceAll returns a copy of the string s with all
// non-overlapping instances of old replaced by new.
// If old is empty, it matches at the beginning of the string
// and after each UTF-8 sequence, yielding up to k+1 replacements
// for a k-rune string.
func ReplaceAll(s, old, new string) string {
	return Replace(s, old, new, -1)
}

其含义是,这里的替换并不是在原有字符串上进行修改,而是产生了一个新的字符串。

那么,在实际工作中,我们怎么设计不变类呢?要做到以下三点:

  • 所有的字段只在构造函数中初始化;
  • 所有的方法都是纯函数;
  • 如果需要有改变,返回一个新的对象,而不是修改已有字段。

回过头来看我们之前改动的“用构造函数消除 setter”的代码,其实就是朝着这个方向在迈进。如果按照这个思路改造我们前面提到的 approve 函数,同样也可以:

func approve(bookId int) Book {
	
	return newBook(...,APPROVED, ...)
}

​ 我们最核心要识别的对象分成两种,实体和值对象。实体对象要限制数据变化,而值对象就要设计成不变类

​ 如果你还想进一步提升自己对于不变性的理解,我们可以回到函数式编程这个编程范式的本质,它其实是对程序中的赋值进行了约束。基于这样的理解,连赋值本身其实都会被归入到坏味道的提示,这才是真正挑战很多人编程习惯的一点

​ Martin Fowler 在《重构》中还提到一个与数据相关的坏味道:全局数据(Global Data)。如果你能够理解可变数据是一种坏味道,全局数据也就很容易理解了,它们处理手法基本上是类似的

坏味道:可变的数据

  • 呈现形态
    • 暴露的细节
    • 可变的数据
    • 全局数据
  • 编程规则
    • 限制变化
    • 尽可能编写不变类
    • 区分类的性质,实体对象要限制数据变化,而值对象就要设计成不变类
  • 重构手法
    • 移除设值函数

--------------------------------限制可变的数据------------------------------

十、变量声明与赋值分离

1、 变量的初始化
var status string
if response.getCode() == 200 {
	status = model.CREATED
} else {
	status = model.TOCREATED
}

​ 我们这次的重点在 status 这个变量上,虽然 status 这个变量在声明的时候,就赋上了一个 null 值,但实际上,这个值并没有起到任何作用,因为 status 的变量值,其实是在经过后续处理之后,才有了真正的值。换言之,从语义上说,第一行的变量初始化其实是没有用的,这是一次假的初始化。

​ 按照我们通常的理解,一个变量的初始化是分成了声明和赋值两个部分,而我这里要说的就是,变量初始化最好一次性完成。这段代码里的变量赋值是在声明很久之后才完成的,也就是说,变量初始化没有一次性完成。

这种代码真正的问题就是不清晰,变量初始化与业务处理混在在一起。通常来说,这种代码后面紧接着就是一大堆更复杂的业务处理。当代码混在一起的时候,我们必须小心翼翼地从一堆业务逻辑里抽丝剥茧,才能把逻辑理清,知道变量到底是怎么初始化的。很多代码难读,一个重要的原因就是把不同层面的代码混在了一起。

​ 这种代码在实际的代码库中出现的频率非常高,只不过,它会以各种变形的方式呈现出来。有的变量甚至是在相隔很远的地方才做了真正的赋值,完成了初始化,这中间已经夹杂了很多的业务代码在其中,进一步增加了理解的复杂度。

​ 所以,我们编程时要有一个基本原则:变量一次性完成初始化

const status := toStatus(response)

func toStatus(response http.Response) string {
	if response.getCode() == 200 {
		return model.CREATED
	}
	return model.TOCREATED
}

​ 还有一点不知道你注意到了没有,在新的变量声明中,我加上了 final,在 Java 的语义中,一个变量加上了 final,也就意味着这个变量不能再次赋值。对,我们需要的正是这样的限制。

​ 上一讲,我们讲了可变的数据会带来怎样的影响,其中的一个结论是,尽可能编写不变的代码。这里其实是这个话题的延伸,尽可能使用不变的量

​ 如果我们能够按照使用场景做一个区分,把变量初始化与业务处理分开,你会发现,在很多情况下,变量只在初始化完成之后赋值,就足以满足我们的需求了,在一段代码中,需要使用可变量的场景并不多。

​ 这个原则其实可以推广一下,在能够使用 final 的地方尽量使用 final,限制变量的赋值。

​ 这里说的“能够使用”,不仅包括普通的变量声明,还包含参数声明,还有类字段的声明,甚至还可以包括类和方法的声明。当然,我们这里改进的考量主要还是在变量上。你可以尝试着调整自己现有的代码,给变量声明都加上 final,你就会发现许多值得改进的代码。

2、 集合初始化
var permissions []string
permissions = append(permissions, "model.READ")
permissions = append(permissions, "model.WRITE")


permissions := []string{"model.READ","model.WRITE"}

​ 这种代码是非常常见的,声明一个集合,然后,调用一堆添加的方法,将所需的对象添加进去。

​ 我们不难发现,其实 permissions 对象一开始的变量声明,并没有完成这个集合真正的初始化,只有当集合所需的对象添加完毕之后,这个集合才是它应有的样子。换言之,只有添加了元素的集合才是我们需要的。

​ 这样解释这段代码,你是不是就发现了,这和我们前面所说的变量先声明后赋值,本质上是一回事,都是从一个变量的声明到初始化成一个可用的状态,中间隔了太远的距离。

​ 对比我们改造前后的代码,二者之间还有一个更关键的区别:前面的代码是命令式的代码,而后面的代码是声明式的代码。

​ 命令式的代码,就是告诉你“怎么做”的代码,就像改造前的代码,声明一个集合,然后添加一个元素,再添加一个元素。而声明式的代码,是告诉你“做什么”的代码,改造后就是,我要一个包含了这两个元素的集合。

​ 在《软件设计之美》专栏中讲 DSL 时,曾经讲过二者的区别,声明式的代码体现的意图,是更高层面的抽象,把意图和实现分开,从某种意义上来说,也是一种分离关注点。

​ 所以,用声明式的标准来看代码,是一个发现代码坏味道的重要参考

坏味道:变量的声明与赋值分离

  • 编程规则
    • 变量要一次性完成初始化
  • 应对策略
    • 在声明前加上const,用不变性的限制约束代码
    • 用声明式的方式进行集合的初始化

--------------------------------一次性完成变量的初始化------------------------------

十一、 依赖混乱

​ 在讲“大类”这个坏味道的时候曾经说过,为了避免同时面对所有细节,我们需要把程序进行拆分,分解成一个又一个的小模块。但随之而来的问题就是,我们需要把这些拆分出来的模块按照一定的规则重新组装在一起,这就是依赖的缘起。

​ 一个模块要依赖另外一个模块完成完整的业务功能,而到底怎么去依赖,这里就很容易产生问题。

if err := c.ShouldBindJSON(&request); err != nil {
		tsderror.SetParamErrRespWithMsg(c, err.Error())
		return
	}

service.Create(request)

​ 从 Resource 调用 Service,这几乎是行业里的标准做法,是没有问题的,但问题出在传递的参数上。请问,这个 NewBookRequest 的参数类应该属于哪一层,是 Controller 层,还是 service 层呢?

​ 一般来说,既然它是一个请求参数,通常要承载着诸如参数校验和对象转换的职责,按照我们通常的理解,它应该属于 Controller 层。如果这个理解是正确的,问题就来了,它为什么会传递给 service 层呢?

​ 按照通常的架构设计原则,service 层属于我们的核心业务,而 Controller 层属于接口。二者相较而言,核心业务的重要程度更高一些,所以,它的稳定程度也应该更高一些。同样的业务,我们可以用 REST 的方式对外提供,也可以用 RPC 的方式对外提供。

​ 还有更关键的一点是,有时候 service 层的参数和 Controller 层的参数并不是严格地一一对应。比如,创建作品时,我们需要一个识别作者身份的用户 ID,而这个参数并不是通过客户端发起的请求参数带过来,而是根据用户登录信息进行识别的。所以,用 service 层的参数做 Controller 层的参数,就存在差异的参数如何处理的问题。

​ 你有没有发现,我们突然陷入了一种两难的境地,如此一个简单的参数,放到哪个层里都有问题

​ 其实,之所以我们这么纠结,一个关键点在于,我们缺少了一个模型。

​ request 之所以弄得如此“里外不是人”,主要就是因为它只能扮演一个层中的模型,所以,我们只要再引入一个模型就可以破解这个问题。

if err := c.ShouldBindJSON(&request); err != nil {
		tsderror.SetParamErrRespWithMsg(c, err.Error())
		return
	}

service.Create(request.toNewCreateRequest)

​ 在这个结构中,NewBookParameter 属于 service 层,而 NewBookRequest 属于 resource 层,二者相互独立,我们之前纠结的问题也就不复存在了。

​ 好,现在我们理解了,通过增加一个模型,我们就破解了依赖关系上的纠结。

​ 也许你会说,虽然它们成了两个类,但是,它们两个应该长得一模一样吧。这算不算是一种重复呢?但我的问题是,它们两个为什么要一样呢?有了两层不同的参数,我们就可以给不同层次上的模型以不同的约定了。

​ 比如,对于 resource 层的请求对象,因为它的主要作用是传输,所以,一般来说,我们约定请求对象的字段主要是基本类型。而 service 的参数对象,因为它已经是核心业务的一部分,就需要全部转化为业务对象。举个例子,比如,同样表示价格,在请求对象中,我们可以是一个 double 类型,而在业务参数对象中,它应该是 Price 类型。

高层模块不应依赖于低层模块,二者应依赖于抽象。

High-level modules should not depend on low-level modules. Both should depend on abstractions.

抽象不应依赖于细节,细节应依赖于抽象。

Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

你需要知道,业务代码中任何与业务无关的东西都是潜在的坏味道

redis.getCache(key)


type Cache interface{
	getCache(key string)
}

type RedisCache struct{}
func(rc *RedisCache) getCache(key string) {
	xxx
}

坏味道:依赖混乱

  • 呈现形态
    • 缺少防腐层,业务与外部接口耦合
    • 业务代码中出现具体实现类
  • 应对策略
    • 引入防腐层,将业务与内部接口隔离
    • 引入模型,将业务和具体实现隔离
  • 编程规则
    • 高层模块不应依赖于低层模块,二者应依赖于抽象
    • 抽象不应依赖于细节,细节应依赖于抽象

--------------------------------代码应该向着稳定的方向依赖------------------------------

十二、 不一致的代码

1、 命名不一致
const (
	WRITE,
	READ_ONLY,
	ALL
)

​ 按照我对一致性的理解,表示类似含义的代码应该有一致的名字,比如,很多团队里都会把业务写到服务层,各种服务的命名也通常都是 XXXService,像 BookService、ChapterService 等等。而一旦出现了不一致的名字,通常都表示不同的含义,比如,对于那些非业务入口的业务组件,它们的名字就会不一样,会更符合其具体业务行为,像 BookSender ,它表示将作品发送到翻译引擎。

2、 方案不一致

​ 有时,程序员也会因为自己的原因引入不一致。比如,在代码中引入做同一件事情类似的程序库。像判断字符串是否为空或空字符串,Java 里常用的程序库就有 GuavaApache 的 Commons Lang,它们能做类似的事情,所以,程序员也会根据自己的熟悉程度选择其中之一来用,造成代码中出现不一致。

​ 这两个程序库是很多程序库的基础,经常因为引入了其它程序库,相应的依赖就出现在我们的代码中。所以,我们必须约定,哪种做法是我们在项目中的标准做法,以防出现各自为战的现象。比如,在我的团队中,我们就选择 Guava 作为基础库,因为相对来说,它的风格更现代,所以,团队就约定类似的操作都以 Guava 为准。

3、 代码不一致

​ 代码中的不一致常常是把不同层次的代码写在了一起,最典型的就是把业务层面的代码和实现细节的代码混在一起。解决这种问题的方式,就是通过提取方法,把不同层次的代码放到不同的函数里,而这一切的前提还是是分离关注点,这个代码问题的背后还是设计问题。

坏味道:不一致的代码

  • 呈现形态
    • 命名中的不一致
    • 方案中的不一致
    • 代码中的不一致
  • 应对策略
    • 团队统一编码方案
    • 提取函数,将不同层次的内容放入不同函数中

--------------------------------保持代码在各个层面上的一致性------------------------------

十三、 落后的代码风格

​ 作为一个精进的程序员,我们要不断地学习“新”的代码风格,改善自己的代码质量,不要故步自封,让自己停留在上一个时代。

​ 坏味道:落后的代码风格

  • 编程规则
    • 声明式编程

--------------------------------不断学习“新”的代码风格,不断改善自己的代码------------------------------

  • 23
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
面向对象编程的核心思想是将现实世界中的事物抽象成对象,然后通过对象之间的交互实现程序的功能。 对于英雄联盟这个游戏来说,我们可以将各个游戏元素抽象成不同的对象,例如: 1. 英雄对象:包含英雄的属性和方法,例如血量、攻击力、技能等。 2. 玩家对象:包含玩家的属性和方法,例如游戏币数量、背包等。 3. 地图对象:包含地图的属性和方法,例如地图大小、地形等。 4. 游戏对象:包含游戏的属性和方法,例如游戏状态、计时器等。 在面向对象编程中,我们通常会使用类来定义对象的属性和方法。因此,我们可以为每个对象定义一个类,例如: ``` class Hero: def __init__(self, name, hp, attack): self.name = name self.hp = hp self.attack = attack def use_skill(self, skill): # 使用技能的逻辑 pass class Player: def __init__(self, name, coins): self.name = name self.coins = coins def buy_item(self, item): # 购买物品的逻辑 pass class Map: def __init__(self, size, terrain): self.size = size self.terrain = terrain def move_hero(self, hero, direction): # 移动英雄的逻辑 pass class Game: def __init__(self): self.status = 'playing' self.timer = 0 def start_game(self): # 游戏开始的逻辑 pass ``` 以上代码只是简单的示例,实际的英雄联盟游戏需要更加复杂的设计和实现。但是,面向对象编程的思想可以帮助我们更好地组织代码,提高代码的可维护性和可扩展性。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值