6
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

and factoryAdvent Calendar 2019

Day 16

マップの中に同じ値のキーが2つ存在する!?

Last updated at Posted at 2019-12-15

TL;DR

キー値が同じ値だと思っていたら実は違っていたというオチです。

  • キー項目として、time.Time型を含んだ構造体を使用した
  • time.Time型は、==で比較してはいけない
    • 構造体を比較する場合、構造体の中にポインタ型が含まれている場合は、ポインタ値を比較する
    • time.Time型は、*time.Location型の項目を含んでいる
  • マップのキーは、==trueの場合、同値として判定する

結論は上記の通りですが、備忘録として、発生した経緯と調査の過程を下記に残したいと思います。

発覚するまで

例えば、データベースに以下のようなテーブルがあったとします。

ID 名前 登録日時
1 七尾 2017-06-29 12:00:00
2 高坂 2017-06-29 13:00:00
3 北沢 2017-06-29 14:00:00
4 矢吹 2017-06-29 15:00:00

このテーブルから、指定された回数の分だけランダムでレコードを取得し、レコードごとの抽出回数を集計する処理を実装しようとしていました。
最終的に以下のように出力する想定でした。

ID 名前 抽出回数
1 七尾 27
2 高坂 26
3 北沢 25
4 矢吹 22

その場合、以下のような処理を書けば目的は達成されるはずです。


const count = 100

type User struct {
    ID           int
    Name         string
    RegisteredAt time.Time
}

func main() {
    m := make(map[User]int)

    for i := 0; i < count; i++ {
        u := getUserRandom()
        m[u]++
    }

    for u, count := range m {
        fmt.Printf("%v %v %v\n", u.ID, u.Name, count)
    }
}

func getUserRandom() model.User {
    // データベースからランダムに1レコード取得する処理
}

上記の処理ですが、countを増やすとおかしくなることがあります。以下のような結果が出てきたりします。

ID 名前 抽出回数
1 七尾 1500
1 七尾 1200
2 高坂 1200
2 高坂 1400
3 北沢 1500
3 北沢 1000
4 矢吹 1100
4 矢吹 1100

同一IDのデータが2行出力されていますね。明らかにおかしいです。

調査

マップ上で別項目として扱われている以上、必ず何かしらの値が違うはずです。とりあえずデバッグ出力してみます。

...
    for u, count := range m {
        // fmt.Printf("%v %v %v\n", u.ID, u.Name, count)
        fmt.Printf("%#v\n", u)
    }
...
出力結果
User{ID:1, Name:"", RegisteredAt:time.Time{wall:0x0, ext:63634302000, loc:(*time.Location)(0xc00033d020)}}
User{ID:1, Name:"", RegisteredAt:time.Time{wall:0x0, ext:63634302000, loc:(*time.Location)(0xc00033dda0)}}
User{ID:2, Name:"", RegisteredAt:time.Time{wall:0x0, ext:63634305600, loc:(*time.Location)(0xc00033d020)}}
User{ID:2, Name:"", RegisteredAt:time.Time{wall:0x0, ext:63634305600, loc:(*time.Location)(0xc00033dda0)}}
User{ID:3, Name:"", RegisteredAt:time.Time{wall:0x0, ext:63634309200, loc:(*time.Location)(0xc00033d020)}}
User{ID:3, Name:"", RegisteredAt:time.Time{wall:0x0, ext:63634309200, loc:(*time.Location)(0xc00033dda0)}}
User{ID:4, Name:"", RegisteredAt:time.Time{wall:0x0, ext:63634312800, loc:(*time.Location)(0xc00033d020)}}
User{ID:4, Name:"", RegisteredAt:time.Time{wall:0x0, ext:63634312800, loc:(*time.Location)(0xc00033dda0)}}

1行目と2行目を比較すると、RegisteredAtのtime.Timeの中にあるlocのポインタ値が異なっています。

マップのキーとして同一かどうかは ==演算子で比較した結果trueかどうか で判定していると考えて良いです。
一方で、構造体の中にポインタ項目があった場合、ポインタのアドレスが同一かどうかで比較されます(参照先は比較しません)
その観点で考えると、1行目のUser != 2行目のUserであるため、異なるキー項目として扱われるのは正しい。という結論になります。

上記を踏まえた回避策としては以下の2つが考えられます

  • time.TimeのタイムゾーンをUTCに変更する
    • locnullになる
    • 1行目のUser == 2行目のUserとなり、同一キーとして扱われるようになる
  • そもそもUserをキーとして使わない
    • User.IDなどのプリミティブな値を使用する

今回についてはタイムゾーンを設定する必要があったため、後者を選択し、無事解決しました。

余談

time.Time型を==で比較してはいけないこと自体は知っていました。
if文などで条件分を記載する際であれば、仮に==をタイプした時点で気付いたとは思いますが、今回はマップを使用した関係で原因を特定するまで気付きませんでした…気をつけなければ。

データベースからデータを取得した場合、このような現象が発生した原因については詳細を調べていません。
データベースから取得してtime.Time構造体を生成する際、ロケーションは使いまわしているが、一定間隔で再生成するのでしょうか?

参考資料

Try Golang! time.Timeの等値判定で注意すること

6
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?