今川館

都内勤務の地味OLです

Goの乱数&time.Durationの算術演算

ランダムに数百ミリ秒スリープしたい

Goで乱数とスリープを組み合わせた処理を書こうとして苦しんだ。

やりたいことは以下なのだが、

  • 100〜900ミリ秒スリープする処理を5回繰り返す
  • 但し、同じ秒数の待機は一度しか許さない(一回100ミリ秒待ったら、もはや100ミリ秒待ってはいけない)
  • 5回繰り返した累積の待機秒数を最後に標準出力に出す。

これは思ったより難しかった。

サンプルコード

まず最初にサンプルコードを示して、ハマりポイントを解説したい。
紆余曲折を経て、以下のコードで期待通りの動きをしてくれた。

package main

import (
	"fmt"
	"math/rand"
	"time"
)

func main() {
	var acc, delay time.Duration
	rand.Seed(time.Now().UnixNano()) // ハマりポイント1
	choices := rand.Perm(9)

	for _, x := range choices[:5] {
		// ハマりポイント2
		delay = time.Duration((x+1)*100) * time.Millisecond
		acc += delay
		time.Sleep(delay)
	}
	fmt.Printf("合計: %v", acc)
}

ハマりポイント1: ランダムにならない(乱数シードの初期化忘れ)

このプログラムを取り敢えず動く状態にして何度か実行していたら、すべて結果が同じ秒数になってしまうことに気づいた。
これは、乱数のシードを適切に初期化しなかったことが原因らしい(以下の記事を参照)。

Random Seed って言葉を初めて知った! | ちょまど帳

こちらはコメント欄が重要
Goで乱数 - Qiita

乱数シードはナノ秒の方が良さそうです。
(1秒以内に実行されたら、同じ結果になる)

サンプルコードの通り、rand.Seed(time.Now().UnixNano())したらちゃんと結果がばらけてくれた。

ハマりポイント2: time.Durationをうまく扱えなくて苦戦

既に他の記事に書いたことだが、time.Sleepは引数にtime.Durationを取る。

これの算術演算に苦戦した。

delay = time.Duration(100 * (x+1) * time.Millisecond)

これができないので、以下の通り、先にtime.Duration(100 * x)してからtime.Millisecondと乗算する。

time.Duration((x+1)*100) * time.Millisecond

どうも、数値のリテラルと掛け算する場合は暗黙にtime.Duration同士に型を推測してくれるようだが、リテラルではなく変数だとダメみたいだ。

当然、変数accもtime.Durationで宣言しておかないとダメ。

var acc int
var delay time.Duration

(中略)
acc += delay
// これはコンパイルエラー
// invalid operation: acc += delay (mismatched types int and time.Duration)

両方ともtime.Durationで宣言しておくこと。
もしaccを整数として扱いたい場合は、型をキャストして使う。以下はOK。

fmt.Printf("合計: %v, 整数-> %v", acc, int(acc)+1000) // 合計: 2.8s, 整数-> 2800001000

Goでスリープしようとしてハマった

Pythonでスリープするときは

import time

time.sleep(1)  # 1秒待つ
time.sleep(0.5)  # 0.5秒待つ

こうすれば良いが、同じ感覚でGoでスリープしようとしたらうまくいかなかった。

import "time"

time.Sleep(1) // 1秒待つ(?)

どうなるかというと、やけに早く戻ってきてしまうのである。

ちなみに、こっちをやるとコンパイルエラーになってしまう。

time.Sleep(0.5)
// constant 0.5 truncated to integer

Goのtime.Sleepの引数はDurationという型

https://golang.org/pkg/time/#Sleep

golangのドキュメントを見ると、time.Sleepの引数にはtime.Durationという型を受け付ける。

そして、time.Durationはint64の別名型らしい。

type Duration int64

A Duration represents the elapsed time between two instants as an int64 nanosecond count.

しかもナノ秒が基礎なので、time.Sleep(1)とやってしまうとたった1ナノ秒しか待たないことになる。
どうりで思ったより早く戻ってくるわけだ。

