Go言語のツアー演習をやってみた

技術,プログラミング言語

先日、ちょっとGo言語が気になって、公式のツアーを見たついでに演習をやってみました。Go使いではないので参考にはならないかもしれませんが、解答例を挙げておきます。

Goのツアーをやってみた

Go言語とは

Go言語とはGoogleが開発したプログラミング言語です。

WikipediaのGo言語の説明には以下のように書いてあります。

Goはプログラミング言語の1つである。2009年、GoogleでRobert Griesemer、ロブ・パイク、ケン・トンプソンによって設計された。
Goは、静的型付け、C言語の伝統に則ったコンパイル言語、メモリ安全性、ガベージコレクション、構造的型付け(英語版)、CSPスタイルの並行性などの特徴を持つ。Goのコンパイラ、ツール、およびソースコードは、すべてフリーかつオープンソースである。

Wikipediaより

すごく大雑把に言えば「Googleが再設計したC言語」と感じました。GAFAの一角としてIT業界の巨人であるGoogleですが、その提供するツールは意外に保守的というか、質実剛健というイメージがあります。その辺が同じクラウドでもAWSとGCPでは方向性の違いが顕著で面白いです。個人の感想ですが、訴求力の高い派手目な機能よりは、実際の現場で役に立つ地味な実装に重点を入れるのがGoogleツールと感じています。(個人の感想です)

そういう点では、ちょっと触っただけで言うのも何ですが、Go言語は実にGoogleらしい感じがしました。今時のモダンな言語にありがちな機能に敢えて背を向けて、実を取りに行っている気がしました。

Go言語の特徴

他の言語と比べて特徴的に感じたのは以下の辺りです。

  • セミコロン不要
  • ループはforだけ、クラスはなし、と割り切ったシンプル設計
  • ポインタ変数はあるが、ポインタ演算はなし
  • goroutine

シンプルだけど抑えるところは抑えている感じです。goroutineとか面白いですが、ちゃんと理解して使わないと悩ましいバグを生み出しそうな危険な香りがします。今時の他のモダンな言語と比べると、おそらくあの機能がない、この機能もない、となってしまうかと思います。モダン機能ではないですがループなんてforしかないですから。whileもないし、モダンな言語によくあるeachやforeachの類もありません。

Go言語の学び方

実際に学ぶにあたっては、Go言語公式の「Welcome to a tour of Go」を一通り眺めつつ、実際にコードを動かし、演習問題を解いてみました。playground対応なので、実際に環境をインストールしたりせずにその場で試せるあたりが今風です。

演習問題を解いてはみましたが回答編があるわけでもなく、実際これでいいのかと悩んだりもしましたので、経験不足の身ではありますが、回答例と考え方を載せておきます。
なお、題意を勘違いしているかもしれませんし、もっとGo言語らしい書き方もあるかもしれませんが、そこはあらかじめご了承ください。Go言語初心者の学習メモとしての掲載です。

演習の解答例

さて、言語の細かい点は公式サイト等をみてもらうとして、早速、回答例を上げていきます。

なお、使っているWordPressテーマのコード引用ツールがGo言語に対応していないので、JavaScriptを指定しています。

演習:ループと関数

最初はループと関数の演習ということで、sqrt関数を使わないで平方根を求める演習です。計算式も書いてあるので、簡単な演習かと思います。

package main

import "fmt"

func Sqrt(x float64) float64 {
	z := 1.0
   	for i := 0; i < 10; i++ {
		z -= (z * z - x) / (2 * z)
	}
	return z
}

func main() {
	fmt.Println(Sqrt(5))
}

このように"名前 型"として宣言するのが、C言語とかと違うところでしょうか。前に各言語を多く使ってきたので、この辺りはまだ慣れていません。ついつい、先に書こうと思ってしまいます。

演習:スライス

配列の一部を抜き出すスライスを使って図形を描く演習です。これはちょっと問題を理解するのに時間がかかりました。それっぽく図形が出てきたので概ね合っているかと思います。

package main

import "golang.org/x/tour/pic"

func Pic(dx, dy int) [][]uint8 {
	// 領域の確保
	pic := make([][]uint8, dy)
	for i := range pic {
		pic[i] = make([]uint8, dx)
	}

	// グラフの描画
	for x := 0; x < dx; x++ {
		for y := 0; y < dy; y++ {
			//pic[y][x] = uint8((x + y) / 2)
			//pic[y][x] = uint8(x * y)
			pic[y][x] = uint8(x ^ y)
		}
	}
	return pic
}

