Unintended Variable Shadowing

Uintended Variable Shadowing中文翻译过来是:非预期变量遮蔽

在讲解什么是非预期变量遮蔽前需要知道什么是变量遮蔽

变量遮蔽: 当内层作用域中定义了一个与外层变量同名的变量,此时在内层作用域中遮蔽了外层变量,此时该外层变量会被暂时的遮蔽了。

非预期变量遮蔽: 由于编写代码的过程中疏忽了导致了外层变量被遮蔽,从而导致程序产生了bug。简而言之就是开发者想要操作外层变量,但是是实际上操作的是内层作用域的变量。

下面展示一个由于不变量的非预期遮蔽从而产生的副作用:

func TestUnintendedVariableShadowing(t *testing.T) {
	var client *http.Client
	tracing := false
	if tracing {
		client, err := createClientWithTracing() //重现定义了client变量,该变量只存在if作用域,与外层作用域的client是两个独立的变量
		if err != nil {
			fmt.Println(err)
		}
		log.Println(client)
	} else {
		client, err := createDefaultClient()
		if err != nil {
			fmt.Println(err)
		}
		log.Println(client)
	}

	useClient(client)
}

func createClientWithTracing() (*http.Client, error) {
	return &http.Client{}, nil
}

func createDefaultClient() (*http.Client, error) {
	return &http.Client{}, nil
}

func useClient(client *http.Client) {
    if client == nil{
        fmt.Println("nil client")
    }else{
        fmt.Println("正确传入client")
    }
}

首先根据上述代码,我们可以发现,在if语句块中client使用了短变量声明运算符,此时的client是全新的变量,我们本来想着是生成tracing client 或者默认client 然后使用该client,但是最后我们发现,useClient传入的参数是nil。

如何修改上述代码,让其外层变量不被遮蔽:

  • if语句块中创建临时变量,然后再语句块尾部将该临时变量赋值给外层的client。
func TestUnintendedVariableShadowing(t *testing.T) {
	var client *http.Client
	tracing := false
	if tracing {
		c, err := createClientWithTracing() //重现定义了client变量,该变量只存在if作用域,与外层作用域的client是两个独立的变量
		if err != nil {
			fmt.Println(err)
		}
		log.Println(c)
		client = c
	} else {
		c, err := createDefaultClient()
		if err != nil {
			fmt.Println(err)
		}
		log.Println(c)
		client = c
	}

	useClient(client)
}

func createClientWithTracing() (*http.Client, error) {
	return &http.Client{}, nil
}

func createDefaultClient() (*http.Client, error) {
	return &http.Client{}, nil
}

func useClient(client *http.Client) {
	if client == nil {
		fmt.Println("nil client")
	} else {
		fmt.Println(client)
	}
}

还有一种解决方案就是类似于上述情况,内部作用域变量是通过函数创建的,那么可以先声明一个error类型的变量,然后使用=操作符而不是:=短变量声明操作符。

func TestUnintendedVariableShadowing(t *testing.T) {
	var client *http.Client
	tracing := false
	var err error
	if tracing {
		client, err = createClientWithTracing() //重现定义了client变量,该变量只存在if作用域,与外层作用域的client是两个独立的变量
		if err != nil {
			fmt.Println(err)
		}
		log.Println(client)
	} else {
		client, err = createDefaultClient()
		if err != nil {
			fmt.Println(err)
		}
		log.Println(client)

	}

	useClient(client)
}

如果使用了第二种方案,可以将没有必要在每一个条件分支进行处理error,因为条件分支判断err逻辑是一样的,这样可以在两个条件分支外层判断该err。

func TestUnintendedVariableShadowing(t *testing.T) {
	var client *http.Client
	tracing := false
	var err error
	if tracing {
		client, err = createClientWithTracing() //重现定义了client变量,该变量只存在if作用域,与外层作用域的client是两个独立的变量

		log.Println(client)
	} else {
		client, err = createDefaultClient()

		log.Println(client)

	}
	if err != nil {
		fmt.Println(err)
	}

	useClient(client)
}

如何避免非预期变量遮蔽

  • 遵循清晰的命名规范,避免同名冲突,