正式なN秒スリープの仕方

N秒スリープするのに正式も自己流も無いと思うが、以下の書き方が安定的である。

time.Sleep(1 * time.Second) // 1秒待つ

先ほど書いた通り、0.5秒待つときにこれはできない。

time.Sleep(0.5 * time.Second)  // これはエラー(floatと掛け算できない) constant 0.5 truncated to integer

こうすれば動く。

time.Sleep(500 * time.Millisecond) // 0.5秒待つときはMillisecondと乗算する

ちなみにこれも動く。

var s time.Duration
s = 5
time.Sleep(100 * s * time.Millisecond)

しかし、こっちは動かない。int64が掛け算の式に混入するのがダメらしい。

var s int64 // この他、float64もintも試したがダメだった
s = 5
time.Sleep(100 * s * time.Millisecond)
// invalid operation: 100 * s * time.Millisecond (mismatched types int64 and time.Duration)

どうしてもintと掛け算したい場合はこのように一度整数からDurationを作ってから掛け算する。

var t int
t = 5
time.Sleep(time.Duration(t*100) * time.Millisecond)
fmt.Println("Hello, world.")

なぜ、こんなことを調べ始めたかというと、「ランダムに数百ミリ秒待つ」という処理を書こうとしてハマったからである。

(続く)

Visual Studio Codeを使ってGoのコードをデバッグする

Goのデバッガ

Goでデバッガを使いたい場合、gdb, delveといったツールは初心者には操作が難しい。
(コマンドラインからbとかlとかpとか入力して、ブレークポイントの設定からやらなければならないので、わたしには厳しかった)

しかし、Visual Studio CodeのGo拡張機能が無料で利用可能なので、これを使うと簡単にデバッグできる。

インストールが必要なもの

以下3つインストールが必要。

  1. Visual Studio Code
  2. Visual Studio CodeのGo拡張機能
  3. delve https://github.com/derekparker/delve

Visuao Studio CodeのGoの拡張機能が裏でdelveを操作することでGUIデバッグ機能を提供しているので、delveも入れる必要があることに注意。

前提条件

  • 今回はUbuntu16.04の64ビットデスクトップ版で実施
  • GOPATHは$HOME/_go とした (わたしのパソコンでは/home/echizen/_go)
  • $HOME/_go/src/github.com/{ユーザー名}/ 配下に自分のリポジトリをcloneする

インストール手順 (Ubuntu16.04 64ビットデスクトップ版で実施)

Visual Studio Codeをダウンロードします。

$ firefox https://code.visualstudio.com/download

f:id:imagawa_yakata:20161209132833p:plain

「.deb」のリンクを押します。
(32ビットのパソコンを使っている人は「32bit versions」のページでやって下さい)

そうすると「code_チョメチョメ.deb」のダウンロードの許可を求めるポップアップウインドウが開きます。

f:id:imagawa_yakata:20161209132836p:plain

ここで「ファイルを保存する」を選びます。
上記debファイルが所定の場所にダウンロードされます。
(わたしの場合は「ダウンロード」配下にファイルが保存されました)

ダウンロードしたdebファイルをインストールします。

まず、debファイルを保存した場所へ移動します。

$ cd ~/ダウンロード

インストールコマンドを実行します。

$ sudo dpkg -i code_1.7.2-1479766213_amd64.deb

f:id:imagawa_yakata:20161209132838p:plain

Visual Studio Codeが起動できることを確かめます。

$ code

f:id:imagawa_yakata:20161209132841p:plain

こんな画面が開いたらokです。

Goの拡張機能をインストールします。
これが無いと画面からブレークポイントを設定し、デバッグできません。

Visual Studio Codeの画面左側最下の「拡張機能」のアイコンを押します。

検索ボックスに「Go」と入力すると「Go/Rich Go language support for Visual Studio Code」という候補が出てくるので、それの「インストール」を押します。

f:id:imagawa_yakata:20161209132850p:plain

Goのプロジェクトを用意します。

$ export GOPATH=/home/echizen/_go