func main() {
	pic.Show(Pic)
}

グラフに使う数式は面白い例として挙げられているものをそのまま使いました。

2次元配列を使いますが、この辺りは分かりにくい部分かと思います。メイン関数では例題用に用意されているpicパッケージのShowメソッドを使って描画しますが、その元データとして二次元配列Picを作って渡しています。

二次元配列を一気に作れないと思ったので、uint8型の配列の配列を作り、その各要素にuint8の配列を代入して二次元配列を作成しました。まあ、ヒントにある通りです。

演習:マップ

続いてマップの演習です。いわゆる連想配列です。

これも例題用にあらかじめ色々用意されているので、WordCount関数の中身を書くだけです。

package main

import (
	"golang.org/x/tour/wc"
	"strings"
)

func WordCount(s string) map[string]int {
	items := strings.Split(s, " ")
	
	wc := make(map[string]int)
	
	for _, v := range items {
		wc[v] ++
	}
	
	return wc
}

func main() {
	wc.Test(WordCount)
}

テストスイートを想定したものがあらかじめ演習用に用意してあるのでそこから呼び出されるWordCount関数を書いていきます。と言っても、スペース区切りで単語を取得して、それをマップのキーにして、出現回数をカウントしていくだけです。ただ、タブ区切りとか、半角スペース以外のスペースには対応していません。

マップ型の変数を作成にmake関数を使いますが、ややトリッキーな構文に感じます。

なお、ソース中にwc[v]++というインクリメント演算子が出てきますが、C言語とちがってGo言語は後置インクリメント演算子だけで、前置インクリメント演算子はないそうです。

演習:フィボナッチ数

続いて、フィボナッチ数です。再起ではなくてループで実装しています。アルゴリズム自体はWikipediaから拝借しました。

package main

import "fmt"

// fibonacci is a function that returns
// a function that returns an int.
func fibonacci() func() int {
	n := 0
	return func() int {
		a, b := 1, 0
		for i:= 0; i < n; i++ {
        	a, b = b, a + b
		}
		n++
    	return b
	}
}

func main() {
	f := fibonacci()
	for i := 0; i < 20; i++ {
		fmt.Println(f())
	}
}

フィボナッチ数の演習というよりはクロージャの演習です。今まで自分がやってきた言語には馴染みのない概念なので、なかなか慣れません。intを返す関数を返す関数とかわけわからなくなってきます。ここも慣れないと分かりづらいバグの温床になりそうです。

演習:stringerインターフェイス

stringerインターフェイスを実装する演習です。Go言語にはクラスや継承はありませんが、構造体に対してメソッドを付けたり、インターフェイスを宣言して実装することができます。

package main

import "fmt"

type IPAddr [4]byte

// TODO: Add a "String() string" method to IPAddr.
func (ipaddr IPAddr) String() string {
	return fmt.Sprintf("%v.%v.%v.%v", ipaddr[0], ipaddr[1], ipaddr[2], ipaddr[3])
}

func main() {
	hosts := map[string]IPAddr{
		"loopback":  {127, 0, 0, 1},
		"googleDNS": {8, 8, 8, 8},
	}
	for name, ip := range hosts {
		fmt.Printf("%v: %v\n", name, ip.String())
	}
}

.表記の文字列を作る部分は、ループとかで綺麗に書けそうな気もしましたが、手を抜いてそのまま4つ並べてしまいました。4つ固定ですから変にループとかにするよりシンプルで速いだろうという判断です。

演習:エラーハンドリング

Go言語ではエラーを返すのに関数の第2返り値とerrorを使います。内容自体は上でやった平方根の計算です。

package main

import "fmt"

// エラー
type ErrNegativeSqrt float64

func (e ErrNegativeSqrt) Error() string {
	return fmt.Sprint("cannot Sqrt negative number: ", float64(e))
}

func Sqrt(x float64) (float64, error) {
	if x < 0 {
		return 0, ErrNegativeSqrt(x)
	}
	
	z := 1.0
  	for i := 0; i < 20; i++ {
    	z -= (z * z - x) / (2 * z)
   	}
   	return z, nil
}