​ 拒绝使用count,data,num等通用的变量名,给变量起【具有描述性】的名字,比如:globalIrderCount,localNewOrderCount,从根源减少不同作用域的同名概率。

  • 利用开发工具和静态检查工具,在开发阶段即使发现遮蔽问题。
  • 避免不必要的变量声明。
Unnecessary nested code

Unnecessary nested code中文: 不必要的嵌套代码

首先我们分析一个丑陋的例子:

// 很丑陋的版本
func join(s1, s2 string, max int) (string, error) {
	if s1 == "" {
		return "", errors.New("s1 is empty")
	} else {
		if s2 == "" {
			return "", errors.New("s2 is empty")
		} else {
			concat, err := concatenate(s1, s2)
			if err != nil {
				return "", err
			} else {
				if len(concat) > max {
					return concat[:max], nil
				} else {
					return concat, nil
				}
			}
		}
	}
}

func concatenate(s1, s2 string) (string, error) {
	return s1 + s2, nil
}

这个例子虽然逻辑上是正确的,但是它涵盖了太多层次的if-else,导致代码阅读起来相对困难,你个良好的代码,是在大致阅读代码的时候就知道这段代码是什么意思,而不是需要一步一步的看。

对于上述代码的优化版本:

// 自行先优化一下
func joinV2(s1, s2 string, max int) (string, error) {
	if s1 == "" || s2 == "" {
		return "", errors.New("s1 or s2 is empty")
	}

	concat, err := concatenate(s1, s2)
	if err != nil {
		return "", err
	}

	if len(concat) > max {
		return concat[:max], nil
	}

	return concat, nil

}

// 100 mistakes 书本中在不涉及改动部分功能下优化版本
func joinV3(s1, s2 string, max int) (string, error) {
	if s1 == "" {
		return "", errors.New("s1 is empty")
	}

	if s2 == "" {
		return "", errors.New("s2 is empty")
	}

	concat, err := concatenate(s1, s2)
	if err != nil {
		return "", err
	}

	if len(concat) > max {
		return concat[:max], nil
	}

	return concat, nil

}

一般来说函数所需的嵌套层次越多,就与但阅读和理解。

