背景
我々プログラマは、しばしば異なる言語処理系の間において、ソースコードを移植することがあります。
syntax (文法) については、compiler が保証してくれるので、ハマることはあまりありません。
しかし、evaluation (評価) については、同じような syntax において、
言語ごとに評価結果が異なることがあり、時々ハマることがあるように思います。
今回は、C から Go へ移植する際に、言語仕様についての差異によって、
意図した結果を得るのにややハマった話を書きます。
「ぐぬぬ…どう見ても同じように移植したのに…」
例えば、こんな C コードを、
// This is main.c
#include <stdio.h>
void main () {
unsigned char a;
unsigned char b;
unsigned short c;
a = 0x12;
b = 0x34;
c = 0x0000;
c |= (unsigned short)(a << 8);
c |= (unsigned short)(b << 0);
printf("c is 0x%04X\n", c);
}
こんな Go のコードへ移植しました。
package main
import "fmt"
func main() {
var a uint8
var b uint8
var c uint16
a = 0x12
b = 0x34
c = 0x0000;
c |= uint16(a << 8)
c |= uint16(b << 0)
fmt.Printf("c is 0x%04X\n", c)
}
二つの変数のビットを結合する、よくある手続きです。
「よし、C のキャストもちゃんと同じように書いたし、
どこからどう見ても全く同じコードやからうまくいくはずや!!」
実行しました。
$ gcc -o main.exe main.c
$ main.exe
c is 0x1234
$ go build -o main.exe
$ main.exe
c is 0x0034
「おや…、実行結果が異なるぞ…??」
「移植元の C ソースはキャストもちゃんと書いてあるし、
Go はそもそも型やらキャストやらちゃんとしてないと怒られるはずや…。
わからん、わからんぞ…」
後になって思えば、当然のことなのですが (実際はもっと複雑なプログラムでしたが)、
私はコレで30分程度首をひねってしまいました。
「ん…?? これは…」
30分首をひねっていると(実際には必死でデバッグしていましたが)…、
「あ、C のこの a << 8
は、int に拡張されるんやった」
「C のプログラムがうまくいくのは、整数の演算は暗黙に int へ拡張されるからや…」
「でも、ナウい Go にはそんな暗黙の動作なんてないんや…」
c |= (unsigned short)(a << 8);
c |= (unsigned short)(b << 0);
そうです。
C 言語は、言語が誕生した時代背景や、
言語処理系が CPU へ依存した規格となっていることのせいで、
「未定義動作」や、暗黙の型キャスト、整数の拡張などを頻繁に行います。
結局、Go において上記のコードは、aよくないコードになっていて、
ささいな思い込みから、Go のコードが動かない理由に気づくのが遅れてしまいました。
つまり、問題の箇所は、Go は以下のように型の拡張をa明示的に書かなければなりませんでした。
c |= uint16(a) << 8 // 8bit 変数を `<<` 演算が評価される前に 16bit 変数へ型キャスト
c |= uint16(b) << 0
これで、無事二つのプログラムは等しく評価されるようになりました。
$ gcc -o main.exe main.c
$ main.exe
c is 0x1234
$ go build -o main.exe
$ main.exe
c is 0x1234
言語仕様を確かめる
というわけで、ここまでは、私の記憶で対処をしてきました。
そもそも、それぞれの言語仕様はどうなっているのでしょうか。
今回は実際に使用した処理系 (C
, Go
) の言語仕様に、
今回のような処理が行われる場合、どのようにふるまうと定義されているのかを見てみます。
C
以下は、ISO/IEC 9899 (C 言語の国際規格:C99)より参照した、shift 演算の仕様です。
# Bit shift の動作について
6.5.7 Bitwise shift operators
(中略)
Semantics
3 The integer promotions are performed on each of the operands. The type of the result is
that of the promoted left operand. If the value of the right operand is negative or is
greater than or equal to the width of the promoted left operand, the behavior is undefined.
(筆者訳)
右オペランドが、「拡張された」左オペランドのビット幅を超える場合、
また、第二オペランドが負の値である場合、動作は未定義となります。
# 整数拡張について
If an int can represent all values of the original type, the value is converted to an int;
otherwise, it is converted to an unsigned int. These are called the integer
promotions.
(筆者訳)
int が元の型のすべての値を表すことができる場合、値は int に変換されます。
それ以外の場合は、unsigned int に変換されます。 これらは "integer promotion" (整数拡張) と呼ばれます。
以下は、Go の referenceより参照した、shift 演算の (整数値の演算の) 仕様です。
Go
Integer overflow
For unsigned integer values, the operations +, -, *, and << are computed modulo 2n,
where n is the bit width of the unsigned integer's type. Loosely speaking,
these unsigned integer operations discard high bits upon overflow, and programs may rely on "wrap around".
(筆者訳)
(前略) 符号なし整数演算は、オーバーフローした場合上位ビットを切り捨て、「ラップアラウンド」します。
(8bit 整数において、 0xFF + 0x01 = 0x00 となるということ)
やはり、shift 演算において、 C は暗黙に int へ拡張するとされているのに対して、
Go は暗黙の動作はなく明確に動作を定義しています。
まとめ
時折、better C とも言われる Go ですが、今回の例のように、実際の挙動には構文レベルでは明示的にならないものもあります。
そのようなときは、一次的なドキュメントが読みやすい形で提供されているならば、
言語仕様を参照するのが、手っ取り早く、確実で、かつ言語仕様への理解を深めることにつながるとおもいました。
reference
open-std.org ISO/IEC 9899:TC3
The Go Programming Language Specification