Goでの延期を理解する

提供:Dev Guides
移動先:案内検索

序章

Goには、ifswitchforなどの他のプログラミング言語に見られる一般的な制御フローキーワードの多くがあります。 他のほとんどのプログラミング言語にはないキーワードの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して、関数を終了します。 それ以外の場合は、宛先ファイルのdeferClose()も使用します。 これで、関数がスコープを終了するときに呼び出される2つのdefer関数ができました。

両方のファイルを開いたので、ソースファイルから宛先ファイルへのデータをCopy()します。 それが成功した場合、両方のファイルを閉じようとします。 いずれかのファイルを閉じようとしてエラーが発生した場合は、エラーをreturnして、関数スコープを終了します。

deferClose()を呼び出しますが、ファイルごとにClose()を明示的に呼び出すことに注意してください。 これは、ファイルを閉じるときにエラーが発生した場合に、エラーを報告するためです。 また、何らかの理由で関数がエラーで早期に終了した場合、たとえば2つのファイル間でコピーに失敗した場合でも、各ファイルは遅延呼び出しから適切に閉じようとします。

結論

この記事では、deferステートメントと、それを使用してプログラム内のシステムリソースを適切にクリーンアップする方法について学習しました。 システムリソースを適切にクリーンアップすると、プログラムのメモリ使用量が減り、パフォーマンスが向上します。 deferの使用場所の詳細については、パニックの処理に関する記事を読むか、Goシリーズのコーディング方法全体を参照してください。