Go言語でDeep Copyする方法

この記事はマネーフォワードアドベントカレンダー2021🎄の22日目の記事です。

経理財務プロダクト本部でソフトウェアエンジニアをしている鈴木です。
開発を行っていてふとGo言語でDeep Copyをやりたいと考え、どのような実現方法があるか調査しました。

Deep Copy

Deep Copyとはオブジェクトを別の実体としてコピーすることです。
詳しくはGoogle検索してもらえればわかりやすい例がたくさん出てきます。
Deep Copyを行うためのハードルとしてGo言語ではポインタ型やSlice、Map型の存在があります。
これらの型は値としてメモリアドレス値を保持しており、代入演算子=にてコピーを行ってもコピーされるものはメモリアドレス値であるため、同じ実体を指し示してしまいます。
これらの型に対して特別な操作でコピーする必要があるため単純な代入演算子=だけではディープコピーが実現できません。

Go言語でDeep Copy

Go言語でポインタ型やslice, mapのディープコピーを行うためにはGo言語では代入=ではなく、それらの型に合わせた操作を行う必要があります。

type Person struct {
    Name string
    Age  int
}

var a *Person = &Person{Name: "A", Age: 10}
var b *Person = &Person{}
*b = *a // ポインタが指す実体をコピーする
b.Name = "B" // BのNameを変更する
fmt.Printf("address: %p, A: %#v\n", a, a)
fmt.Printf("address: %p, B: %#v\n", b, b)

// 結果 AとBは別のメモリアドレスを持つ
address: 0xc00000c5a0, A: &main.Person{Name:"A", Age:10} 
address: 0xc00000c5b8, B: &main.Person{Name:"B", Age:10} // BだけNameが変更される
as := []int{1, 2, 3, 4, 5}
bs := make([]int, len(as)) // 少なくともas以上の容量を持たなくてはコピーできない
copy(bs, as)
bs = append(bs, 6) // bsのみappendする
fmt.Printf("address: %p, A: %#v\n", as, as)
fmt.Printf("address: %p, B: %#v\n", bs, bs)

// 結果 
address: 0xc0000ba030, A: []int{1, 2, 3, 4, 5}
address: 0xc0000e8000, B: []int{1, 2, 3, 4, 5, 6} // Bだけがappendされている

DeepCopyライブラリ

このようにポインタ型やslice, mapに対してDeepCopyを行うためには通常の代入とは違う操作が必要となるわけですが、これらを複数持つような複雑な構造体を定義すると、そのDeepCopyメソッドを手作業で記述することはやや骨が折れます。
そこでGo言語ではいくつかのDeepCopyライブラリが有志によって作成されています。

Go言語でDeepCopyを実現する方法の一例

  1. シリアライザを使って実体をbyte列に変換し、再び別の実体としてデシリアライズする。
  2. リフレクションを使ってコピーする
  3. ライブラリにてDeep Copyメソッドを自動生成する
  4. (1つ1つ手作業でコピーする)

4はライブラリを用いたものではないため括弧付きにしています。
それぞれの方法について解説します。

まず以下のような構造体を用意します。

type Member struct {
    Name   string
    Age    int
    secret int
    Family []string
    Job    *Job
}

type Job struct {
    Name   string
    Salary int
}

// 構造体のポインタに実体を作成
var member1 *Member = &Member{
    Name:   "MF1",
    Age:    30,
    secret: 100,
    Family: []string{
        "MF2", "MF3",
    },
    Job: &Job{
        Name:   "Software Engineer",
        Salary: 500,
    },
}

シリアライザを利用したDeep Copy

以下のようなメソッドを用意します。
シリアライザとしてJSONエンコーダーを使うかGobエンコーダーを使うかの違いなので結果は同じです。

func deepcopyJson(src interface{}, dst interface{}) (err error) {
    b, err := json.Marshal(src)
    if err != nil {
        return err
    }

    err = json.Unmarshal(b, dst)
    if err != nil {
        return err
    }
    return nil
}

func deepcopyGob(src interface{}, dst interface{}) (err error) {
    b := new(bytes.Buffer)
    enc := gob.NewEncoder(b)
    err = enc.Encode(src)
    if err != nil {
        return err
    }
    dec := gob.NewDecoder(b)
    err = dec.Decode(dst)
    if err != nil {
        return err
    }
    return nil
}

// DeepCopyテスト
func test2(member1 *Member) {
    member2 := &Member{}
    deepcopyJson(member1, member2)

    member2.Age = 35
    member2.Family = append(member1.Family, "MF4")
    member2.Job.Salary = 700
    fmt.Printf("Member1: %#v, Job1:%#v\n", member1, member1.Job)
    fmt.Printf("Member2: %#v, Job1:%#v\n", member2, member2.Job)
}

// 結果 プライベートフィールド以外はDeepCopyされています
Member1: &main.Member{Name:"MF1", Age:30, secret:100, Family:[]string{"MF2", "MF3"}, Job:(*main.Job)(0xc0000ae570)}, Job1:&main.Job{Name:"Software Engineer", Salary:500}
Member2: &main.Member{Name:"MF1", Age:35, secret:0, Family:[]string{"MF2", "MF3", "MF4"}, Job:(*main.Job)(0xc0000ae6a8)}, Job1:&main.Job{Name:"Software Engineer", Salary:700}

リフレクションを用いたディープコピー

jinzhu/copierというライブラリを使わせていただきました。
仕組みとしてはリフレクションを用いて構造体が持つフィールドを解析し、コピーを行っているようです。