func main() {
	var result float64
	var err error
	
	fmt.Print("sqrt(2):")
	result, err = Sqrt(2)
	if err != nil {
		fmt.Println(err)
	} else {
		fmt.Println(result)
	}
	
	fmt.Print("sqrt(-2):")
	result, err = Sqrt(-2)
	if err != nil {
		fmt.Println(err)
	} else {
		fmt.Println(result)
	}
}

負の数値を指定した場合にエラーを返す演習です。sqrt関数の冒頭で引数チェックして、負の数値ならばすぐにエラーを返しています。

他のモダンな言語では例外機構が用意されているケースが多いですが、Go言語には例外はありません。そもそも、例外は一つしか値を返せないことへの折衷案として用意されたものだから、2つ返すことができれば問題ないよね、というのがGoogleの主張のようです。(個人の感想です)

モダンな言語(と言う程、例外というかtry〜catchは新しくもない)には当たり前の例外処理ですが、確かに意外に使い方って難しいです。大雑把に全体を括るとどこで問題が起きたか分かりにくいし、かと言って、一文ずつtry〜catchで囲うのもどうかと思うし。どうも「例外」が例外じゃなくて当たり前になってしまってるのはいかがなものか?という考えらしい。(個人の感想です)

参考記事 Qiita:「例外」がないからGo言語はイケてないとかって言ってるヤツが本当にイケてない件

まあ、以下のような書き方もできたりします。

package main

import "fmt"

// エラー
type ErrNegativeSqrt float64

func (e ErrNegativeSqrt) Error() string {
	return fmt.Sprint("cannot Sqrt negative number: ", float64(e))
}

func Sqrt(x float64) float64 {
	if x < 0 {
		panic(ErrNegativeSqrt(x))
	}
	
	z := 1.0
  	for i := 0; i < 20; i++ {
    	z -= (z * z - x) / (2 * z)
   	}
   	return z
}

func main() {
	defer func() {
		if e := recover(); e != nil {
			fmt.Println(e)
		}
	}()

	fmt.Printf("Sqrt(2):%f\n", Sqrt(2))
	fmt.Printf("Sqrt(-2):%f\n", Sqrt(-2))
}

deferはツアーでも説明されていますが、panicとrecoverは説明されていません。詳細は公式のリファレンスや上のリンク先の参考記事を参照してください。(丸投げ)

ただ、2番目の書き方を推奨するわけではありません。こういう書き方もできるというだけです。これって、どこで問題が起きたのかとか、誰がどう対処すべきなのかが曖昧になってしまうので。まあ、書く方は楽できますけど。

引数の妥当性はどこで判断すべきか

例外の話に絡みますが、ある関数を呼び出す際の引数の妥当性(上のSqrt関数なら引数が正の値かどうか)をチェックするのは、呼び出しもとか呼び出し先か、というのはなかなか難しい問題です。もちろん、規約で決めてしまえば良いのですが。

結局、両方で値をチェックしてたりするのは、それはそれで非効率です。さらに、呼び出し先でチェックして個々に例外をスローした上で、さらに呼び出し元で全部拾うというのもいかがなものかと。ついつい、例外処理の覚えたての頃は無駄に細かく仕分けした例外を定義してケース毎に投げ分けたりするのはあるあるかと思うのですが(自分だけ?)。

結局、複数人で開発する場合は多少の非効率は目を瞑ってダブルチェックしてしまう方向に倒してしまいます。もっとも、呼び出し前に引数のチェックをした上でさらに細かい例外で拾うのは無駄ですね。

演習:Readerインターフェイス

これは演習の問題文がシンプルすぎて、何言っているのかよく分かりませんでした。演習用に提供されているreaderパッケージの中身を見てようやく理解しました。

package main

import "golang.org/x/tour/reader"

type MyReader struct{}

// TODO: Add a Read([]byte) (int, error) method to MyReader.
func (mr MyReader) Read(b []byte) (int, error) {
	for i := 0; i < len(b); i++ {
		b[i] = 'A'
	}
	return len(b), nil
}

func main() {
	reader.Validate(MyReader{})
}

与えられたバイト配列を’A’ で埋めた文字列を返しています。ライブラリをちゃんと探せば、指定した文字数分同じ文字で埋める関数とか有りそうでしたが、面倒だったのでループ回して埋めています。

演習:ROT13Reader

これはReaderをラップして変換する例で、ROT13変換という古典的な文字の置き換えをしています。

package main

