錯誤的種類

在講這個之前,先提一下主流語言通常「錯誤檢出」方面通常分為三個階段:

  • 編譯前階段 (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++來講直到conceptC++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)
			}
		})
	}
}

看起來abcdeabcdef都沒啥問題,個數有奇數有偶數,看起來都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寫法,不過快點把舊寫法轉換成新的吧。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。