LoginSignup
1

More than 1 year has passed since last update.

Go 1.18 を触ってみた(Generics, Fuzzing, Workspaces)

Last updated at Posted at 2022-04-02

初めに

Go 1.18がリリースされたので触ってみました。
今回の重要なリリースは下記の The Go Blog に掲載しているものだと思ったので、ここに書いてあるものを触ってみました。

主な改善点

上記のページを見ると、今回のバージョンアップで特に注目すべきものは以下になります。

  • Generics
  • Fuzzing
  • Workspaces
  • 20% Performance Improvements

それぞれコードを交えながら、触ってみた感想と解説をしていこうと思います。

Generics

日本語だと「ジェネリクス」と読みます。Javaを触ったことある人にとっては知ってて当たり前のものではないでしょうか。
Generics とは、1つのソースコードで様々なデータ型の処理をできるようにするものです。

The Go Blog には、最も頻繁にリクエストされていた機能だと書いてあります。つまりこの機能は多くの人が待ち望んでいた機能だと言えます。

親切にチュートリアルまで用意してくれています。今回はこのチュートリアルを通じて Generics に触れてみました。
Generics の解説で使用したコードは全てこのリンクが出典元となっています。

基本的な使い方

言葉よりコードを見ていただいたほうが理解しやすいと思います。まずは Generics を使っていない下記のコードを見てください。

// SumInts adds together the values of m.
func SumInts(m map[string]int64) int64 {
	var s int64
	for _, v := range m {
		s += v
	}
	return s
}

// SumFloats adds together the values of m.
func SumFloats(m map[string]float64) float64 {
	var s float64
	for _, v := range m {
		s += v
	}
	return s
}

int64 の合計を出す関数と、float64 の合計を出す関数が書かれています。
型は違いますが2つとも処理の内容は同じです。型が違うだけで同じコードを複数用意するのは面倒です。

Generics ならこの問題を解決してくれます。

// SumIntsOrFloats sums the values of map m. It supports both int64 and float64
// as types for map values.
func SumIntsOrFloats[K comparable, V int64 | float64](m map[K]V) V {
	var s V
	for _, v := range m {
		s += v
	}
	return s
}

この関数だけで int64 にも float64 にも対応可能となっています。
コード内の [K comparable, V int64 | float64] は、type parameter(型パラメータ)と呼びます。これによって、いくつかの型をあらかじめ宣言しておき、宣言した型の引数しか使えない制約を付与することができます。

int64 と float64 の指定は理解できるが、comparable って何?という方もいるでしょう。これはGo言語が用意したインターフェースで、日本語にすると「比較対象になる」という意味になります。

つまり、comparable は==!=のような比較演算子を利用できる全ての型を表しています。 map は key の重複がないように値を保管していくデータ構造なため、比較が可能な値を key にする必要があります。
そのため、comparable というインターフェースが利用されています。

ちなみに、関数内のコードが全ての型パラメータに対して動作しなければコンパイルが通らないそうです。

型推論の利用

Generics には型推論も機能しています。
ただ、関数を作成するときではなく呼び出すときに使えるものっぽいです。

func main() {

	ints := map[string]int64{
		"first":  34,
		"second": 12,
	}

	// 型推論なし
	SumIntsOrFloats[string, int64](ints)
	// 型推論あり
	SumIntsOrFloats(ints)
}

型制約の代わりのインターフェース

前のコードで型制約をする時に、下記のように記述しました。

func SumIntsOrFloats[K comparable, V int64 | float64](m map[K]V) V {
	// ~ 略 ~
}

型制約をする時に、int64 | float64と記述していますが、複雑さの原因になりそうです。
今回の場合は2つなので問題ありませんが、もっと多くの型を指定すると可読性が落ちそうです。

そこで利用できるのがインターフェースです。
インターフェースを利用した場合、下記のようなコードになります。

type Number interface {
	int64 | float64
}

// SumNumbers sums the values of map m. It supports both integers
// and floats as map values.
func SumNumbers[K comparable, V Number](m map[K]V) V {
	var s V
	for _, v := range m {
		s += v
	}
	return s
}

Generics で利用する型の宣言を分離化できるため、複雑性を解消できます。

以上、Generics の紹介でした。
上記のコードは紹介したチュートリアルに全て載っています。
チュートリアルの最後に今回使用した全てのコードが載っているので、コードだけ確認したい方はそちらをご覧ください。

また、この記事により細かく Generics について紹介がされているので、もしよろしければ参考にしてください。

Fuzzing

日本語だと「ファジング」と読みます。
Fuzzing とは、プログラムに意図的に無効なデータ(ファズ)を送り、例外的な状況でも問題なく動くか確かめるソフトウェアテストの一種です。

Go の Fuzzing では下記のことができるようです。

This tutorial introduces the basics of fuzzing in Go. With fuzzing, random data is run against your test in an attempt to find vulnerabilities or crash-causing inputs. Some examples of vulnerabilities that can be found by fuzzing are SQL injection, buffer overflow, denial of service and cross-site scripting attacks.

