Goでの延期を理解する
序章
Goには、if
、switch
、for
などの他のプログラミング言語に見られる一般的な制御フローキーワードの多くがあります。 他のほとんどのプログラミング言語にはないキーワードの1つは、defer
です。あまり一般的ではありませんが、プログラムでどれほど役立つかがすぐにわかります。
defer
ステートメントの主な用途の1つは、開いているファイル、ネットワーク接続、データベースハンドルなどのリソースをクリーンアップすることです。 プログラムがこれらのリソースで終了したら、プログラムの制限を使い果たしないように、また他のプログラムがこれらのリソースにアクセスできるように、リソースを閉じることが重要です。 defer
は、開いている呼び出しの近くでファイル/リソースを閉じるための呼び出しを維持することにより、コードをよりクリーンにし、エラーが発生しにくくします。
この記事では、defer
ステートメントを適切に使用してリソースをクリーンアップする方法と、defer
を使用するときに発生するいくつかの一般的な間違いについて学習します。
defer
ステートメントとは
defer
ステートメントは、defer
キーワードに続くfunction呼び出しをスタックに追加します。 そのスタック上のすべての呼び出しは、それらが追加された関数が戻ったときに呼び出されます。 呼び出しはスタックに配置されるため、後入れ先出しの順序で呼び出されます。
defer
がどのように機能するかを、テキストを印刷して見てみましょう。
main.go
package main import "fmt" func main() { defer fmt.Println("Bye") fmt.Println("Hi") }
main
関数には、2つのステートメントがあります。 最初のステートメントはdefer
キーワードで始まり、その後にBye
を出力するprint
ステートメントが続きます。 次の行はHi
を出力します。
プログラムを実行すると、次の出力が表示されます。
OutputHi Bye
Hi
が最初に印刷されたことに注意してください。 これは、defer
キーワードが前に付いているステートメントは、defer
が使用された関数が終了するまで呼び出されないためです。
プログラムをもう一度見てみましょう。今回は、何が起こっているのかを説明するのに役立つコメントを追加します。
main.go
package main import "fmt" func main() { // defer statement is executed, and places // fmt.Println("Bye") on a list to be executed prior to the function returning defer fmt.Println("Bye") // The next line is executed immediately fmt.Println("Hi") // fmt.Println*("Bye") is now invoked, as we are at the end of the function scope }
defer
を理解するための鍵は、defer
ステートメントが実行されると、遅延関数への引数がすぐに評価されることです。 defer
が実行されると、関数が戻る前に呼び出されるリストに、それに続くステートメントが配置されます。
このコードは、defer
が実行される順序を示していますが、Goプログラムを作成するときに使用される一般的な方法ではありません。 defer
を使用して、ファイルハンドルなどのリソースをクリーンアップしている可能性が高くなります。 次にそれを行う方法を見てみましょう。
defer
を使用してリソースをクリーンアップする
defer
を使用してリソースをクリーンアップすることは、Goでは非常に一般的です。 まず、ファイルに文字列を書き込むが、defer
を使用してリソースのクリーンアップを処理しないプログラムを見てみましょう。
main.go
package main import ( "io" "log" "os" ) func main() { if err := write("readme.txt", "This is a readme file"); err != nil { log.Fatal("failed to write file:", err) } } func write(fileName string, text string) error { file, err := os.Create(fileName) if err != nil { return err } _, err = io.WriteString(file, text) if err != nil { return err } file.Close() return nil }
このプログラムには、最初にファイルの作成を試みるwrite
という関数があります。 エラーがある場合は、エラーを返し、関数を終了します。 次に、指定されたファイルに文字列This is a readme file
を書き込もうとします。 エラーを受け取った場合は、エラーを返し、関数を終了します。 次に、関数はファイルを閉じて、リソースをシステムに解放しようとします。 最後に、関数はnil
を返し、関数がエラーなしで実行されたことを示します。
このコードは機能しますが、微妙なバグがあります。 io.WriteString
の呼び出しが失敗した場合、関数はファイルを閉じずにリソースをシステムに解放せずに戻ります。
別のfile.Close()
ステートメントを追加することで問題を修正できます。これは、defer
のない言語でこれを解決する方法です。
main.go
package main import ( "io" "log" "os" ) func main() { if err := write("readme.txt", "This is a readme file"); err != nil { log.Fatal("failed to write file:", err) } } func write(fileName string, text string) error { file, err := os.Create(fileName) if err != nil { return err } _, err = io.WriteString(file, text) if err != nil { file.Close() return err } file.Close() return nil }
これで、io.WriteString
の呼び出しが失敗した場合でも、ファイルを閉じます。 これは比較的簡単に見つけて修正できるバグでしたが、より複雑な機能を備えていたため、見逃されていた可能性があります。
file.Close()
に2番目の呼び出しを追加する代わりに、defer
ステートメントを使用して、実行中にどのブランチが実行されるかに関係なく、常にClose()
を呼び出すことができます。
defer
キーワードを使用するバージョンは次のとおりです。
main.go
package main import ( "io" "log" "os" ) func main() { if err := write("readme.txt", "This is a readme file"); err != nil { log.Fatal("failed to write file:", err) } } func write(fileName string, text string) error { file, err := os.Create(fileName) if err != nil { return err } defer file.Close() _, err = io.WriteString(file, text) if err != nil { return err } return nil }
今回は、defer file.Close()
というコード行を追加しました。 これは、関数write
を終了する前にfile.Close
を実行する必要があることをコンパイラーに通知します。
これで、コードを追加して、将来関数を終了する別のブランチを作成した場合でも、常にファイルをクリーンアップして閉じることが保証されました。
ただし、延期を追加することにより、さらに別のバグを導入しました。 Close
メソッドから返される可能性のあるエラーをチェックしなくなりました。 これは、defer
を使用すると、戻り値を関数に返す方法がないためです。
Goでは、プログラムの動作に影響を与えることなく、Close()
を複数回呼び出すことは安全で受け入れられている方法と見なされています。 Close()
がエラーを返す場合は、最初に呼び出されたときにエラーが返されます。 これにより、関数の実行の成功パスで明示的に呼び出すことができます。
defer
Close
の呼び出しと、エラーが発生した場合のレポートの両方を行う方法を見てみましょう。
main.go
package main import ( "io" "log" "os" ) func main() { if err := write("readme.txt", "This is a readme file"); err != nil { log.Fatal("failed to write file:", err) } } func write(fileName string, text string) error { file, err := os.Create(fileName) if err != nil { return err } defer file.Close() _, err = io.WriteString(file, text) if err != nil { return err } return file.Close() }
このプログラムの唯一の変更は、file.Close()
を返す最後の行です。 Close
の呼び出しでエラーが発生した場合、これは呼び出し元の関数に期待どおりに返されるようになりました。 defer file.Close()
ステートメントもreturn
ステートメントの後に実行されることに注意してください。 これは、file.Close()
が2回呼び出される可能性があることを意味します。 これは理想的ではありませんが、プログラムに副作用を引き起こさないため、許容できる方法です。
ただし、WriteString
を呼び出すときなど、関数の早い段階でエラーを受け取った場合、関数はそのエラーを返し、延期されたためfile.Close
も呼び出そうとします。 file.Close
もエラーを返す可能性がありますが(おそらくそうなるでしょう)、最初に何が悪かったのかを教えてくれる可能性が高いエラーを受け取ったため、これはもはや気にすることではありません。
これまで、単一のdefer
を使用して、リソースを適切にクリーンアップする方法を見てきました。 次に、複数のdefer
ステートメントを使用して複数のリソースをクリーンアップする方法を説明します。
複数のdefer
ステートメント
1つの関数に複数のdefer
ステートメントがあるのは通常のことです。 defer
ステートメントのみを含むプログラムを作成して、複数の遅延を導入したときに何が起こるかを見てみましょう。
main.go
package main import "fmt" func main() { defer fmt.Println("one") defer fmt.Println("two") defer fmt.Println("three") }
プログラムを実行すると、次の出力が表示されます。
Outputthree two one
defer
ステートメントを呼び出した順序とは逆であることに注意してください。 これは、呼び出される各遅延ステートメントが前のステートメントの上にスタックされ、関数がスコープを終了するときに逆に呼び出されるためです(後入れ先出し)。
関数では必要な数の遅延呼び出しを行うことができますが、それらはすべて実行されたのとは逆の順序で呼び出されることを覚えておくことが重要です。
複数のdeferが実行される順序がわかったので、複数のdeferを使用して複数のリソースをクリーンアップする方法を見てみましょう。 ファイルを開いて書き込み、もう一度開いて内容を別のファイルにコピーするプログラムを作成します。
main.go
package main import ( "fmt" "io" "log" "os" ) func main() { if err := write("sample.txt", "This file contains some sample text."); err != nil { log.Fatal("failed to create file") } if err := fileCopy("sample.txt", "sample-copy.txt"); err != nil { log.Fatal("failed to copy file: %s") } } func write(fileName string, text string) error { file, err := os.Create(fileName) if err != nil { return err } defer file.Close() _, err = io.WriteString(file, text) if err != nil { return err } return file.Close() } func fileCopy(source string, destination string) error { src, err := os.Open(source) if err != nil { return err } defer src.Close() dst, err := os.Create(destination) if err != nil { return err } defer dst.Close() n, err := io.Copy(dst, src) if err != nil { return err } fmt.Printf("Copied %d bytes from %s to %s\n", n, source, destination) if err := src.Close(); err != nil { return err } return dst.Close() }
fileCopy
という新しい関数を追加しました。 この関数では、最初にコピー元のソースファイルを開きます。 ファイルを開くときにエラーが発生したかどうかを確認します。 その場合、エラーをreturn
して、関数を終了します。 それ以外の場合は、defer
開いたばかりのソースファイルを閉じます。
次に、宛先ファイルを作成します。 ここでも、ファイルの作成中にエラーが発生したかどうかを確認します。 その場合、そのエラーをreturn
して、関数を終了します。 それ以外の場合は、宛先ファイルのdefer
Close()
も使用します。 これで、関数がスコープを終了するときに呼び出される2つのdefer
関数ができました。
両方のファイルを開いたので、ソースファイルから宛先ファイルへのデータをCopy()
します。 それが成功した場合、両方のファイルを閉じようとします。 いずれかのファイルを閉じようとしてエラーが発生した場合は、エラーをreturn
して、関数スコープを終了します。
defer
もClose()
を呼び出しますが、ファイルごとにClose()
を明示的に呼び出すことに注意してください。 これは、ファイルを閉じるときにエラーが発生した場合に、エラーを報告するためです。 また、何らかの理由で関数がエラーで早期に終了した場合、たとえば2つのファイル間でコピーに失敗した場合でも、各ファイルは遅延呼び出しから適切に閉じようとします。
結論
この記事では、defer
ステートメントと、それを使用してプログラム内のシステムリソースを適切にクリーンアップする方法について学習しました。 システムリソースを適切にクリーンアップすると、プログラムのメモリ使用量が減り、パフォーマンスが向上します。 defer
の使用場所の詳細については、パニックの処理に関する記事を読むか、Goシリーズのコーディング方法全体を参照してください。