import (
	"fmt"
	"io"
	"strings"
)

type rot13Reader struct {
	r io.Reader
}

func (self rot13Reader) Read(b []byte) (int, error) {
	n, err := self.r.Read(b)
	if err != nil {
		return 0, err
	}
	// ROT13処理
	for i, c := range b {
		switch {
		case c >= 'A' && c <= 'M':
			b[i] += 13
		case c >= 'N' && c <= 'Z':
			b[i] -= 13
		case c >= 'a' && c <= 'm':
			b[i] += 13
		case c >= 'n' && c <= 'z':
			b[i] -= 13
		}
	}
	return n, nil
}

func main() {
	s := strings.NewReader("Lbh penpxrq gur pbqr!")
	r := rot13Reader{s}

	b := make([]byte, 1024)
	_, err := r.Read(b)
	if err != nil {
		fmt.Println("read error : " + err.Error())
		return
	}
	fmt.Printf("%s\n", b)
}

ROT13変換部分はもう少しスマートに書けそうな気がしますが、手を抜きました。switchの条件部を空にしてcaseの方に条件を書くのは慣れないと分かりにくいですね。

Readerインターフェイスを実装すると色々応用が効くようです。

演習:Imageインターフェイス

スライスの演習と同じことをImageインターフェイスで実装しています。

package main

import "golang.org/x/tour/pic"
import (
	"image"
	"image/color"
)

type Image struct{}

func (i Image) ColorModel() color.Model {
	return color.RGBAModel
}

func (i Image) Bounds() image.Rectangle {
	return image.Rect(0, 0, 250, 250)
}

func (i Image) At(x, y int) color.Color {
	//v := uint8((x + y) / 2)
	//v := uint8(x * y)
	v := uint8(x ^ y)
	return color.RGBA{v, v, 255, 255}
}

func main() {
	m := Image{}
	pic.ShowImage(m)
}

記述量は増えていますが、内容が分かればどこをどう変えれば良いか分かり易いかと思います。

演習:2分木探索

package main

import "golang.org/x/tour/tree"
import "fmt"

// Walk walks the tree t sending all values
// from the tree to the channel ch.
func Walk(t *tree.Tree, ch chan int) {
	if t.Left != nil { Walk(t.Left, ch) }
	ch <- t.Value
	if t.Right != nil { Walk(t.Right, ch) }
}

// Same determines whether the trees
// t1 and t2 contain the same values.
func Same(t1, t2 *tree.Tree) bool {
	ch1 := make(chan int)
	ch2 := make(chan int)
	go Walk(t1, ch1)
	go Walk(t2, ch2)
	
	for i := 0; i < 10; i++ {
		if <- ch1 != <- ch2 {
			return false
		}
	}
	return true
}

func main() {
	ch := make(chan int) // チャンネルを生成
	go Walk(tree.New(1), ch)
	
	// Exersize 1,2
	for i := 0; i < 10; i++ {
		if i != 0 { fmt.Print(",") }
		fmt.Print(<- ch)
	}
	fmt.Println("")
	
	// Exercise 3,4
	fmt.Println(Same(tree.New(1), tree.New(1)))
	fmt.Println(Same(tree.New(1), tree.New(2)))
}

まとめて一つのソースとしてしまいました。これも演習用に用意されているtreeパッケージの中身を見て理解しました。二分木の中身を順次チャネルに詰め込んで行って、それを呼び出し元で取り出しています。

並列処理の使い所がまだ完全に把握できていない気はするのですが。

演習:Webクローラ

Webクローラの演習ですが、実際にどこかにアクセスするわけではなくて、スタブを咬まして処理しています。

これあれこれやってみたのですが、どうしてもうまくいかず。チャネル使って並行処理をしてみたものの、どうしてもdeadlockしてしまいます。それと言うのも、クロールする側で最後にcloseしないといけないのですが、その「最後」の判断ができないからです。

仕方ないので、これだけWeb検索で答えを調べてみましたが、どうもツアーでは触れていない機能を使わないとどうにもならない感じでした。なんとかツアーの範囲内でなんとかしたものもありましたが、かえってよく分からなくなるので、素直にツアー外の機能を使った方がわかりやすいと感じました。

その使う機能というのが、sync.WaitGroupパッケージです。使うメソッドは以下の3つ。

  • Add:WaitGroupの開始
  • Done:WaitGroupの終了
  • Wait:WaitGroupの終了待ち