出典: https://go.dev/doc/tutorial/fuzz

意訳すると、ランダムにデータを走らせて脆弱性やクラッシュの原因となるインプットを見つけると書いてあります。これによってSQLインジェクション、バッファオーバーフロー、サービス拒否攻撃、クロスサイトスクリプティングなどに対する脆弱性を発見できます。

Fuzzing でも親切にチュートリアルを用意してくれています。なので ここもチュートリアルを通じて学習していきました。
これから記載する解説コードは全てこのリンクが出典元となっています。

Fuzzing テストコードの書き方

Fuzzing はテストに使用するため、まずはテスト検証用のコードを用意します。
下記のコードでは文字列を与えたときに順番を逆にする関数を用意しています。

main.go
package main

import "fmt"

// 文字列の順番を逆にする関数
func Reverse(s string) string {
	b := []byte(s)
	for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
		b[i], b[j] = b[j], b[i]
	}
	return string(b)
}

func main() {
	input := "The quick brown fox jumped over the lazy dog"
	rev := Reverse(input)
	doubleRev := Reverse(rev)
	fmt.Printf("original: %q\n", input)
	fmt.Printf("reversed: %q\n", rev)
	fmt.Printf("reversed again: %q\n", doubleRev)
}

Fuzzing を利用する前に、普通の単体テストを使ってみましょう。
作成した関数に対する単体テストは以下になります。

reverse_test.go
package main

import (
	"testing"
)

func TestReverse(t *testing.T) {
	testcases := []struct {
		in, want string
	}{
		{"Hello, world", "dlrow ,olleH"},
		{" ", " "},
		{"!12345", "54321!"},
	}
	for _, tc := range testcases {
		rev := Reverse(tc.in)
		if rev != tc.want {
			t.Errorf("Reverse: %q, want %q", rev, tc.want)
		}
	}
}

上記のコードを用意すれば、変数 testcases に検証したいinputと出力してほしい文字を記載するだけでテストができます。
しかし、考えついたテストケースのみしかテストが行われないため、検証しきれていないケースが存在する可能性があります。
そこで Fuzzing を使い、考えつかなかったテストケースからエッジケースの識別を行います。

Fuzzing を利用したコードは以下になります。
ファイル名は単体テストと同じ *_test.go になります。

reverse_test.go
package main

import (
	"testing"
	"unicode/utf8"
)

func FuzzReverse(f *testing.F) {
	testcases := []string{"Hello, world", " ", "!12345"}
	for _, tc := range testcases {
		f.Add(tc) // Use f.Add to provide a seed corpus
	}
	f.Fuzz(func(t *testing.T, orig string) {
		rev := Reverse(orig)
		doubleRev := Reverse(rev)
		if orig != doubleRev {
			t.Errorf("Before: %q, after: %q", orig, doubleRev)
		}
		if utf8.ValidString(orig) && !utf8.ValidString(rev) {
			t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
		}
	})
}

