protobufのVarintを64bitから32bitに変更したときの挙動

protobufのVarintProtocol Buffers - Encodingで定義されるように、32-bit(int32, uint32, sint32)や64-bit(int64, uint64, sint64)のどちらも含まれています。 Varintエンコーディングは、その値によってbyte長が変わりますが(負数は-1のような小さい数でも最上位bitが1になりエンコードした結果長くなってしまうので非推奨となっている)、64-bitでエンコードされた値を32-bitで読み込んだ場合、どのような動作になるのか気になったので試してみました。

なお、Varintには、boolenumも含まれていますが、 その変換については以下の記事で検証したのため、興味があればそちらをご覧ください。

cipepser.hatenablog.com

cipepser.hatenablog.com

結論から言うと仕様に、以下のように書いてあるので、処理系の実装依存と思われますが、Goで実際に試してみたメモとして残します。

If a number is parsed from the wire which doesn't fit in the corresponding type, you will get the same effect as if you had cast the number to that type in C++ (e.g. if a 64-bit number is read as an int32, it will be truncated to 32 bits).

環境

❯ go version
go version go1.10.3 darwin/amd64

❯ protoc --version
libprotoc 3.6.0

検証(uint64 -> uint32)

uint64math.MaxUint32よりも大きくなる値をuint32で読み込んでみて動作を検証します。
(最後にintも検証結果も記載しますが、コードも含めてすべて記載すると長いのでuintのみとします)

今回の例では、読み込む値をUserがもつIdとし、以下のように.protoを定義します。

syntax = "proto3";
package max;

message User {
  uint64 id = 1;
}

書き出し

次に書き出し用のコードです。 math.MaxUint32よりも大きくなる値を0b1111111111111111111111111111111100000000000000000000000000000000とします。 これは前半32-bitが1で後半32-bitが0となる値で、math.MaxUint64 - math.MaxUint32で生成します。この数字を使うことでuint32で読み込んだときに後半32-bitだけに切り詰められる(0になる)のか、math.MaxUint32より大きくオーバーフローしてしまうのか確認します。

// MaxWrite.go
package main

import (
    "io/ioutil"
    "log"
    "math"

    pb "github.com/cipepser/protobuf-sample/max"
    "github.com/golang/protobuf/proto"
)

func main() {
    user := &pb.User{
        Id: math.MaxUint64 - math.MaxUint32,
    }

    if err := write("./max/uint.bin", user); err != nil {
        log.Fatal(err)
    }
}

func write(file string, user *pb.User) error {
    out, err := proto.Marshal(user)
    if err != nil {
        return err
    }
    if err := ioutil.WriteFile(file, out, 0644); err != nil {
        return err
    }
    return nil
}

読み込み

読み込み用のコードです。

// MaxRead.go
package main

import (
    "fmt"
    "io/ioutil"
    "log"

    pb "github.com/cipepser/protobuf-sample/max"
    "github.com/golang/protobuf/proto"
)

func main() {
    if err := read("./max/uint.bin"); err != nil {
        log.Fatal(err)
    }
}

func read(file string) error {
    in, err := ioutil.ReadFile(file)
    if err != nil {
        return err
    }
    user := &pb.User{}

    if err := proto.Unmarshal(in, user); err != nil {
        return err
    }
    fmt.Printf("0d%d\n0b%b\n", user.Id, user.Id)
    return nil
}

10進数(0d)と2進数(0b)でuser.Idを標準出力します。 試しに実行してみると、以下のように正常にデコードできていることがわかります。

❯ go run MaxRead.go
0d18446744069414584320
0b1111111111111111111111111111111100000000000000000000000000000000

.protouint32に変更する

では、uint32に変更して、上記で出力したuint64のバイナリを読み込みましょう。

syntax = "proto3";
package max;

message User {
  uint32 id = 1;
}

protocしてから読み込みます。

go run MaxRead.go
0d0
0b0

0になったので、後半32-bitだけに切り詰められたようです。

結果

同様の検証をint64 -> int32sint64 -> sint32でもやってみた結果も含めて、まとめると以下のようになりました。

検証パターン(.proto) 読み込む値 64-bitのまま読み込み 32-bitにして読み込み
uint64 -> uint32 math.MaxUint64 - math.MaxUint32 0d18446744069414584320
0b1111111111111111111111111111111100000000000000000000000000000000
0d0
0b0
int64 -> int32 math.MaxInt64 - math.MaxInt32 - 1 <<31 0d9223372032559808512
0b111111111111111111111111111111100000000000000000000000000000000
0d0
0b0

結果、uintでもintでも同様に後半32-bitに切り詰められる結果となりました。

余談

intのほうで1 << 31を引いているのですが、最初、math.MaxInt64 - math.MaxInt32としていました。 符号付き整数なので、これだと下位31-bitしか0にできず、この値をint32として読み込むと以下のようになってしまいます。

0d-2147483648
0b-10000000000000000000000000000000

検証としては、下位32-bitを残して切り詰める結果から相違ないので、参考としてこの結果も載せておきます。

ちなみにmath.MinInt64 - math.MinInt32を読み書きしても上記と同じ結果になります(2進数で見たときに同じ値になるので)。

References