Posted at

Goにデストラクタがないのをなんとかしたい


Goの真実

Goにはコンストラクタもデストラクタもない


ので、便宜上 コンストラクタに当たる関数は 構造体名の頭にNewのプリフィクスを入れた関数である


C言語だと init_hoge とするのが NewHogeになっただけだ


そして、そのNew関数は構造体のポインタを返す(Cでも呼び方わすれたが構造体をClassっぽく書くときに同じことをする)


結局C言語なのだ(念のため、C++ではない)

そしてデストラクタがないので、呼び出し側が終了関数を明示的に呼ばなければならない


Cだと delete_hoge とか release_hoge、teardown_hoge・・・ 呼び名が統一されてる覚えがない


Goにもデストラクタとして呼び名が統一されている気がしない


つまり、よく呼び忘れてメモリリークする!!

[https://play.golang.org/p/i6-vTaTVx9Z]

package main

import (
"fmt"
)

type File struct{
filename string
}

func NewFile(f string) *File{
fmt.Printf("File{%s} new&open\n", f)
return &File{f}
}

func(s *File)Read(){
fmt.Printf("File{%s} read\n", s.filename)
}

func(s *File)Close(){
fmt.Printf("File{%s} close\n", s.filename)
}

func main() {
fmt.Println("main start")

file := NewFile("test")
file.Read()

fmt.Println("main finish")

}

main start
File{test} new&open
File{test} read
main finish

上記のコードはRAII原則にのっとり、New時にリソースをOpenしているが


残念なことにライブラリを使う人がClose()を呼び忘れたため、リソースリークしている


defer

いちおうGoにはdeferがあり、関数が終了した時に実行されるファイナライザを書くことができる

[https://play.golang.org/p/LPEm7oDSf2M]

...

file := NewFile("test")
defer file.Close()
file.Read()
...

main start
File{test} new&open
File{test} read
main finish
File{test} close

これは便利だが、このdeferはライブラリを使う側の人間が忘れたらCloseしてくれない


構造体の方でdeferを入れたい

そう考えるかもしれないが、deferはあくまで、関数の終了時のファイナライザだ


コンストラクタにdeferを入れると当然コンストラクトした後にすぐCloseが走る

[https://play.golang.org/p/aav2psenFxF]


...
func NewFile(f string) *File{
// file Open処理をする
fmt.Printf("File{%s} new&open\n", f)
file := File{f}
defer file.Close()
return &file
}
...

main start
File{test} new&open
File{test} close
File{test} read
main finish


runtime.SetFinalizer

見るからに悪手だが、ランタイムライブラリに、ガーベージコレクションが走った時に実行されるコールバックがある


x:= "hoge"
runtime.SetFinalizer(&x, func(x *string){fmt.Println("SetFinalizer")})

// 上記だと即終了しGCが走らないので故意にGCを走らせ待機させる

と、上記では xがGCされる時に 次のラムダ式が呼ばれる。ラムダ式の引数はGCされるオブジェクトのポインタだ

[https://play.golang.org/p/QYkVhC1uQKP]

これを構造体に当てはめられないか?


コンストラクタでSetFinalizerする

コンストラクタで構造体を作成し、ポインタを使う側に返すが、このポインタにSetFinalizerを設定し


構造体がGCされるときにCloseしてみよう


念のためインスタンスを複数作る

[https://play.golang.org/p/LL3ejYYcowe]


package main
import (
"fmt"
"runtime"
"time"
)

type File struct{
filename string
}

func NewFile(f string) *File{
// file Open処理をする
fmt.Printf("File{%s} new&open\n", f)
file := File{f}
runtime.SetFinalizer(&file, func(f *File){f.Close()})

return &file
}

func(s *File)Read(){
fmt.Printf("File{%s} read\n", s.filename)
}

func(s *File)Close(){
fmt.Printf("File{%s} close\n", s.filename)
}

func(s *File)Nop(){
fmt.Printf("File{%s} nop\n", s.filename)
}

func hoge(){
file := NewFile("test")
file.Read()
file2 := NewFile("test2")
file2.Read()
file3 := NewFile("test3")
file3.Nop()
}

func main() {
fmt.Println("main start")

hoge()
runtime.GC()
time.Sleep(1 * time.Second)

fmt.Println("main finish")
}

main start
File{test} new&open
File{test} read
File{test2} new&open
File{test2} read
File{test3} new&open
File{test3} nop
File{test3} close
File{test2} close
File{test} close
main finish

綺麗に動いてしまった・・・


レシーバーにSetFinalizerする

先ほどはコンストラクタにSetFinalizerをしたので、たぶん確実にFinalizerが走るが


特定メソッドで、レシーバーに対してSetFinalizerしたらどうなるか?


たとえばReadメソッドでやってみる


func(s *File)Read(){
runtime.SetFinalizer(s, func(f *File){f.Close()})
fmt.Printf("File{%s} read\n", s.filename)
}

main start
File{test} new&open
File{test} read
File{test2} new&open
File{test2} read
File{test3} new&open
File{test3} nop
File{test2} close
File{test} close
main finish

すばらしい、意図したとおりに動いた(file3はReadメソッドを読んでないのでFinalizeされてない)

使うシーンがあるか不明だが、例えばOpenメソッドを呼んだ時だけCloseのFinalizerを設定


という使い方も不可能ではなさそうだ


でもね

GCされるまでは開放されないので、もし長時間GCされずに存在していたら


その間ファイルを開きっぱなしだったり、ネットワークリソースを開放しない事になる


だから、SetFinalizerでデストラクターをするのは、よくない事は明確である・・

どこを間違えたかと思ったが、Goはオブジェクト指向言語ではないというのが答えだ


本当は関数型のように書くべきなんだろうか?


Callback にすべきではないか?

コンストラクタでdeferしても、コールバックで続きの処理を行えば


コンストラクタのコンテキスト内なので問題ないはずだ・・・

[https://play.golang.org/p/1ZBphVFMHKM]


package main
import (
"fmt"
"runtime"
"time"
)

type File struct{
filename string
}

func NewFile(f string, cb func(file *File)*File) *File{
// file Open処理をする
fmt.Printf("File{%s} new&open\n", f)
file := File{f}
defer file.Close(nil)

if(cb != nil){
return cb(&file)
}
return &file
}

func(s *File)Read(cb func(file *File)*File)*File{
fmt.Printf("File{%s} read\n", s.filename)
if(cb != nil){
return cb(s)
}
return s
}

func(s *File)Close(cb func(file *File)*File)*File{
fmt.Printf("File{%s} close\n", s.filename)
if(cb != nil){
return cb(s)
}
return s
}

func(s *File)Nop(cb func(file *File)*File)*File{
fmt.Printf("File{%s} nop\n", s.filename)
if(cb != nil){
return cb(s)
}
return s
}

func hoge(){
_ = NewFile("test", func(file *File)*File{
file.Read(func(file *File)*File{
fmt.Println("callback hell")
return file
})
return file
})
}

func main() {
fmt.Println("main start")

hoge()
runtime.GC()
time.Sleep(1 * time.Second)

fmt.Println("main finish")

}

main start
File{test} new&open
File{test} read
callback hell
File{test} close
main finish

やったぜ!!(やりたくない


もう少し実用的に

ReadやWriteの引数ちゃんと作って、もう少し実用的にします


エラーも返さないとね(エラー処理は省略

[https://play.golang.org/p/XbAWauNUNyZ]


package main

import (
"fmt"
"runtime"
"time"
)

type File struct {
filename string
}

func NewFile(f string, cb func(file *File) (*File, error)) (*File, error) {
// file Open処理をする
fmt.Printf("File{%s} new&open\n", f)
file := File{f}
defer Close(&file, nil)

if cb != nil {
return cb(&file)
}
return &file, nil
}

func Read(file *File, len int, cb func(file *File, readed *string) (*File, error)) (*File, error) {
fmt.Printf("File{%s} read\n", file.filename)
data := "readed"

if cb != nil {
return cb(file, &data)
}
return file, nil
}

func Write(file *File, data *string, cb func(file *File, written int) (*File, error)) (*File, error) {
written := len(*data)
fmt.Printf("File{%s} write {%s} size={%d}\n", file.filename, *data, written)

if cb != nil {
return cb(file, written)
}
return file, nil
}

func Close(file *File, cb func(file *File) (*File, error)) (*File, error) {
fmt.Printf("File{%s} close\n", file.filename)
if cb != nil {
return cb(file)
}
return file, nil
}

func Nop(file *File, cb func(file *File) (*File, error)) (*File, error) {
fmt.Printf("File{%s} nop\n", file.filename)
if cb != nil {
return cb(file)
}
return file, nil
}

func hoge() {
_, _ = NewFile("test", func(file *File) (*File, error) {
Read(file, 5, func(file *File, readed *string) (*File, error) {
fmt.Printf("READ{%s}\n", *readed)
data :="hogehoge"
Write(file, &data, func(file *File, written int) (*File, error) {
fmt.Printf("WRITE [%d]bytes\n", written)
return file, nil
})

return file, nil
})
return file, nil
})

}

func main() {
fmt.Println("main start")

hoge()
runtime.GC()
time.Sleep(1 * time.Second)

fmt.Println("main finish")

}

main start
File{test} new&open
File{test} read
READ{readed}
File{test} write {hogehoge} size={8}
WRITE [8]bytes
File{test} close
main finish

ライブラリを使う側からはもうCloseの事考えなくてよくなりました


やったね!!

しかし、コールバック地獄、エラー処理の場所・・・もっと考えなければならない事が増えてしまいました

しかもGoはJavaScriptやC#のような、クロージャを簡単に書く書式もないので、まいかいfunc ()と大変


コールバック必要なのはNewだけだったんや!

別に非同期プログラミングのようなコールバックする必要はない


GoはGoroutineという素晴らしい仕組みがあるので、同期っぽく書けばいい


わざわざ人間に不親切なコールバック地獄にする必要がない

コールバックを使った理由はNew関数を抜けるときにdeferdでCloseし忘れを防ぎたい


処理が終わるまでNew関数を抜けないためだ


ReadやWriteまでコールバックする必要がない。Newだけでよかったんだ

[https://play.golang.org/p/WtixVskDI7R]


package main

import (
"fmt"
)

type File struct {
filename string
}

func NewFile(f string, cb func(file *File)error) ( error) {
// file Open処理をする
fmt.Printf("File{%s} new&open\n", f)
file := File{f}
defer file.Close()
err := cb(&file)

return err
}

func (s *File) Read(){
fmt.Printf("File{%s} read\n", s.filename)
}

func (s *File) Close() {
fmt.Printf("File{%s} close\n", s.filename)
}

func (s *File) Nop() {
fmt.Printf("File{%s} nop\n", s.filename)
}

func hoge() {
_ = NewFile("test", func(file *File)error{
file.Read()
return nil
})
}

func main() {
fmt.Println("main start")

hoge()

fmt.Println("main finish")

}

main start
File{test} new&open
File{test} read
File{test} close
main finish

当然ちゃんとクローズされるし、コールバックは初回のNewだけなので、コードの見通しも悪くない


ただ、コールバックとして全てを中にいれなければいけない


オブジェクト指向脳だと自由に構造体を使い回したいと思うだろう


コールバックとオブジェクト指向のハイブリッド

Goにはメソッドのオーバーライドやデフォルト引数がない


可変長引数で処理できるが今回はそこは手抜きで、CallbackがnilだとClose管理はライブラリでは行わない

[https://play.golang.org/p/M6g-dqUz86P]


package main

import (
"fmt"
)

type File struct {
filename string
}

func NewFile(f string, cb func(file *File) error) (*File, error) {
// file Open処理をする
fmt.Printf("File{%s} new&open\n", f)
file := File{f}
var err error = nil
if cb != nil {
defer file.Close()
err = cb(&file)
}

return &file, err
}

func (s *File) Read() {
fmt.Printf("File{%s} read\n", s.filename)
}

func (s *File) Close() {
fmt.Printf("File{%s} close\n", s.filename)
}

func (s *File) Nop() {
fmt.Printf("File{%s} nop\n", s.filename)
}

func hoge() {
// クロージャで自動的にCloseする
_,_ = NewFile("test", func(file *File) error {
file.Read()
return nil
})

// クローズは使う側が責任持つ
file2, _ := NewFile("test2", nil)
defer file2.Close()
file2.Read()

// 忘れると当然リークします
file3, _ := NewFile("test3", nil)
// defer file3.Close()
file3.Read()
}

func main() {
fmt.Println("main start")

hoge()

fmt.Println("main finish")

}

main start
File{test} new&open
File{test} read
File{test} close
File{test2} new&open
File{test2} read
File{test3} new&open
File{test3} read
File{test2} close
main finish


結局どれがいいのか?

Finalizerは動作はするが、GCが走るまで開放が行われないので実用的ではない


コールバック地獄はダメ!絶対!(おそらくGoはGoroutineで同期的に書く設計なので、promise/futureやasync/awaitのようなコールバック解決ライブラリはあまり出ない)


Newのコールバックは場合によっては使えると思う


でも基本は、ライブラリを使う側が責任もって後始末しなければいけないっぽい

ハイブリッドは良さそう