普通の単体テストと違う点は

  • 関数名が FuzzXxx となっている
  • 引数が *testing.F となっている
  • t.Run ではなく f.Fuzz を実行している
    • f.Fuzz のことをファズターゲットと呼ぶ
    • パラメータは*testing.Tとテストに利用するデータの型を指定した関数。ここにテストの内容を記述していく
    • テストに利用するデータの型の指定(今回の場合 orig string)は、引数名の決まりはなく、型もstring以外のものを指定できる
  • f.Add というものがある。ここで seed corpus(テストに渡すデータセット) を追加
    • f.Fuzz(func(t *testing.T, orig string) を行う前に記述する必要がある。f.Fuzz で指定されている型(このコードの場合はstring型)と異なる型の場合、エラーとなる

上記を踏まえて、今回のコードで何をしているかまとめると以下のようになります。

  • f.Add(tc) によって Hello, world !12345 の3つはテストで行われるようになる。
  • string型のデータをランダムに作成してテストを行う。
  • Reverse関数を行った後にもう一度Reverse関数を行い、元のデータと一致する確かめる。
  • UTF-8にエンコードされているか確かめる

Fuzzing テストを実行してみる

Fuzzing を利用したテストを行う場合、go test -fuzz=Fuzz というコマンドを使えばできます。
このコマンドにより、ランダムに生成された文字列入力が行われ、失敗したら seed corpus file(テストに渡すデータセットのファイル) を生成して失敗したデータを書き込んでくれます。

また、testdata / fuzz / 関数名(今回の場合FuzzReverse) というディレクトリも自動で作成し、FuzzReverseの中に corpus file を入れてくれます。
失敗の原因となった文字列を確認したい場合、このファイルを見れば確認できます。

% go test -fuzz=Fuzz
fuzz: elapsed: 0s, gathering baseline coverage: 0/3 completed
fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing with 12 workers
fuzz: minimizing 33-byte failing input file
fuzz: elapsed: 0s, minimizing
--- FAIL: FuzzReverse (0.08s)
    --- FAIL: FuzzReverse (0.00s)
        reverse_test.go:20: Reverse produced invalid UTF-8 string "\x94\xdf"
    
    Failing input written to testdata/fuzz/FuzzReverse/a430e9f383c554b44a13dbaa6dbc3609ad0c48c4e94923b6845b42b07d9392de
    To re-run:
    go test -run=FuzzReverse/a430e9f383c554b44a13dbaa6dbc3609ad0c48c4e94923b6845b42b07d9392de
FAIL
exit status 1
FAIL    example/fuzz    0.321s

メッセージを確認すると、Reverse をして生み出したものがUTF-8 にとって無効なものだったから失敗したぽいです。

go test -fuzz=Fuzz を行なったあとのディレクトリ構造

.
├── go.mod
├── main.go
├── reverse_test.go
└── testdata
    └── fuzz
        └── FuzzReverse
            └── a430e9f383c554b44a13dbaa6dbc3609ad0c48c4e94923b6845b42b07d9392de

自動生成されたファイル(a430e9f383c554b4...)の中身

go test fuzz v1
string("ߔ")

go test -fuzz=Fuzz でファイルが作成されると、オプションなしで go test を行うときに、そのファイルの中にある文字列もテスト対象にしてくれます。

% go test
--- FAIL: FuzzReverse (0.00s)
    --- FAIL: FuzzReverse/a430e9f383c554b44a13dbaa6dbc3609ad0c48c4e94923b6845b42b07d9392de (0.00s)
        reverse_test.go:20: Reverse produced invalid UTF-8 string "\x94\xdf"
FAIL
exit status 1
FAIL    example/fuzz    0.246s

ちなみに go test -fuzz=Fuzz はテストが失敗しない限り永遠に実行されます。止めるときは ctrl-C を使います。
また、-fuzztime [秒数]s というオプションを使えば、指定した時間の間テストを実行し続け、時間が経ったら終了してくれるようになります。

今回使ったコードに対するデバッグ方法もチュートリアルには載っていますが、本題とは逸れるので割愛します。
気になる方はぜひチェックしてみてください。

また、より詳しい内容を見たい方は、こちらのドキュメントを読んでみてください。

Workspaces

Workspaces を使えば複数のモジュールでの作業が簡単になるとのことです。
ここでもチュートリアルが用意されています。

基本的な使い方

Workspace として扱いたいディレクトリで

go work init [モジュールのパス]

これを行うと go.work というファイルが作成され、モジュールがそのファイルに登録されます。

それでは実際にコードを使って見ていきましょう。
下記のディレクトリ構成で使うとどうなるか見ていきましょう。

.
├── hello
│   ├── go.mod
│   └── hello.go
└── main
    ├── go.mod
    └── main.go
main.go
package main

import "example.com/hello"

func main() {
	hello.Hello()
}
hello.go
package hello

import (
	"fmt"
)

func Hello() {
	fmt.Println("hello")
}

main.gohello.go の関数を参照する状態になっています。
この状態で main.go を実行してもエラーが発生します(mainディレクトリで実行しています。)

% go run main.go
main.go:3:8: no required module provides package example.com/hello; to add it:
        go get example.com/hello

それでは go work initgo.work ファイルを作成し、2つのモジュールパスを登録します

% go work init ./main ./hello
go.work
go 1.18

use (
	./hello
	./main
)

このファイルには、モジュールと Go のバージョンが記述されます。
ここに記載されたモジュールは全てメインモジュールとして扱われます。
モジュールの外部であっても、Workspace 内であればモジュールのパッケージを参照できます。

ではもう一度 main.go を実行してみます(mainディレクトリで実行します)

% go run main.go
hello

無事に成功しました。
このように Workspace化をさせれば他のモジュールを呼び出すことが可能になります。
go.mod ファイルで replace を使うことも可能ですが、Workspace化させたほうが管理が楽になります。

ちなみに go.work ファイルをリポジトリに登録するのは非推奨らしいです。

他のコマンド

今回は go work init のみ使用しましたが、他にもコマンドはあります。

go work use [-r] [dir]

ディレクトリが存在すればworkファイルに追加、存在しなければworkファイルから削除します。
また、-r を付ければサブディレクトリを再帰的に調べます。

go work edit

go.work ファイルを編集します。go mod edit と同じように利用できるとのことです。

go work sync

Workspace のビルドリストを Workspace のモジュールに同期します。
ビルドリストとは、Workspace でビルドを実行するために使用されるすべての依存関係モジュールのバージョンのセットのことです。

詳しい説明は Go Modules Reference に載っているので、ぜひ参照してください。

20% Performance Improvements

この見出しは機能の追加ではなく、パフォーマンスが20%向上したという報告です。
Go 1.17 で導入されたレジスタABI呼び出し規約が Apple M1、ARM64、PowerPC64 に拡張されたことにより、最大20%のCPUパフォーマンス向上となったとのことです。

最後に

以上、go1.18 について見ていきました。
どの改善点も軽く触れた程度なので、使いこなすにはもっと勉強が必要です。
引き続きキャッチアップをしていきたいと思います。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1