Go语言设计的一些优点及缺陷
Go 语言的设计哲学强调简洁和效率。部分特性从代码工程最终的结果来看(静态),确实简洁舒服,然而,从开发过程(动态)来看,难道不会加大开发成本吗?!
Go把项目当成艺术品进行雕磨,然而,它似乎不太关心程序员的生产力。
以下观点仅代表个人意见,不喜勿喷😃
1.优秀设计
1.1.统一格式化
在 Go 语言中,gofmt
是官方提供的代码格式化工具,它能够自动格式化 Go 源代码,使得代码风格保持一致。
这是非常有趣和创新的,因为格式和问题,如制表符与空格或“我应该把花括号放在循环定义的同一行还是下一行”,都是浪费时间。
语言创造者定义了规则,每个人都使用这些规则。
这对于拥有大型团队的项目非常有用。
1.2.唯一的循环关键字
在Java编程语言,循环至少有for,do while ,while,foreach等不同的方式。
而在Go,它只提供唯一的选择,那就是for
// 基本循环
for i := 0; i < 10; i++ {
// 循环体
}
// 死循环
for {
// 循环体
}
// 条件循环
i := 0
for i < 10 {
// 循环体
i++
}
// 标签循环
outerLoop:
for i := 0; i < 3; i++ {
for j := 0; j < 3; j++ {
if i == 1 && j == 1 {
continue outerLoop // 跳过外层循环的剩余部分,开始下一次迭代
}
// 循环体
}
}
1.3.接口使用“鸭子类型”
Go 语言并不支持传统的面向对象编程(OOP)范式,如类(class)和继承(inheritance)。然而,Go 语言提供了一种类似于“鸭子类型”(duck typing)的方式,这种方式侧重于对象的行为而不是它们的类型。在 Go 中,你可以通过接口(interface)来实现类似面向对象的特性。
鸭子类型(Duck Typing)
鸭子类型是一种编程范式,其中对象的有效性不是由对象的类型决定的,而是由对象的方法集合决定的。换句话说,如果它像鸭子一样走路,像鸭子一样叫,那么它就可以被认为是鸭子。
接口(Interface)在 Go 中的作用
在 Go 中,接口是一种定义了一组方法签名的类型。任何具有这些方法的类型都实现了接口,而不需要显式声明。这允许你创建具有不同类型但共享相同方法集的对象。
当然,该机制也有一些缺陷,例如:
当接口新增方法时,代码编辑器并不一定会报错,实现类可能不再是实现类。Java的话,接口新增方法,子类会编译报错。幸运的是,goland之类的IDE提供了自动在接口及其实现类添加方法的工具。
再比如,在java里,接口除了表示具有某些行为的抽象,还可以作为标记,只有名称没有方法,例如常见的序列化接口。这在Go里就不存在了!
public interface Serializable {
}
1.4.函数支持多重返回值
Go函数允许返回多个不同类型的值,类似
func operations(a int, b int) (int, int) {
return a + b, a - b
}
虽然这不是Go的初创(Lua也支持,不考虑java,python这种以对象,元组模拟的多重返回值),但确定好用。但如果告诉你,Go设计这种机制,主要是为了它的异常处理机制,那我还是选择,不要这种特性!
1.5.函数返回值可命名
在 Go 语言中,返回值可以被命名,这通常用于提供更清晰的错误处理和更有意义的变量名。当你给返回值命名时,你可以在函数内部直接通过这些名字来引用它们,而不是使用临时变量。
以下是一个简单的例子,展示了如何在 Go 中命名返回值:
func divide(a, b float64) (result float64, err error) {
if b == 0 {
// 直接使用命名的返回值
err = fmt.Errorf("cannot divide by zero")
return // 没有提供 result 的值,编译器会使用 result 的类型零值
}
result = a / b
return // 提供了 result 的值
}
1.6.元组赋值
元组赋值是一种特殊的赋值语句,它允许同时更新多个变量的值(Python也支持该模式)。在赋值之前,赋值语句右边的所有表达式将会先进行求值,然后再统一更新左边对应变量的值。这对于处理有些同时出现在元组赋值语句左右两边的变量很有帮助,例如我们可以这样交换两个变量的值:
x, y = y, x
计算计算斐波纳契数列
func fib(n int) int {
x, y := 0, 1
for i := 0; i < n; i++ {
x, y = y, x+y
}
return x
}
1.7.集成单元测试与基准测试
Go集成单元测试,并采用“约定胜于配置”的更理念。在包目录内,所有以_test.go
为后缀名的源文件表示测试文件。
在*_test.go
文件中,有三种类型的函数:测试函数、基准测试(benchmark)函数、示例函数。测试函数是以Test为函数名前缀的函数,用于测试程序的一些逻辑行为是否正确;go test命令会调用这些测试函数并报告测试结果是PASS或FAIL。
基准测试函数是以Benchmark为函数名前缀的函数,它们用于衡量一些函数的性能;go test命令会多次运行基准测试函数以计算一个平均的执行时间。
示例函数是以Example为函数名前缀的函数,提供一个由编译器保证正确性的示例文档。
有一点不好的地方在于,go将测试文件与业务文件放在同一个包里,虽然在执行go build时不会被构建成包的一部分,但仍显得比较混乱。而java将main与test放在不同的目录,并规定了独立的运行环境。
另外,java使用单元测试需要引入junit,使用基准测试需要引入jmh。
2.缺陷设计(仁人见仁,智者见智)
2.1.禁止申明未使用的变量
-
限制灵活性:开发者可能希望保留某些变量以备将来使用,而不必立即删除它们
-
增加工作量:开发中需要频繁地添加和删除变量,增加不必要的工作量
-
调试困难:在调试过程中,需要添加一些临时变量,调试后需要删除
2.2.包范围命名空间
在同一个包内,即使处于不同的文件,我们也无法申明两个名字相同的变量。
为了避免这个问题,我们不得不把变量的名称弄长点,增加一些前缀来表示名称的上下文。这无疑增加了我们起名的难度,毕竟,起个好名不是一件简单的事!
2.3.通过返回值表示异常
这绝对是社区争吵最激烈,最多人诟病的一项特性了。难以想象,要在外层捕捉一个底层的错误返回值,你不得不写出下面的代码(这种机制真的提高开发人员的工作效率了,这里需要打个问号)
package main
import (
"fmt"
)
func divide(dividend, divisor float64) (float64, error) {
if divisor == 0 {
return 0, fmt.Errorf("除数不能为0")
}
return dividend / divisor, nil
}
func middleFun(dividend, divisor float64) (float64, error) {
result, err := divide(dividend, divisor)
if err != nil {
return result, err
}
// do sth
return result, nil
}
func outerFun(dividend, divisor float64) (float64, error) {
result, err := middleFun(dividend, divisor)
if err != nil {
return result, err
}
// do sth
return result, nil
}
func main() {
result, err := outerFun(10, 3)
if err != nil {
fmt.Printf("发生错误: %v\n", err)
return
}
fmt.Printf("结果: %v\n", result)
}
2.4.打包体积较大
使用java的打包插件,是可以将业务代码与依赖分离的。因为生产环境一般依赖包的变动很少,只传输业务代码可以提高传输速度。另一方面,如果同一台机器部署多个节点,隔离的依赖包还可以被重用,减少硬盘使用。
而go build命令打出的二进制包,会把全部依赖都包含进去,打成一个”胖包“。这也是一个弊端吧!
2.5.不支持重入锁
在java里,锁是支持重入的,只要是同一个线程,允许多次获得同一个锁对象,只要保证解锁也是一样次数即可。下面的代码没有任何问题
public static void main(String[] args) throws Exception {
Lock l = new ReentrantLock();
for (int i = 0; i < 100; i++) {
l.lock();
}
for (int i = 0; i < 100; i++) {
l.unlock();
}
}
而在Go,下面的代码却是无法执行!
package main
import "sync"
var mu sync.Mutex
func main() {
mu.Lock()
mu.Lock()
}
直接红牌出局
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [sync.Mutex.Lock]:
sync.runtime_SemacquireMutex(0xc000051f40?, 0x9d?, 0x0?)
D:/Program Files/Go/src/runtime/sema.go:95 +0x25
sync.(*Mutex).lockSlow(0xc08ca8)
D:/Program Files/Go/src/sync/mutex.go:173 +0x15d
sync.(*Mutex).Lock(...)
D:/Program Files/Go/src/sync/mutex.go:92
main.main()
2.6.通过大小写表示访问权限
在java里,private表示只能由当前类内部访问,而public表示类外部可以访问(不考虑jdk9的模块化)
而在 Go 语言中,变量的可见性(或者说是访问级别)是由变量名的首字母的大小写决定的:
- 小写字母开头的变量:这样的变量是包私有的(package-private),它们只能在定义它们的包内部访问。
- 大写字母开头的变量:这样的变量是公开的(public),它们可以在其他包中访问。
这个规则同样适用于函数、结构体、接口、类型等其他标识符。
乍看起来这促进了简洁性,但随着时间的推移,这种模式的缺点比优点更明显,看下面的例子,类型被变量遮蔽了, 这~~~
// 申明一个包内可见的结构体,小写字母开关
type user struct {
name string
}
func main() {
// 申明一个局部变量,变量与类型同名,这没毛病
user := &user{name: "gforgame"}
// 申明另一个局部变量,这里的类型被上一个局部变量遮蔽了,导致编译失败
user2 := &user{name: "jforgame"}
}
2.7.循环变量作用域的陷阱
在下面的程序中,for循环语句引入了新的词法块,循环变量i在这个词法块中被声明。在该循环中生成的所有函数值都共享相同的循环变量。然而,函数中记录的是循环变量的内存地址,而不是循环变量某一时刻的值。以i为例,后续的迭代会不断更新i的值,可能导致不同的goroutine获取到同样的变量。
package main
import (
"fmt"
"time"
)
func main() {
for i := 0; i < 500; i++ {
go func() {
time.Sleep(1 * time.Second)
fmt.Println(i)
}()
}
time.Sleep(10 * time.Second) // 等待所有 goroutine 完成
}
该问题的解决方案大致有以下两种:
func main() {
for i := 0; i < 500; i++ {
// 申明一个局部变量,该变量的作用域只有本次循环
index := i
go func() {
time.Sleep(1 * time.Second)
fmt.Println(index)
}()
}
time.Sleep(10 * time.Second) // 等待所有 goroutine 完成
}
func main() {
for i := 0; i < 500; i++ {
// 将i当做匿名函数的参数传递
go func(index int) {
time.Sleep(1 * time.Second)
fmt.Println(index)
}(i)
}
time.Sleep(10 * time.Second) // 等待所有 goroutine 完成
}
java虽然也有同样的问题,但编译器会直接告诉你无法编译