ツアーに出てきたチャネルでなんとかするよりも素直に組めるかと思います。今回のオリジナルの非並列処理版の場合、クロール処理の中で出力をやっていて、そこから外に情報を出す必要がないのでチャネルを使うシーンじゃないよね、と思います。

ですので、このWaitGroupを使って並列処理を開始するたびにカウントアップし、クロールが終わったらカウントダウンすることで、全ての処理が終わるのを待とうという目論見です。

もちろん、クロール処理の中では出力しないでクロールだけ行い、全ての情報が揃った後で呼び出し元の方で結果を出力するという考え方もあります。元ソースがなければ自分なら後者で実装したかと思います。それだとメモリを食うのでメリットもデメリットもあります。

ただ、元の非並列処理版を手直しして並列処理にしなさいというお題なので、今回は前者で実装しました。あまりあちこち手を入れたくないので、変更は最小限にする方向です。

package main

import (
	"fmt"
	"sync"
	"time"
)

type Fetcher interface {
	// Fetch returns the body of URL and
	// a slice of URLs found on that page.
	Fetch(url string) (body string, urls []string, err error)
}

type CrawlStatus struct {
	wg      sync.WaitGroup
	fetched map[string]bool
	mu      sync.Mutex
}

// Crawl uses fetcher to recursively crawl
// pages starting with url, to a maximum of depth.
func Crawl(url string, depth int, fetcher Fetcher) {
	startTime := time.Now().UTC() // 開始時刻を保持
	var status CrawlStatus
	status.fetched = make(map[string]bool)
	CrawlProc(url, depth, fetcher, &status)
	status.wg.Wait()
	fmt.Printf("%d ms.\n", time.Since(startTime).Milliseconds()) // 経過時間を出力
}

func CrawlProc(url string, depth int, fetcher Fetcher, status *CrawlStatus) {
	// TODO: Fetch URLs in parallel.
	// TODO: Don't fetch the same URL twice.
	// This implementation doesn't do either:
	defer status.mu.Unlock()
	status.mu.Lock()
	if depth <= 0 || status.fetched[url] == true {
		return
	}
	body, urls, err := fetcher.Fetch(url)
	if err != nil {
		fmt.Println(err)
		return
	}
	status.fetched[url] = true
	fmt.Printf("found: %s %q\n", url, body)
	status.wg.Add(len(urls))
	for _, u := range urls {
		go func(u string) {
			defer status.wg.Done()
			CrawlProc(u, depth-1, fetcher, status)
		}(u)
	}
	return
}

func main() {
	Crawl("https://golang.org/", 4, fetcher)
}

// fakeFetcher is Fetcher that returns canned results.
type fakeFetcher map[string]*fakeResult

type fakeResult struct {
	body string
	urls []string
}

func (f fakeFetcher) Fetch(url string) (string, []string, error) {
	if res, ok := f[url]; ok {
		time.Sleep(time.Second * 2) // dummy
		return res.body, res.urls, nil
	}
	return "", nil, fmt.Errorf("not found: %s", url)
}

// fetcher is a populated fakeFetcher.
var fetcher = fakeFetcher{
	"https://golang.org/": &fakeResult{
		"The Go Programming Language",
		[]string{
			"https://golang.org/pkg/",
			"https://golang.org/cmd/",
		},
	},
	"https://golang.org/pkg/": &fakeResult{
		"Packages",
		[]string{
			"https://golang.org/",
			"https://golang.org/cmd/",
			"https://golang.org/pkg/fmt/",
			"https://golang.org/pkg/os/",
			"https://golang.org/pkg/os/",
		},
	},
	"https://golang.org/pkg/fmt/": &fakeResult{
		"Package fmt",
		[]string{
			"https://golang.org/",
			"https://golang.org/pkg/",
		},
	},
	"https://golang.org/pkg/os/": &fakeResult{
		"Package os",
		[]string{
			"https://golang.org/",
			"https://golang.org/pkg/",
		},
	},
}

色々悩んだ結果まとめたのが上記のソースです。

方針として、メイン関数は変えないことにしました。そこから一旦クロール処理の親玉を呼び出して、オリジナルの再帰を使ったクロール処理を呼び出すようにしています。