$ mkdir -p /home/echizen/_go/src/github.com/oyakata/
$ cd /home/echizen/_go/src/github.com/oyakata/
$ git clone git@github.com:oyakata/tokyo.git
$ cd tokyo

delveをインストールします。

https://github.com/derekparker/delve/blob/master/Documentation/installation/linux/install.md

このページの説明に従って、作業を行います。
(今回はgo getでやりました)

$ go get github.com/derekparker/delve/cmd/dlv

(プロンプトが返ってくるまで少し時間がかかります)

ここまで成功すればVisual Studio Codeの画面からデバッグ可能です。

実際にデバッグをやってみます。

Visual Studio Codeの画面を開きます。
(既に動作確認で開いた画面は一度閉じて、開き直しておいた方が良いでしょう)

$ code

画面左最上のファイルのアイコン「エクスプローラー」を押して、「フォルダーを開く」ボタンを押して、プロジェクトディレクトリを開きます。

f:id:imagawa_yakata:20161209132844p:plain

先ほど用意したプロジェクトを選択し、「OK」を押します。

f:id:imagawa_yakata:20161209132847p:plain

すると、Visual Studio Codeの画面の左側にプロジェクトの構成が読み込まれるので、デバッグしたいファイルを開いて行番号の隣の赤い●を押すとブレークポイントを設定できます。

f:id:imagawa_yakata:20161209132852p:plain

画面左の下から2番め「デバッグ」アイコンを押すと、デバッグ実行できます。

初期状態だと「構成がありません」と表示されるので、赤い●が付いている歯車アイコンを押して「Go」を選択して下さい。

f:id:imagawa_yakata:20161209132854p:plain

f:id:imagawa_yakata:20161209132856p:plain

あとは、デバッグの右にある▶を押せばデバッグ機能が使えます。

f:id:imagawa_yakata:20161209132859p:plain

以上

Goでユニットテストの書き方② 実践編

ユニットテストで守るべきルール

ふつうのユニットテストのための7つのルール - ブログなんだよもん

ここに良くまとまっているのだが、ユニットテストは特定の条件に依存せず、何度やっても同じ結果になるよう作ることが望ましい。

その上でよく問題になることとして、

  • ファイルの読み書きを伴うプログラムのテスト
  • ログが標準出力に出てしまう
  • 例外を出すことをテスト
  • 設定ファイルの設定値に依存するテスト
  • モックやスタブを使ったテスト
  • テストデータの投入/破棄

自分の経験としては上記の対処に頭を悩ませてきた。

今回はそういう事柄の解決をGoのユニットテストでどうやったら良いか考えてみる。

ファイルを読み取るプログラムのテスト

ファイルを読み取る場合は文字列のファイルライクオブジェクトを使って対処。
Goの場合、strings.NewReaderを使えば良い。(バイナリファイルの場合bytes.NewReader)

[unittest/fileread.go]

package unittest

import (
	"encoding/csv"
	"io"
)

type Entry map[string]string

// ファイルを処理するプログラム
func CSVToMaps(in io.Reader) (xs []Entry, err error) {
	var r *csv.Reader
	r = csv.NewReader(in)

	var names []string
	var entry Entry
	for {
		record, err := r.Read()
		if err == io.EOF {
			break
		}
		if err != nil {
			return nil, err
		}

		if names == nil {
			// 最初の行
			names = record
		} else {
			entry = Entry{}
			for i := 0; i < len(names); i++ {
				entry[names[i]] = record[i]
			}
			xs = append(xs, entry)
		}
	}
	return
}

[unittest/fileread_test.go]

package unittest

import (
	"reflect"
	"strings"
	"testing"
)

