錯誤的種類
在講這個之前,先提一下主流語言通常「錯誤檢出」方面通常分為三個階段:
- 編譯前階段 (pre-compile time)
- 指的是這個錯誤不需要被編譯即可被檢出
- 很多語言沒有這東西,他需要language server protocol(LSP)支援,如
gopls
- 很多IDE各語言的賣點就是自己獨到的LSP,但是剛好拿來做利子的
C++
擁有非常混亂的LSP實作,常常pre-compile time報錯,但是compile下去沒問題,以及反之….主要也是因為C++
實在是過於複雜。
- 編譯階段 (compile time)
- 指的是這個錯誤在編譯的時候就可以找出來
- 前兩者也可以並稱為compile time,如果沒有要特別指名LSP提供的功能的話。
- 執行階段 (runtime)
- 指的是這個錯誤需要在執行期才會發作
而go是一個滿特殊的語言,他能夠把一些runtime才能檢出的東西藉由gopls
以及go vet
做結構性語法檢查下,將錯誤把runtime提前到pre-compile time。
type A string
以及type A = string
的差異
在go source code裡面,any其實就是interface{}
:
type any = interface{}
很多人會有個疑問,這跟這以下的code有啥不同
type any interface{}
我們可以用下面這組code看出差異在哪
type Foo int
type Foo2 = int
func (f *Foo) Method() { //這個沒問題
}
func (f2 *Foo2) Method() { //這行編譯器就會報錯了
}
簡單的說,第一種寫法是宣告Foo基底型態為int,且可以擴展,第二種寫法是Foo就是int的別稱,兩者意義上是不同的。(印象中)這種寫法的差異也是1.18版才開始有的。
Constraints
要熟悉Generic,首先要先理解constraints
這個新概念。如果有寫過CPP的同學應該對以下這段code很熟悉:
template <class T>
int compute_length(T *value)
{
return value->length();
}
而C++
來講直到concept
在C++20
版本出現前,是沒有constraint的,即使是concept
也是用一種比較截然不同的隱晦方法來做type constraint
,這點先表過不談。在沒constraint的情況下,這段code其實在編譯的期間不會檢查,只有在真正實體化用他的時候才會做編譯檢查,如:
class someClass1 {
}; //沒有int length()方法
class someClass2 {
int length()
};
sc1 = new someClass1();
sc2 = new someClass2();
compute_length(sc1); //編譯這行的時候會出錯並且噴出一堆看不懂的error
compute_length(sc2); //沒問題
那同樣的東西在golang會是這樣
type Countable interface {
length() int
}
func computeLength[T Countable](value *T) int {
return value.length()
}
而這個會在編譯期就會檢查目標有沒有satisfy interface,如
type someStruct1 struct {} //不satisify Countable
type someStruct2 struct {} //Satisify Countable
func (s *someStruct2) length() int {
return 0
}
computeLength(&someStruct1{}) //pre-compile time error
computeLength(&someStruct2{}) //OK!
當然,所有的struct都satisfy interface{}
,下面會提到。
最簡單的Generic
大多數情況來講,最常見的就是宣告constraint為any:
func GenericFunc[T any](input T) T {
return input
}
這邊的any
其實就是大家的老相好interface{}
的馬甲:
// in builtin.go:
// any is an alias for interface{} and is equivalent to interface{} in all ways.
type any = interface{}
前面有提過這個等號是幹嘛的,所以這個any一整個其實就是 interface{}
的別稱。
Primitive type constraints
再來就是套constraint的generic,基本上constraint可以是primitive type也可以是interface{}
,而且是Union。先從primitive type看起
func GF[T int](){
}
這個是最簡單的,後面還可以有些變化如
func GF[T int | int8 | int16 | int32 | int64](){
}
甚至還可以把constraint union寫成一種type,如
type IntConstraint interface {
int | int8 | int16 | int32 | int64
}
func GF[T IntConstraint]() {
}
事實上目前有一個package叫做golang.org/x/exp/constraints
裡面有滿多實用的constraints。不過使用X exp package是有風險的(下面會舉一個活生生的例子)
// Copyright 2021 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package constraints defines a set of useful constraints to be used
// with type parameters.
package constraints
// Signed is a constraint that permits any signed integer type.
// If future releases of Go add new predeclared signed integer types,
// this constraint will be modified to include them.
type Signed interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
// Unsigned is a constraint that permits any unsigned integer type.
// If future releases of Go add new predeclared unsigned integer types,
// this constraint will be modified to include them.
type Unsigned interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}
// Integer is a constraint that permits any integer type.
// If future releases of Go add new predeclared integer types,
// this constraint will be modified to include them.
type Integer interface {
Signed | Unsigned
}
// Float is a constraint that permits any floating-point type.
// If future releases of Go add new predeclared floating-point types,
// this constraint will be modified to include them.
type Float interface {
~float32 | ~float64
}
// Complex is a constraint that permits any complex numeric type.
// If future releases of Go add new predeclared complex numeric types,
// this constraint will be modified to include them.
type Complex interface {
~complex64 | ~complex128
}
// Ordered is a constraint that permits any ordered type: any type
// that supports the operators < <= >= >.
// If future releases of Go add new ordered types,
// this constraint will be modified to include them.
type Ordered interface {
Integer | Float | ~string
}
關於 ~
是啥意思可以參考這一篇。簡單的說就是讓他適用於所有衍生型別。衍生型別就是我前面提到的
type A string
那constraint要是打 ~string
的話,A
也算是合乎條件。
更多關於X exp package以及RC Release的風險,以及為啥這個方便包被丟進去,可以看這一篇的說明。另外再強調一次,有風險的僅止於X EXP Package,其他的X Package都可以安心使用。
interface type constraint
考慮一下以下的code。
type TypeA interface {
A()
}
type TypeB interface {
B()
C()
}
func GF1[T TypeA](t T) { //OK!
t.A()
}
func GF2[T TypeB](t T) { //OK!
t.C()
t.B()
}
func GF3[T TypeA | TypeB](t T){ //Error: can't use interface with methods in union
}
即使是兩個有共享的Method也不行
type TypeA interface {
A()
B()
}
type TypeB interface {
B()
C()
}
func GF3[T TypeA | TypeB](t T){ //Error: can't use interface with methods in union
}
這要用一種很特殊的解法。先把TypeA/TypeB宣告成struct,分別作出method後,把他放在constraint union裡面宣告
type TypeA struct {
//有以下實作
//A()
//B()
//Other()
}
type TypeB struct {
//有以下實作
//B()
//C()
//Other()
}
type TypeAB interface {
*TypeA|*TypeB
B()
}
func GF3[T TypeAB](t T) {
t.B() //就僅有B能用,即使是Other()也是共通,但是沒宣告故無法使用
}
不過老實講在1.18裡面generic/constraint語法並沒有很完善的情況下,除非真的有什麼不得了的用途非得這樣玩不可,我會寧可放棄generic來寫兩組code分別對應TypeA
以及TypeB
…
有興趣的同學可以讀讀這一篇,裡面作者有非常詳細的解釋他的原理。
實例:不同型態的slice之間互轉
我們先假設一下我們有個type長這樣 (沒辦法,golang的enum就是不搞好)
type DeployTarget string
const (
DeployTargetDev DeployTarget = "dev"
DeployTargetQA DeployTarget = "qa"
DeployTargetProd DeployTarget = "prod"
)
我們需要把這個東西當作query parameter傳給api端,比較常見的寫法就是
targets := []DeployTarget{DeployTargetDev, DeployTargetQA, DeployTargetProd}
values := url.Values{
DeployTargetQueryParamKey: targets, //報錯,他只吃[]string
}
//然後url往下傳
顯然,我們需要一個方法能夠把[]DeployTarget
轉成[]string
。真的要特地為DeployTarget
寫一個轉換slice也不難,但是這種case一多,總會希望要轉乘Generic。
中間思路就不寫了,直接說結果。其實用熟了不自己想出來(我也是在做SDK過程中花十分鐘拼出來的)
type Castable[T any] interface {
CastTo() T
}
//讓DeployTarget能confort Castable interface
func (d DeployTarget) CastTo() string {
return string(d)
}
//要讓DeployTarget能Cast成string,所以T為DeployTarget, U為string
func CastSlice[T Castable[U], U any](from []T) []U {
to := make([]U, len(from))
for i, v := range from {
to[i] = v.CastTo()
}
return to
}
//使用方法
func TestFunc(targets []DeployTarget) {
values := url.Values{
DeployTargetQueryParamKey: CastSlice[DeployTarget, string](targets),
}
}
當然不見得要轉string
,你只要implement CastTo() T
你要轉成int也成… 希望這個能讓你們覺得「哇靠,居然能這樣用,generic包generic!」。
限制
有receiver的func (即func (s *someStruct) foo()
之類,或稱method)無法使用generic。這個限制其時是有點爭論的,很多人怕要是開放這種東西,將會導致golang重演CPP的template惡夢。如果你不想要看到一堆什麼偏特化啊,指定特化啊在go的未來跟你招手的話,這是一個很好的參考。
不過對我這個習慣STL的人來講,這個總覺得有點….隔靴搔癢的feel。
slices / maps的支援
請注意,這兩個哥倆好package在RC才剛過沒多久,就在正式版裡面被丟到X exp Packages了,use with caution
附帶一提,其實這兩個package都沒啥問題,之所以被打進exp package是因為他引用了golang.org/x/exp/constraints,算是有夠衰小。
裡面其實就是一些我們平常就會寫的utility。比方說在沒有generic以前,我們要找某個string在slice裡面的index大概都會這樣寫
func Index(s []string, v string) int {
for i, vs := range s {
if v == vs {
return i
}
}
return -1
}
func main() {
s := []string{"a", "b", "c"}
fmt.Println(Index(s, "b"))
}
這兩個包大概率都是幫你用這些看起來樸實無華且實用的方法,以generic幫你實現而已:
func Index[E comparable](s []E, v E) int {
for i, vs := range s {
if v == vs {
return i
}
}
return -1
}
然後讓你可以這樣用
func main() {
s := []string{"a", "b", "c"}
fmt.Println(slices.Index(s, "b"))
}
可以參考一下這兩個包有哪些工具能用
但是尷尬的是他們是X exp package,所以啥時會被放回去也不知道,所以….好吧,就是尷尬。
Fuzz Test
Fuzz Test就是模糊測試,簡單的說就是利用亂數輸入測試參數跑很多次,來確定結果是正確的。這可以測出如boundary overflow等普通測試不容易測出的問題。
Fuzz Test基本上都是跟業務邏輯高度相關,滿難寫的 XD 所以我直接從網路上抄一個。假設我們有一個函數,他要把字串給倒轉:
func Rev(s string) string {
bs := []byte(s)
length := len(bs)
for i := 0; i < length/2; i++ {
bs[i], bs[length-i-1] = bs[length-i-1], bs[i]
}
return string(bs)
}
先不管這邏輯有沒有瑕疵(我們就是要測出瑕疵啊),這個函數看起來是沒問題的。比方說我們寫一個標準table test
func TestRev(t *testing.T) {
type args struct {
s string
}
tests := []struct {
name string
args args
want string
}{
{
name: "Happy Path1",
args: args{
s: "abcde",
},
want: "edcba",
},
{
name: "Happy Path2",
args: args{
s: "abcdef",
},
want: "fedcba",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Rev(tt.args.s); got != tt.want {
t.Errorf("Rev() = %v, want %v", got, tt.want)
}
})
}
}
看起來abcde
跟abcdef
都沒啥問題,個數有奇數有偶數,看起來都cover到啦,這個Rev()
看來OK!過關!
然而我們用Fuzz Test測一次…
func FuzzRev(f *testing.F) {
str_slice := []string{"abc", "bb"}
for _, v := range str_slice {
f.Add(v)
}
f.Fuzz(func(t *testing.T, str string) {
//基本原理就是,理論上你兩次反轉以後,應該要轉回原本的字串。
rev_str1 := Rev(str)
rev_str2 := Rev(rev_str1)
if str != rev_str2 {
t.Errorf("fuzz test failed. str:%s, rev_str1:%s, rev_str2:%s", str, rev_str1, rev_str2)
}
if utf8.ValidString(str) && !utf8.ValidString(rev_str1) {
t.Errorf("reverse result is not utf8. str:%s, len: %d, rev_str1:%s", str, len(str), rev_str1)
}
})
}
誒,報錯了,我們看一下
fuzz: elapsed: 0s, gathering baseline coverage: 2/5 completed
--- FAIL: FuzzRev (0.03s)
--- FAIL: FuzzRev (0.00s)
fuzz_test.go:20: reverse result is not utf8. str:0ؔ, len: 3, rev_str1:��0
恩,在輸入為0ؔ,
的時候這個會有問題。如果要更深層次追究的話,就要把這組hex印出來(hint:轉成rune
)看看怎麼回事了。不過可以先說結論,就是生成了某個測資非ascii字串,反轉後就得到一個非UTF-8字串(理論上ascii跟UTF-8,後者應該要完美包含前者)。
這就是fuzz要做的事情。
升級要避免的雷
安裝binary不能再用go get -u
還有強制 @latest,用go get -u
僅會安裝package source而不會安裝binary
如果使用RC版本,很可能正式包的東西會被移到X Package(反之亦然)
我們碰到的就有slices包從RC直接被移到X Package的包以至於編譯炸掉(雖然還滿好修的)
如果沒指定版本的話,CICD可能會因為這原因失敗
之前碰過的問題,沒指定版本之前會默認 @latest
,但是現在沒指定版本的話有時候會抓到很奇怪的版本… 好像跟release tag與否有關,但是我不太確定。
Build Tag語法的更改
這個倒是還好,就是把舊的// +build <tag>
annotation改成go標準annotation //go:build <tag>
而已。目前直到1.19都還相容舊的tag寫法,不過快點把舊寫法轉換成新的吧。