接下来我们来看一下,如何避免太多层次的if-else

  • 当一个if语句块返回时,我们应该在所有情况下省略else语句块。

  • 当然也可以走一个相反的路径,比如说if condition {return }那么我们也可以将condition修改为`if !condition{//执行业务逻辑}else {return}。

  • 尽可能将正常执行的代码,对齐到代码的左侧。

    我们分析一下糟糕版本的代码,发现正常执行的代码一直在右侧挪动,如果这个嵌套层次更深,那么正常执行的代码还要继续往右侧移动。所以说我们尽可能将正常执行的逻辑对齐到代码的左侧,这样在阅读代码的过程中,很轻易的知道这段代码到底是要做什么。

misusing-init-functions

滥用初始化函数

Go语言中的init函数

​ 在Go语言中,init函数是一个内置的特殊函数,其主要特征如下:

  • 函数签名固定: func init(){}无参数、无返回值
  • 无法被程序显式调用
  • Go语言运行时系统在程序启动阶段自动执行。

init 执行规则

init函数的执行是在包级全局变量初始化之后,main函数执行之前

// utils.go
package utils

import "fmt"

var Count int

func init() {
	Count = 1
	fmt.Println("我是init函数,我是否在main函数执行之前")
}

func AddCount() {
	Count++
}

func GetCount() int {
	return Count
}


// main.go
package main

import (
	"100-mistakes/code-and-project/utils"
	"fmt"
)

func main() {
	fmt.Println(utils.GetCount())
	utils.AddCount()
	fmt.Println(utils.GetCount())
}


// output:
// 我是init函数,我是否在main函数执行之前
// 1
// 2

从上面的程序运行结果可以看出,init是在main函数之前就执行了。

多个init函数执行的顺序:

  • 同一个源文件下,init的执行顺序为定义顺序
// init.go
package utils

import "fmt"

func init() {
	fmt.Println(1)
}

func init() {
	fmt.Println(2)
}

func init() {
	fmt.Println(3)
}

func init() {
	fmt.Println(4)
}

func init() {
	fmt.Println(5)
}

func init() {
	fmt.Println(6)
}

// 首先不用再main中单独引入该源文件,由于上面的程序中已经引入该包,而init函数的执行过程中是在包导入的时候就执行了
// output:
// 1
// 2
// 3
// 4
// 5
// 6
  • 同一个包内多个源文件: 按照源文件名称的字字母顺序执行。

​ 就按照上述utils包中有两个源文件add.goinit.go,这两个源文件中都定义了一个或者多个init函数,按照字母顺序add.go中的init函数是优先执行,然后再是init.go中的init函数。

  • 不同包之间,按照导入依赖的顺序,被导入的包先执行init然后是当前包的init

init函数的常见使用场景

  • 初始化全局变量

​ 首先我们知道init函数的执行是在包全局变量初始化之后,在main函数之前,那么我们可以在使用全局变量之前将复杂赋值的逻辑放在init函数中,这样在导入包的过程中,全局变量就已经初始化完成了。

  • 注册驱动/插件

Go生态中很多场景依赖init函数注册驱动,比如数据库驱动.

  • 加载配置/初始化资源

    比如程序启动时读取配置文件、初始化数据库连接池

何时应该正确使用init函数

不合理的例子:

package utils

import (
	"database/sql"

	_ "github.com/go-sql-driver/mysql"
)

var DB *sql.DB

// 在init中链接数据库
func init() {
	dsn := "foreverool:010101@tcp(127.0.0.1:3306)/study_mysql?charset=utf8mb4&parseTime=True&loc=Local"
	db, err := sql.Open("mysql", dsn)
	if err != nil {
		panic(err)
	}
	err = db.Ping()
	if err != nil {
		panic(err)
	}
	DB = db
}

为什么上述例子不合理呢?

  • 由于init函数的返回错误,所以在内部我们只能通过panic来展示错误,这样做程序就中止了,如果我们想采取重试机制链接数据库,这样通过init函数没有办法做到。

  • 如果我们在对于该文件进行测试,在测试之前,init函数就已经执行,这可能不是我们想要的,因为我们有可能测试的对象就是连接数据库逻辑是否正确。

  • 最后一个缺点就是将数据库连接池赋值给一个全局变量,而全局变量存在一些严重的缺陷。

    • 包内任何函数都可以修改全局变量
    • 单元测试可能变得更加复杂,因为依赖于全局变量的函数将不再能独立地测试了。

    在大多数情况下下,我们应该优先将变量封装起来,而不是将其设置为全局变量。基于这些原因我们应该之前的初始化操作应该被作为一个普通函数来处理:

    func CreateDB(dsn string) (*sql.DB, error) {
    	db, err := sql.Open("mysql", dsn)
    	if err != nil {
    		return nil, err
    	}
    	err = db.Ping()
    	if err != nil {
    		return nil, err
    	}
    	return db, nil
    }
    

    这样的函数,错误的处理将由调用者来处理,而不是直接panic,并且该函数可以独立的使用测试函数进行测试。

Ad Hoc函数

Ad Hoc函数是指为解决特定的、临时的问题而编写的函数,并不是为了复用性创建的函数。通常只在特定场景下使用。

阅读完本篇文章:

​ 个人觉得:

​ 如果init函数内部可能出现错误处理,那么就不要使用init函数实现该部分内容而是单独定义一个可以被调用者显式调用的函数。

​ 如果init函数实现的功能需要单元测试该部分内容是否正确,那么就不要使用init函数,而是单独定义。

overusing-getters-and-setters

Overusing getters and setters

说起getters 和setters我个人感觉在java中十分常见,由于java是纯面向对象语言,所以经常会将某些字段定义为private然后使用getter 和setter进行操作该priavte字段。但是在Go语言中我在很多源码中并没有看到gettersetter,而是直接操作struct中的字段。除非该字段被设置为当前包可见。

首先说明:

Go不强制使用gettersetter,在标准库中也不强制使用。但是gettersetter具有一些优点:

  • 封装了字段的设置与获取行为,允许在获取或者设置过程中添加新的行为(验证字段,返回计算值等)
  • 隐藏了内部表示

Go语言如何使用gettersetter呢?

​ 如果我们要为balance字段设置gettersetter,那么我们的getter方法应该是Balance()而不是GetBalance()

​ 而setter应该命名为SetBalance

如果我们的gettersetter就只是简单的取值,赋值,那么没有必要为该字段设置gettersetter

只有当我们的gettersetter方法有有意义时才使用,遵循go语言的设计哲学

简单。