// ファイルを入力に受け付けるプログラムのテスト: strings.NewReader/bytes.NewReaderで対処
func TestCSVToMaps(t *testing.T) {
	fp := strings.NewReader("" +
		"id,name,age\n" +
		"1,foo,18\n" +
		"2,bar,\n" +
		"3,,5")

	xs, err := CSVToMaps(fp)
	if err != nil {
		t.Errorf("ファイルの読み込みに失敗しました: %v", err)
	}

	expected := []Entry{
		{"id": "1", "name": "foo", "age": "18"},
		{"id": "2", "name": "bar", "age": ""},
		{"id": "3", "name": "", "age": "5"},
	}

	if !reflect.DeepEqual(xs, expected) {
		t.Errorf("結果が期待値と一致しません: %v != %v", xs, expected)
	}
}

ファイルを書き出すプログラムのテスト

ファイルを書き出す場合はテンポラリファイルを使って対処。Goの場合、ioutil.TempFileを使えば良い。
テンポラリファイルは最後に消すので、deferを使ってos.Remove(tmp.Name())を仕掛けておく。
なお、この方法はローカルのファイルを書き出す場合に有効である。S3とか、外部の出力先に書き出す場合は別の方法を採る必要がある。

[unittest/filewrite.go]

package unittest

import (
	"encoding/csv"
	"os"
)