で、折角並列処理を入れるので、クロールの親玉のところで処理時間を表示するようにしました。また、FakeFetcherのFetch関数の中でダミーで2秒のウェイトを入れてみました。これは何もなしでするっと抜けると並列処理した意味合いが分かりにくいためです。

あと、ヒントにあった並列処理の安全性の確認のために、FakeFetcherのPackageの部分の子URLリストの「os」を含む部分を2行にしました。並列処理のタイミングによっては未取得のURLに対して同時にクロールに行ってしまうこともあるのを再現しています。実際には、子供のURLのクロールに行く前にユニーク処理をすべきだと思いますが、そこは手抜きしてます。
今回のFakeFetcherではシンプルな構造になっていますが、実際にWebクロールする際には1つのページから同じURLへのアクセスが大量にあると思うので、ユニーク処理しないと無駄にスレッド処理を開始してしまいます。いくらGo言語のスレッドが軽量スレッドだとしても、無駄打ちは良くないですので、URLリストのユニーク処理や、自分自身の除外とかは必須かと思います。そもそも、今回は並列処理の中で重複判定していますが、本来は並列処理に投げる前にチェックすべきですね。

さて、本題です。
関数に引数をごちゃごちゃ増やすのは嫌だったので、手始めに以下の構造体を用意しました。これをクロール本体で使っていきます。中身はWaitGroupとURLを処理したかを示すマップ型、ロック処理のMutexです。

type CrawlStatus struct {
	wg      sync.WaitGroup
	fetched map[string]bool
	mu      sync.Mutex
}

これをクロールの親玉で初期化して、クロール本体を再起的に呼び出していきます。

func Crawl(url string, depth int, fetcher Fetcher) {
	startTime := time.Now().UTC() // 開始時刻を保持
	var status CrawlStatus
	status.fetched = make(map[string]bool)
	CrawlProc(url, depth, fetcher, &status)
	status.wg.Wait()
	fmt.Printf("%d ms.\n", time.Since(startTime).Milliseconds()) // 経過時間を出力
}

クロール本体の方はほとんどオリジナルのままですが、最初の所定の深さにたどり着いた際の脱出部分に、そのURLが処理済みかどうかの判定を加えています。さらに、この処理自体をロックで囲っています。これがないと、先ほど仕込んだ同じURLを並べた部分で2回クロールしてしまいます。

	defer status.mu.Unlock()
	status.mu.Lock()
	if depth <= 0 || status.fetched[url] == true {
		return
	}

クロール済みにするのは実際にFetch関数で取ってきてエラー判定した後、結果を出力する直前にしています。その結果の子URLに対してクロール処理をかける時点で、WaitGroupを使っています。

	status.wg.Add(len(urls))
	for _, u := range urls {
		go func(u string) {
			defer status.wg.Done()
			CrawlProc(u, depth-1, fetcher, status)
		}(u)
	}

まあ、一応それっぽくできはしました。が、これロック処理を入れない場合と処理時間が変わらないという。そりゃ、時間のかかる処理部分をロックしているのだから考えれば当然です。
ですので、ロックする部分を冒頭からFetchを呼び出す前に変更すれば並行処理してくれるので速くなります。ただ、これだとなんらかの問題でFetchが失敗した場合(例えばネットワーク障害とか、クロール先のホストの混雑でタイムアウトしたとか)に処理されなくなってしまいます。FakeFetcherではそんな問題は起きないし、実際の処理でもどこまでエラーとしてリカバリするかという設計上の問題になるので、演習でそこまで気にするかは微妙な気はします。

	// defer status.mu.Unlock()
	status.mu.Lock()
	if depth <= 0 || status.fetched[url] == true {
		status.mu.Unlock()
		return
	}
	// ここでの判定は早すぎる
	status.fetched[url] = true
	status.mu.Unlock()
	body, urls, err := fetcher.Fetch(url)
	if err != nil {
		fmt.Println(err)
		return
	}
	// 本来のクロール済み判定
	// status.fetched[url] = true

というわけで、ややモヤッとしましたが、本気で作るならロック部分は変更版の方にして、エラーになった場合の処理は別処理にするでしょうか。例えば、エラーリストを作っておいて、全体の処理が終わった後にでもエラーになった部分に再挑戦するとか(あるいは、これだけエラーになったけどどうするか?と問い合わせるとか、エラー部分はリストアップして知らせるだけにして再実行はユーザに任せるとか)。

技術GO言語,プログラミング言語

Posted by woinary