$ go get -u github.com/jinzhu/copier 
// DeepCopyテスト
func test3(member1 *Member) {
    member2 := &Member{}
    copier.CopyWithOption(member2, member1, copier.Option{
        IgnoreEmpty: false,
        DeepCopy:    true,
    })

    member2.Age = 35
    member2.Family = append(member1.Family, "MF4")
    member2.Job.Salary = 700
    fmt.Printf("Member1: %#v, Job1:%#v\n", member1, member1.Job)
    fmt.Printf("Member2: %#v, Job1:%#v\n", member2, member2.Job)
}

// 結果 DeepCopyされていますがプライベートフィールドはコピーされないようです
Member1: &main.Member{Name:"MF1", Age:30, secret:100, Family:[]string{"MF2", "MF3"}, Job:(*main.Job)(0xc000124570)}, Job1:&main.Job{Name:"Software Engineer", Salary:500}
Member2: &main.Member{Name:"MF1", Age:35, secret:0, Family:[]string{"MF2", "MF3", "MF4"}, Job:(*main.Job)(0xc0001245d0)}, Job1:&main.Job{Name:"Software Engineer", Salary:700}

ライブラリにてDeep Copyメソッドを自動生成する

globusdigital/deep-copyというライブラリを使わせていただきました。
deep-copyコマンドにてそれぞれの構造体に合わせたDeepCopyメソッドを自動生成してくれます。

$ go get github.com/globusdigital/deep-copy
$ deep-copy -o main_deep_copy.go --type Member .
// DeepCopyテスト
func test4(member1 *Member) {
    member2 := member1.DeepCopy()

    member2.Age = 35
    member2.Family = append(member1.Family, "MF4")
    member2.Job.Salary = 700
    fmt.Printf("Member1: %#v, Job1:%#v\n", member1, member1.Job)
    fmt.Printf("Member2: %#v, Job1:%#v\n", member2, member2.Job)
}

// 結果 privateなフィールドもコピーされる
Member1: &main.Member{Name:"MF1", Age:30, secret:100, Family:[]string{"MF2", "MF3"}, Job:(*main.Job)(0xc00000c588)}, Job1:&main.Job{Name:"Software Engineer", Salary:500}
Member2: main.Member{Name:"MF1", Age:35, secret:100, Family:[]string{"MF2", "MF3", "MF4"}, Job:(*main.Job)(0xc00000c5a0)}, Job1:&main.Job{Name:"Software Engineer", Salary:700}

ベンチマーク

% go test -bench .
goos: darwin
goarch: amd64
pkg: test_deepcopy
cpu: Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz
BenchmarkMember_DeepCopyJson-16           495898              2352 ns/op
BenchmarkMember_DeepCopyGob-16             55078             21616 ns/op
BenchmarkMember_DeepCopyReflect-16        227006              5186 ns/op
BenchmarkMember_DeepCopyGenerate-16     24410784                48.37 ns/op

ベンチマークを取った感じでは以下のような順位になりました。
1. 自動生成したコピーメソッドを用いたDeepCopy 48.37 ns/op
2. JSONシリアライザを用いたDeepCopy 2352 ns/op
3. リフレクションを用いたDeepCopy 5186 ns/op
4. Gobシリアライザを用いたDeepCopy 21616 ns/op

圧倒的に自動生成コピーメソッドが速く、JSONとReflectは2倍程度の差、Gobが最遅となりました。
Gobは謳い文句としてJSONよりも速いということでしたが今回は逆の結果になりました。
(Gobの使い方が間違っているような気もしています)

結論

利用の手軽さだけを考えるとシリアライザを使う方法かリフレクションを使う方法だと考えます。
Deep Copyメソッドをライブラリで自動生成するは他の方法よりも一手間かかることや、構造体のフィールドを変更したときは再び再生成し直さなければならない等、開発において考えなければならないことが増えてしまいます。
その一方で速度面では自動生成したコピーメソッドが圧倒的に速く、DeepCopyを速度が重視される場面で用いるならばこのライブラリを用いるか、手作業でディープコピーメソッドを書くほうが良いと思います。
またシリアライザやリフレクションを用いる方法ではプライベートフィールドがコピーされなかったためその点も注意する必要があります。
自分が行いたいDeep Copyが何を重視しているか、速度、手軽さ、プライベートフィールドのコピーの観点で考えてみて最適な選択を行うのが良いと思います。

私がDeepCopyしたいと考えた理由と用いた方法

今回、私はテストに用いる複雑な構造体のDeep Copyを行いたいと考えました。
テストのみに用いるため速度は重視しておらず、手軽に使いたいことと、全てがパブリックフィールドだったためJSONシリアライザを用いる方法で実装しました。


マネーフォワードでは、エンジニアを募集しています。
ご応募お待ちしています。

【サイトのご案内】
マネーフォワード採用サイト
Wantedly
京都開発拠点

【プロダクトのご紹介】
お金の見える化サービス 『マネーフォワード ME』 iPhone,iPad Android

ビジネス向けバックオフィス向け業務効率化ソリューション 『マネーフォワード クラウド』

おつり貯金アプリ 『しらたま』

お金の悩みを無料で相談 『マネーフォワード お金の相談』

だれでも貯まって増える お金の体質改善サービス 『マネーフォワード おかねせんせい』

金融商品の比較・申し込みサイト 『Money Forward Mall』

くらしの経済メディア 『MONEY PLUS』

Pocket