// ローカルのファイルを出力するプログラム (S3とかに出力する場合はこの方法ではダメ)
func WriteCSV(names []string, xs []Entry, filepath string) {
	out, _ := os.OpenFile(filepath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
	defer out.Close()
	w := csv.NewWriter(out)

	defer w.Flush()
	w.Write(names)

	values := make([]string, len(names))
	for _, x := range xs {
		for i := 0; i < len(names); i++ {
			values[i] = x[names[i]]
		}
		w.Write(values)
	}
}

[unittest/filewrite_test.go]

package unittest

import (
	"io/ioutil"
	"os"
	"testing"
)

// ファイルを出力するプログラムのテスト: ioutil.TempFileで対処
func TestWriteCSV(t *testing.T) {
	tmp, _ := ioutil.TempFile("", "_")
	defer func() {
		os.Remove(tmp.Name())
	}()

	data := []Entry{
		{"id": "1", "name": "foo", "age": "18"},
		{"id": "2", "name": "bar", "age": ""},
		{"id": "3", "name": "", "age": "5"},
	}
	names := []string{"id", "name", "age"}
	WriteCSV(names, data, tmp.Name())

	in, _ := os.Open(tmp.Name())
	defer in.Close()
	bs, err := ioutil.ReadAll(in)
	if err != nil {
		t.Errorf("処理に失敗しました: %v", err)
	}

	result := string(bs)

	expected := ("" +
		"id,name,age\n" +
		"1,foo,18\n" +
		"2,bar,\n" +
		"3,,5\n")
	if result != expected {
		t.Errorf("結果が期待通りではありませんでした: %v != %v", result, expected)
	}
}

ログを出力するプログラムのテスト

これには悩んだ。そもそもGoは敢えて貧弱な標準ロギングライブラリを設けているらしい。

loggingについて話そう - Qiita

ログを出力するプログラムのテストは事前にロギングを無効化することにした。
方法としては、ioutil.Discardが/dev/nullに向いた出力先として用意されているので、log.SetOutput(ioutil.Discard)した。

同じ方法は以下の記事でも紹介されていた。

Using The Log Package In Go

[unittest/logging.go]

package unittest

import (
	"log"
)

// ログを出力するプログラム
func DoSomethingWithLog(x int) (ok bool) {
	if x%2 == 0 {
		log.Printf("偶数は受け付けません: %vが入力されました", x)
		return false
	}
	return true
}

[unittest/logging_test.go]

package unittest

import (
	"io/ioutil"
	"log"
	"os"
	"testing"
)

// ログを出力するプログラムのテスト: logモジュールで出力先を潰してしまう(!)
func TestDoSomethingWithLog(t *testing.T) {
	setUp := func() {
		log.SetOutput(ioutil.Discard) // /dev/nullに出力
	}
	tearDown := func() {
		log.SetOutput(os.Stdout) // 出力先を戻す
	}

	func() {
		setUp()
		defer tearDown()

		var ok bool

		ok = DoSomethingWithLog(3)
		if !ok {
			t.Errorf("結果が期待通りではありませんでした: %v != true", ok)
		}

		ok = DoSomethingWithLog(2)
		if ok {
			t.Errorf("結果が期待通りではありませんでした: %v != false", ok)
		}
	}()

	// DoSomethingWithLog(2) // 当然、無名関数の外で呼び出したら標準出力にログが出力される
}

とりあえずここまで。他のテーマは随時更新で。

Goでユニットテストの書き方① 基礎編

testing.Tのポインタを引数に取る関数を定義する

ベンチマークテストのことを先に調べてからユニットテストを調べているのもアレだが、Goでユニットテストを書くときはtesting.Tのポインタを引数に取る関数を定義すれば良いらしい。

assertなんちゃら といったメソッドは無い: t.Error(), t.Errorf()を使う

PythonJavaユニットテストライブラリに慣れた身としては、まずアサーションの実行には「Error」や「Errorf」という名前のメソッドを使うルールに混乱した。

ちゃんと「Fail」というメソッドもあるので、最初こっちを使うのだろうと思ったが、どうやらこれはPythonのunittest.TestCase.fail()に相当する(=とにかくテストを失敗させるメソッド、アサーション無し)もののようだ。

簡単な例を示すとこんな感じ。

[foo/main_test.go]

package foo

import "testing"

func TestFoo(t *testing.T){
	var result int
	var err error
	result, err = Foo("1")
	if result, error = Foo("1"); err != nil {
		t.Errorf("処理に失敗しました: %v", err)
	}

	if result != 10 {
		t.Errorf("結果が不正です: %v != 10", result)
	}
}

非公開モジュールのテストをするためにアプリケーションコードと同じパッケージに所属させる

小文字で名前が始まるモジュールはパッケージの外からアクセスできないので、それらのテストを書く場合はテスト対象と同じパッケージを宣言する。

ひとつのテストケースの中で複数のアサーションをやっても別々に出力してくれる

感心したのが、testing.Tはt.Errorfを同じ関数(=テストケース)の中で複数出力しても処理は続行される点だ。

Pythonならば、assertなんちゃらメソッドを呼んで期待値と結果が異なれば、即座にそのテストケースは打ち切りになる。

import unittest

class FooTest(unittest.TestCase):
    def test(self):
        self.assertEqual(1, 0)  # ここでテスト失敗、処理は打ち切られる。
        self.assertEqual(2, 2)  # ここは通らない

しかし、Goの場合はt.Error, t.Errorfが標準出力にテストの失敗を出してくれるだけで、処理は続行される。

import "testing"

func TestFoo(t *testing.T){
	if 1 != 0 {
		t.Error("結果が不正です: 1 != 0") // ここで処理は止まらない
	}

	if 2 != 1 {
		t.Error("結果が不正です: 2 != 1") // こっちもテストしてくれる
	}
}

[処理結果]

--- FAIL: TestFoo (0.00s)
	sample_test.go:7: 結果が不正です: 1 != 0
	sample_test.go:11: 結果が不正です: 2 != 1
FAIL
FAIL	github.com/oyakata/tokyo/err	0.007s

このように、ちゃんとTestFooの中で別々の検証と判るよう整形してくれることに感銘を受けた。

Goのエラー処理② panicとrecover

Goのpanicとrecover

Goのエラー処理は原則として例外を使わず、エラーを丁寧に処理すべき方針はわかったとして、Goの例外機構というものがどういう仕組なのか調べた。

まず、基本に忠実に、golang.jpの説明を読むと、

実践Go言語 - golang.jp

Recover

panicが呼び出されたとき(これには、配列のインデックスが範囲外であるときや、型アサーションに失敗したような暗黙的なものも含む)は、すぐさま、カレントの関数を停止し、ゴルーチンのスタックの巻き戻しを開始します。その途中、遅延指定されている関数をすべて実行します。この巻き戻しがゴルーチンのスタックの先頭にたどり着くと、プログラムは終了します。ただし、組み込み関数recoverを使うことで、ゴルーチンの制御を取り戻し、通常の実行を再開させることが可能です。

panicが発生すると即座に処理を打ち切るので、これが他の言語の例外に相当するものらしい。

「その途中、遅延指定されている関数をすべて実行します」とも言っているので、deferに無名関数を指定してrecoverしないとダメなんじゃないかと思ったら、ご丁寧にその後に明記されていた。

recoverを呼び出すと、巻き戻しを停止し、panicに渡された引数が返ります。巻き戻り中に実行できるコードは、defferで遅延指定された関数内のみなので、recoverは遅延指定された関数内でのみ役立ちます。

Pythonとの比較

例えば、リトライ可能な例外がサブルーチンから送出された場合は例外を握りつぶし、それ以外は上位ルーチンに送出するプログラムをPythonで書くとこんな感じだろう。

class Retryable(Exception):
    pass

try:
    do_something() // 何か例外を送出する可能性がある処理
except Retryable as ex:
    print(ex)
except Exception as ex:
    raise ex

普通にtry-exceptブロック使うでしょう。

対して、Goで似たようなことをやろうとすると、こんな感じだろう。

type Retryable struct{}

defer func() {
	switch p := recover(); p {
	case nil:
		// ここはパニックが起きなかった場合が該当する
		fmt.Println("No error.")
	case Retryable{}:
		err = fmt.Errorf("failed, retry")
		// err = errors.New("failed, retry")
	default:
		panic(p)
	}
}()

Pythonがブロックを扱う方法を採るのに対し、Goはブロックを監視する仕組みではなくあくまでも関数ベースの解決方法を採ることがよく分かる。

「実践Go言語」のページにはこのようにも書いてある。

他言語のブロックレベルのリソース管理に慣れたプログラマには、deferは特異に見えるかも知れませんが、deferのもっとも面白く、強力な活用法は、deferがブロックベースではなく、関数ベースであることによって生み出されます。panicとrecoverのセクションに、この可能性を示す例があります。

やっぱり、recoverはdeferと一緒に使わないと意味がないみたいだ。

また、Pythonのコンテキストマネージャはまさにブロックを監視する仕組みなので、Goで同じことをしようと思ったらdeferを使うのだろう。
とはいえ、Pythonでコンテキストマネージャを作るときはcontextlib.contextmanagerで関数をラップすれば簡単に作れるので、Goの流儀でPythonのコンテキストマネージャを考えると、Pythonには関数ベースの基本的なアプローチの上に、ブロックベースの仕組みに置き換える方法が用意されていると捉えるべきか(謎)。

contextlibを使う場合はこんな感じ。

from contextlib import contextmanager

class Retryable(Exception):
    pass

@contextmanager
def retrying():
    try:
        yield
    except Retryable as ex:
        print(ex)
    # except Exception as ex:
    #     raise ex

with retrying():
    do_something() // 何か例外を送出する可能性がある処理

実際に試した

せっかくなので、エラーを返却する原則的なやり方と、panic/recoverを利用する方法をそれぞれ使い分けるサンプルコードを書いてみた(以下)。

package main

import "fmt"

type Challenger struct {
	count     int
	threshold int
}

type Retryable struct{}

// 非公開メソッドの方はわざとpanicを利用する。
func (c *Challenger) attack() bool {
	if c.count < c.threshold {
		c.count++
		panic(Retryable{}) // パニック、しかしリトライ可能
	}
	if c.count > c.threshold {
		panic("No more chance.")
	}
	c.count++
	return true
}

// 公開メソッドはpanicを捕捉してリカバーをかける。
func (c *Challenger) Attack() (ok bool, err error) {
	defer func() {
		switch p := recover(); p {
		case nil:
			// ここはパニックが起きなかった場合が該当する
			fmt.Println("No error.")
		case Retryable{}:
			err = fmt.Errorf("Failed(%v times)", c.count)
		default:
			panic(p)
		}
	}()
	ok = c.attack()
	return
}

func main() {
	var c *Challenger
	c = &Challenger{0, 3}
	fmt.Println(c.Attack()) // false Failed(1 times)
	fmt.Println(c.Attack()) // false Failed(2 times)
	fmt.Println(c.Attack()) // false Failed(3 times)
	fmt.Println(c.Attack()) // true <nil>
	fmt.Println(c.Attack()) // 以下panic

	// panic: No more chance. [recovered]
	// 	panic: No more chance.
	//
	// goroutine 1 [running]:
	// panic(0x4b9f40, 0xc82000a400)
	// 	/usr/lib/go-1.6/src/runtime/panic.go:481 +0x3e6
	// main.(*Challenger).Attack.func1(0xc820039e30, 0xc820039e58)
	// 	/home/echizen/_go/src/github.com/oyakata/tokyo/trash/retry.go:35 +0x2fc
	// panic(0x4b9f40, 0xc82000a400)
	// 	/usr/lib/go-1.6/src/runtime/panic.go:443 +0x4e9
	// main.(*Challenger).attack(0xc820039e58, 0x52c678)
	// 	/home/echizen/_go/src/github.com/oyakata/tokyo/trash/retry.go:19 +0xd0
	// main.(*Challenger).Attack(0xc820039e58, 0x0, 0x0, 0x0)
	// 	/home/echizen/_go/src/github.com/oyakata/tokyo/trash/retry.go:38 +0x78
	// main.main()
	// 	/home/echizen/_go/src/github.com/oyakata/tokyo/trash/retry.go:49 +0x52c
	// exit status 2
}

Goのエラー処理① エラー処理の基本的な考え方

Goのエラー処理方針

Goの例外やエラーの処理について勉強中。まず簡単なことからメモ。

例外は原則として使わない・予期される異常処理はすべて手当する

プログラミング言語Go』を読むと、Goに例外の仕組みはあるけど、基本的にエラーを丁寧に処理しないという方針らしい。

(前略)Goはある種の例外機構を持っていますが、バグであることを示す本当に予期されていないエラーを報告するためだけに使われ、頑強なプログラムを構築するためのルーチンで予期されるエラーには使われません。

なので、エラーを処理する関数は戻り値にerror型の値を返すよう実装するのが良いようだ。

エラーの作り方

エラーは組み込みのerror型を利用する。作り方は

  • errors.New("エラーメッセージ")で作る
  • fmt.Errorf("...")で作る

上記二通りあるようだ。(以下、サンプル)

package main

import (
	"errors"
	"fmt"
)

func main() {
	var err error

	err = errors.New("処理に失敗しました") // エラーの原因などが何も無いので、あまり良くないメッセージ
	fmt.Println(err)

	err = errors.New(fmt.Sprintf("処理中に接続が切れたためリトライを開始します(%v回め)", 3))
	fmt.Println(err)

	// 前のエラー内容を引き継いで新規エラーメッセージを追記する場合
	err = fmt.Errorf("%v: リトライ回数が一定に達したため、処理を打ち切ります(%v回試しました)", err, 7)
	fmt.Println(err)
}

エラーメッセージはデバッグ等に役立てるためにも、(引数やローカル変数の内容も併記することが望ましいので)、とりあえずfmt.Errorfを使っていれば良いんじゃないかと思った。

エラーメッセージのルール

これも『プログラミング言語Go』で推奨されているエラーメッセージのルールだが、

  • メッセージに大文字を使わない方が良い(頻繁に連鎖[追記]するから)
  • 改行文字も使わない(grepしにくくなるから)
  • 関数f(x)のエラーは、操作fに対する引数xとエラーの関連がわかるように書く
  • サブルーチンから返却されたエラーに無い情報は、呼び出し元で追記して最終的なエラーとして返却する(エラーの伝搬)

PythonJavaなどの例外を常用する言語でプログラムを書いていると、メインルーチンで基底の例外を捕捉し、サブルーチンでより詳しい例外派生クラスを送出する処理を書く手法に慣れているので、Goのルールになじむまでなかなか骨が折れそうだ。

ただ、Goのポリシーは異常が起きた箇所で確実に手当していく心がけにもつながるんじゃないかと思う。