🔍

Goの静的解析ツールSleuthを作成しました ʕ◔ϖ◔ʔ

2021/05/15に公開約3,900字

はじめまして、しぶちゃりです。
今回はGoでたまに起きるようなミスを無くすための静的解析ツールについて紹介します。

Goでスライス利用時に起きる問題

Goで開発している方にとってスライスは馴染みの深いものであるかと思います。
加えて、事前にスライスの容量(以下cap)がわかっている場合、makeを利用してcapを事前に確保するでしょう。

makeでは2通りの定義の仕方があります。

package main

func main() {
	s1 := make([]int, 10)
	s2 := make([]int, 0, 10)
}

上記の違いはスライスの長さ(以下len)とcapの確保する量です。
実際に出力した結果がこちらです。

package main

import "fmt"

func main() {
	s1 := make([]int, 10)
	s2 := make([]int, 0, 10)

	fmt.Println("====s1====")
	fmt.Println("len: ", len(s1), " cap: ", cap(s1), "slice: ", s1)
	fmt.Println("==========")

	fmt.Println("====s2====")
	fmt.Println("len: ", len(s2), " cap: ", cap(s2), "slice: ", s2)
	fmt.Println("==========")
}
====s1====
len:  10  cap:  10 slice:  [0 0 0 0 0 0 0 0 0 0]
==========
====s2====
len:  0  cap:  10 slice:  []
==========

このようにmakeの第3引数を省略した場合はlenとcapは等しくなります。また長さを指定している場合はスライスの型の初期値で埋まります。

より細かい定義については以下のリンクを参照してください。

https://blog.golang.org/slices-intro
https://golang.org/pkg/builtin/#append

上記の違いを理解した上でGopherが起こす可能性のある問題に進みます。

appendで発生する初期値を忘れる問題

Goにはスライスに対して要素を追加するappendというメソッドがあります。

func append(s []T, vs ...T) []T

A Tour of Goには以下のように説明があります。

https://go-tour-jp.appspot.com/moretypes/15
上の定義を見てみましょう。 append への最初のパラメータ s は、追加元となる T 型のスライスです。 残りの vs は、追加する T 型の変数群です。

append の戻り値は、追加元のスライスと追加する変数群を合わせたスライスとなります。

もし、元の配列 s が、変数群を追加する際に容量が小さい場合は、より大きいサイズの配列を割り当て直します。 その場合、戻り値となるスライスは、新しい割当先を示すようになります。

先程作成した2つのスライスに対して1から10の数字を入れるためにこのappendを使うと以下のようになります。

package main

import "fmt"

func main() {
	s1 := make([]int, 10)
	s2 := make([]int, 0, 10)

	vv := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}

	for _, v := range vv {
		s1 = append(s1, v)
	}

	for _, v := range vv {
		s2 = append(s2, v)
	}

	fmt.Println("s1: ", s1)
	fmt.Println("s2: ", s2)
}

想定通りですとどちらも結果は以下のようになるでしょう。

s1:  [1 2 3 4 5 6 7 8 9 10]
s2:  [1 2 3 4 5 6 7 8 9 10]

しかし、結果はこのようになります。

s1:  [0 0 0 0 0 0 0 0 0 0 1 2 3 4 5 6 7 8 9 10]
s2:  [1 2 3 4 5 6 7 8 9 10]

これは先程の説明にもあるようにappendはスライスに要素を足すメソッドであるからです。
よってmakeでlenも確保しているs1は初期値が0で埋まっているのでこのような想定外の結果となってしまいます。

もしs1は期待する動作にしたい場合はindexに対して要素を更新するように書く必要があります。

package main

import "fmt"

func main() {
	s1 := make([]int, 10)

	vv := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}

	for i, v := range vv {
		s1[i] = v
	}

	fmt.Println("s1: ", s1)
}
s1:  [1 2 3 4 5 6 7 8 9 10]

期待する結果になりました。

Goには標準機能としてgo vetをはじめとした静的解析機能がありますが上記のコードについては警告を出してくれません。

bash-3.2$ go vet main.go 
bash-3.2$ 

このことからもアプリケーションやライブラリとしては致命的なエラーであるにも関わらず、コンパイルエラーもランタイムエラーも発生しません。
そのため開発者にとっては、このエラーが起きた際にどこで間違えたかというソースコードの確認から行う必要性があります。

そこで今回sleuthという上記の対策を行う静的解析ツールを作成しました。
実際に利用した結果がこちらです。サンプルコードは先程と同様のものを利用します。

package main

import "fmt"

func main() {
	s1 := make([]int, 10)
	s2 := make([]int, 0, 10)

	vv := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}

	for _, v := range vv {
		s1 = append(s1, v)
	}

	for _, v := range vv {
		s2 = append(s2, v)
	}

	fmt.Println("s1: ", s1)
	fmt.Println("s2: ", s2)
}

fish

go vet -vettool=(which sleuth) ./...
go vet -vettool=(which sleuth) main.go
# command-line-arguments
./main.go:12:3: sleuth detects illegal

bash

go vet -vettool=`which sleuth` ./...
bash-3.2$ go vet -vettool=`which sleuth` main.go 
# command-line-arguments
./main.go:12:3: sleuth detects illegal

このようにsleuthでチェックを挟むことでコンパイルとランタイムで気づくことのできなかったエラーに事前に気づくことが可能です。
またファイルと行数もわかるため修正の際にもソースコードを精査せずに特定することが可能です。

CIにも組み込むことが可能です。

CircleCI

- run:
    name: Install sleuth
    command: go get github.com/sivchari/sleuth

- run:
    name: Run sleuth
    command: go vet -vettool=`which sleuth` ./...

GitHub Actions

- name: Install sleuth
    run: go get github.com/sivchari/sleuth

- name: Run sleuth
    run: go vet -vettool=`which sleuth` ./...

いいと思ったらスターや要望をissueやPRで送っていただけると嬉しいです。
以上となります。ありがとうございました!

https://github.com/sivchari/sleuth

Discussion

ログインするとコメントできます