Help us understand the problem. What is going on with this article?

Better C - Goと整数 #golang

More than 1 year has passed since last update.

はじめに

GoはC言語やその後のオブジェクト指向言語における課題をシンプルな手法で解決しており、大変使い勝手の良い言語になっています。

具体的にはC言語と比較すると以下のような点が優れています。

  • 充実した標準ライブラリ
  • コンパイルの容易さ
  • 公式フォーマッター
  • クロスコンパイル、マルチプラットフォーム対応
  • 複数返り値によるerrnoからの脱却
  • インターフェースによるオブジェクト指向の実現
  • GC(Garbage Collection)
  • スタックやヒープを意識しないプログラミング
  • 整数仕様の整理

今回はこの中でも紹介されることが少ない整数の仕様について紹介したいと思います。

整数オーバーフローの仕様

整数オーバーフローについて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''.
符号なし整数値の場合、+、 - 、
、および<<は2nを法として計算されます.nは符号なし整数型のビット幅です。 大まかに言えば、これらの符号なし整数演算は、オーバーフロー時に上位ビットを破棄し、プログラムは「ラップアラウンド」に依存することがあります。

For signed integers, the operations +, -, , and << may legally overflow and the resulting value exists and is deterministically defined by the signed integer representation, the operation, and its operands. No exception is raised as a result of overflow. A compiler may not optimize code under the assumption that overflow does not occur. For instance, it may not assume that x < x + 1 is always true.
符号付き整数の場合、演算+、 - 、
、<<は正当にオーバーフローし、その結果の値が存在し、符号付き整数表現、演算、およびそのオペランドによって確定的に定義されます。 オーバーフローの結果、例外は発生しません。 オーバーフローが発生しないという前提で、コンパイラはコードを最適化できないことがあります。 例えば、x <x + 1が常に真であるとは仮定しないかもしれない。
https://golang.org/ref/spec#Arithmetic_operators

要約すると、符号なし整数の値は、その型で表現できる値の最大値を超えた場合にはラップアラウンドする(0に戻る)。符号あり整数の値は、例外は発生せず符号付き整数表現、演算、およびそのオペランドによって結果が決まる。ということです。

符号無し整数についてはわかりやすいと思いますが、符号付き整数については少し回りくどい表現で何を意味しているか分かりづらいかと思います。
これはGo言語仕様がC言語仕様と比較するような表現で書かれているためです。そのためC言語の仕様を簡単に紹介した後にGoの仕様を解説したいと思います。

C言語の符号あり整数のオーバーフロー

C言語では符号あり整数のオーバーフローは言語仕様として未定義であり、その時の挙動は実行環境により異なります。例外の発生、もしくは符号付き整数の任意の値になるかもしれません。

以降で1つずつ説明します。

例外について

オーバーフローの結果、例外は発生しません。

C言語では符号付き整数のオーバーフローにより例外が発生する可能性があります。
つまりシグナルを正しく制御するハンドラを実装していないとプログラムが停止してしまう恐れがあります。

しかしGoではこのような例外が発生しないためそのような心配はなく、オーバーフローによってプログラムが停止するということはありません。

オーバーフロー時の値について

符号付き整数の場合、演算+、 - 、*、<<は正当にオーバーフローし、その結果の値が存在し、符号付き整数表現、演算、およびそのオペランドによって確定的に定義されます。

C言語では符号付き整数が任意の値になるかもしれません。その理由ですが、符号付き整数のオーバーフローを実装する際に数値としてラップアラウンドするにしてもC言語の符号付数値表現には、2の補数(最小値-128)、1の補数(最小値-127)、符号付き絶対値表現(最小値-127)の3つが許されており、それぞれで最小値が異なるためラップアラウンド後の値を一意に決めることができません。

しかしGoの整数は2の補数で表現すると仕様で決められています。

Numeric types
The value of an n-bit integer is n bits wide and represented using two's complement arithmetic.
nビット整数の値はnビット幅であり、2の補数演算を使用して表される。
https://golang.org/ref/spec#Numeric_types

つまりGoでは、符号付き整数表現、演算、およびそのオペランドを確定すれば一意に値を決めることができるということです。(符号付き整数表現は2の補数と決められているので、上記の仕様に符号付き整数表現という文言を含める必要はない気もしますがGoの言語仕様は規格化されているわけではないので細かいことは気にしないようにしましょう)

最適化について

オーバーフローが発生しないという前提で、コンパイラはコードを最適化できないことがあります。 例えば、x < x + 1が常に真であるとは仮定しないかもしれない。

C言語は符号付き整数のオーバーフローを未定義にすることで最適化を実現しています。
オーバーフローがないことを前提にすることでx < x + 1は必ずtrueとなるため最適化可能です。Goではオーバーフローで値が小さくなることを言語仕様として許しているため必ず最適化できるとは限らないということを意味しています。

以上のルールによりGoでは符号付き整数型において、ラップアラウンドを意図したコードを書くことが許容されます。

しかしGoが符号付き整数のオーバーフローによるラップアラウンドを許容しているといっても、気にせずコーディングして良いというわけではありません。必ずラップアラウンドすることを考慮して実装する、入力としてラップアラウンドするような値が入ってきた場合にエラー処理をする、もしくは内部関数ならエラー処理ではなく関数仕様として入力を受け付けない(panicにする)など状況に合わせて適切な実装を心がけましょう。

定数のオーバーフロー

定数では実行時のオーバーフローは発生せずにコンパイルエラーになります。C言語でも適切なオプションを設定すると警告は出力してくれます。

Constants
Numeric constants represent exact values of arbitrary precision and do not overflow.
定数
数値定数は任意の精度の正確な値を表し、オーバーフローしません。
https://golang.org/ref/spec#Constants

fmt.Println(uint32(0xffffffff) + 1)
// tmp/sandbox270573308/main.go:6: constant 4294967296 overflows uint32

https://play.golang.org/p/sDffG9PnPb

除算の整数オーバーフロー

C言語とGoで共通する整数オーバーフローの注意点を紹介します。

加算や乗算では整数オーバーフローに気をつけてコーディングしていると思いますが、除算でも整数オーバーフローが発生することがあります。

func main() {
    min := int8(-128)
    fmt.Println(min / -1)
}
// 実行結果
// -128 (128ではない)

https://play.golang.org/p/E3lMSYBczs

これはint8の最小値-128を-1で割ると128になります。しかしint8の最大値は127であるため、ラップアラウンドが発生し、int8の最小値の-128になります。

char型がない

C言語のchar型はとても危険です。
通常shortやintは、signed short, signed intとC言語仕様として決まっていますが、charだけはsigned charかunsigned charかは未定義です。そのため文字ではなく数値としてchar型を利用する際には必ずunsignedかsignedの明示が必要となります。

Goのbyte型とuint8は符号なし、int8は符号ありと仕様で決まっているため安全です。(C言語でも数値として使う場合はuint8_tなどを使うべきでしょう)

Numeric types
uint8 the set of all unsigned 8-bit integers (0 to 255)
int8 the set of all signed 8-bit integers (-128 to 127)
byte alias for uint8
https://golang.org/ref/spec#Numeric_types

整数変換と整数拡張

C言語では、この2つの仕様がとても複雑で難解です。
Goではあることを諦めることでとてもシンプルな仕様となっています。本当にGoらしい素晴らしい決断だと思います。そうそう こういうのでいいんだよという気持ちで幸せになります。

少し長くなりますが、Go言語仕様の素晴らしさを説明するためにC言語の整数拡張の仕様について説明します。

C言語の整数拡張

整数変換の順位がintunsigned intと同じ、または低い場合にはintunsigned intが必要とされる式において拡張される。元の型のすべての値をintで表現可能な場合、その値をintに変換する。そうでない場合、unsigned intに変換する。

short a = 32767; // shortの最大値
short b = 1;

int c = a + b;
// 上記の行は以下のように処理されます。
// int c = (int)a + (int)b

short d = a + b;
// 上記の行は以下のように処理されます。
// short d = (short)((int)a + (int)b)

printf("int c = %d   ",c);
printf("short d = %d",d);
// int c = 32768   short d = -32768

https://ideone.com/nQkJ22

なぜわざわざshortであるaとbをintに整数拡張してから計算するのかというと、計算途中の値がオーバーフローすることで算術エラーが起こるのを防ぐためです。
上記の例では、もし整数拡張が行われないとするとcとdの値は同じ-32768になるはずですが、整数拡張が行われているため、a+bが実行された時点ではどちらも32768となっています。出力時の結果が異なる理由はshort dに代入する際に32768はshortの最大値32767を超えているためラップアラウンドしているためです。注意しなければいけない点はラップアラウンドはあくまでも代入時の話であり計算過程の話ではありません。

ここまでは特にC言語の仕様でも問題ありませんが、次に紹介する例では少々困ることが起きます。

C言語の整数拡張の問題点

int64_t ans = 2147483647 + 1;
printf("%lld",ans);
// -2147483648

先ほどの例と同様にオーバーフローせずに2147483648になって欲しいところですがそうはなりません。

改めてC言語仕様を振り返ると、その理由がわかります。

元の型のすべての値をintで表現可能な場合、その値をintに変換する。

この通り拡張するのはintです。計算時に代入先の変数に拡張されるわけではありません。

int64_t ans = 2147483647 + 1;
// 上記は以下のようになる。
// int64_t ans = (int64_t)((int)2147483647 + (int)1);
printf("%lld",ans);
// -2147483648

https://ideone.com/VQPLDX

計算時に変換する型はint64_tではなくintであるため演算時点でintのオーバーフローが起きてしまいます。その後にint64_tにキャストしたところで意味がありません。

上記を回避するためには以下のようなコードを書く必要があります。

int64_t ans = (int64_t)2147483647 + 1;
// 上記は以下のようになる。
// int64_t ans = (int64_t)2147483647 + (int64_t)1;
printf("%lld",ans);
// 2147483648

https://ideone.com/PrdoBv

2147483647をあらかじめint64_tと明示することで、1が整数変換により同様のint64_tになり、オーバーフローすることがなくなります。

この仕様は分かりづらいため、あまり仕様を理解していないと以下のようなコードを書いてしまうかもしれませんが間違いです。問題は代入時の型ではなく計算時の型であるためです。

int64_t ans = (int64_t)(2147483647 + 1);
printf("%lld",ans);
// -2147483648

https://ideone.com/WAlhmN

つまりC言語ではint以下の型の計算と、intより上位の型の計算でキャストの有無の実装が異なるということが起きてしまいます。

Goの整数拡張

Goの整数拡張の仕様を確認してみましょう。

整数拡張は英語でInteger Promotionと言いますが、Go言語仕様には記載されていません。
どういうことでしょうか?

C言語と同様の例をGoでも実装して確認してみたいと思います。

a := int16(32767)
b := int16(1)
var c int32
c = int32(a + b)
fmt.Println(c)
// -32768

https://play.golang.org/p/UbpMHqHc9R

整数拡張されていないことが確認できます。 どうやらa + bint16で計算されているようです。

Goの整数変換

変換の仕様は以下の通りです。

Conversions
Conversions are required when different numeric types are mixed in an expression or assignment.
異なる数値型が式または代入で混合されている場合は、変換が必要です。
https://golang.org/ref/spec#Conversions

先ほどの例と似ていますが、代入時に明示的なキャストをやめてみます。

a := int16(32767)
b := int16(1)
var c int32
c = a + b // キャストしないで代入
fmt.Println(c)
// tmp/sandbox388561942/main.go:11: cannot use a + b (type int16) as type int32 in assignment

https://play.golang.org/p/-PjNuPIlHK

C言語では、この時32768という結果になりましたが、Goではコンパイルエラーになります。

コンパイルエラーになる理由はGoでは代入時の型変換は暗黙的に行われないためです。
つまり代入時の型変換を行わないのであれば、先ほどの整数拡張も必要ありません。

Goでは異なる型の演算や代入を禁止することで、複雑な型変換や整数拡張を気にする必要をなくしています。
C言語が実現していた計算途中の値がオーバーフローすることで算術エラーが起こるのを防ぐということはできなくなっていますが、オーバーフローするのがわかっているなら1つ上の型を使えばいいわけですから、必須の機能ではなかったわけです。
本当にGoの仕様は素晴らしいですね。

定数の型

次に型を明示しない定数の挙動を見てみましょう。

var ans int64
ans = 2147483647 + 1
fmt.Println(ans)
// 2147483648

https://play.golang.org/p/nwBX5JCHRp

少し意外ですがオーバーフローしませんでした。
予想としては、定数は自動的にint(今回で言うとint32)で演算が行われてオーバーフローするかと思ったのですが・・・

定数の型が決定するまでの仕様を確認してみましょう。

Constants
Constants may be typed or untyped. Literal constants, true, false, iota, and certain constant expressions containing only untyped constant operands are untyped.
定数は型があるかもしれないし、型がないかもしれません。リテラル定数、true、false、iota、および型指定されていない定数オペランドだけを含む定数式は型がありません。
https://golang.org/ref/spec#Constants

どうやらGoでは、上記のように変数に代入していないリテラル定数には型がないようです。

では型がない値同士の演算結果はどのような型になるのでしょうか?

Constant expressions
the operands of a binary operation are different kinds of untyped constants, the operation and, for non-boolean operations, the result use the kind that appears later in this list: integer, rune, floating-point, complex.
const Θ float64 = 3/2 // Θ == 1.0 (type float64, 3/2 is integer division)
二項演算のオペランドは、種類の異なる型のない定数の時、その演算は整数、ルーン、浮動小数点、複合を使用します。
const Θ float64 = 3/2 // Θ == 1.0 (float64型, 3/2 is 整数除算)
https://golang.org/ref/spec#Constant_expressions

この例から型がない整数同士を演算する場合は代入先の型から型を類推することがわかります。C言語のようにとりあえずintにするようなことはありません。
つまりans = 2147483647 + 1の右辺の定数はどちらも型がない状態で演算され、ansへの代入時にint64になります。

以下のように定数の型を明示するとコンパイルエラーになります。これは先ほど説明した通り異なる型同士の演算や代入は禁止されているためです。

var ans int64
ans = int32(2147483647) + 1
fmt.Println(ans)
// tmp/sandbox986794255/main.go:7: constant 2147483648 overflows int32
// tmp/sandbox986794255/main.go:7: cannot use int32(2147483647) + 1 (type int32) as type int64 in assignment

https://play.golang.org/p/7I3IAc3Mnn

Goは危険なところでは余計な類推はせず厳密な宣言を求められるが、そうでないところでは適宜類推してくれるという安全かつ使いやすい仕様となっていました。

符号なし型と符号あり型の演算

あまり知られていないかもしれませんがC言語における符号なし型と符号あり型の演算はとても危険です。

以下のコードは一見問題なさそうに見えますが、想定している結果とは異なりa > bが出力されます。

int a = -1;
unsigned int b = 1;
if (a < b) {
    printf("a < b");
} else {
    printf("a > b");
}
// 出力結果 a > b

https://ideone.com/ATpy1l

なぜこのようになるかというと以下のルールが関係しており、これは仕様どおりの挙動となります。

符号無し整数型を持つオペランドが、他方のオペランドの整数変換の順位より高い又は等しい順位をもつならば、符号付き整数型をもつオペランドを、符号無し整数型をもつオペランドの型に変換する。
INT02-C. 整数変換のルールを理解する

つまりintunsigned intに変換されて計算されます。

上記のコード例では、int a = -1;a < bの演算時にabの型が異なるため型変換により上記のルールが適用され、a-1unsigned int4,294,967,295として比較されます。

Goでは型が異なる場合にはコンパイルエラーになるためそんな失敗もありません。

a := int(-1)
b := uint(1)
if a < b {
    fmt.Println("a < b")
} else {
    fmt.Println("a > b")
}
// tmp/sandbox775038133/main.go:10: invalid operation: a < b (mismatched types int and uint)

https://play.golang.org/p/AdCJGcZr5Q

シフト演算

シフト演算もC言語仕様で難しいものの1つですが、Goではシンプルになっています。

  • 負の値でのシフト
    • C言語 未定義
    • Go コンパイルエラー(uint型以外はエラーとなる)
  • 左オペランドの精度と同じかそれ以上のビット数分シフト
    • C言語 未定義
    • Go オペランドがどちらも定数の場合はオーバーフローによりコンパイルエラー、どちらかが変数だと0。
  • 符号付整数に対する左シフト
    • C言語 未定義(以外に知られていない)
    • Go 算術シフト
  • 右シフトの挙動
    • C言語 論理シフト or 算術シフトは未定義。
    • Go 左のオペランドが符号付き整数の場合は算術シフト、符号なし整数の場合は論理シフト。
  • 負の値に対する右シフト
    • C言語 未定義。右シフトが論理シフトか算術シフトかが未定義のため。
    • Go 算術シフト。

C言語では多くの動作が未定義となっています。
色々な場合に分けて仕様を記述しましたが、Goでは細かい状況にあわせて仕様を理解する必要はなく下記の仕様を理解しておけば正しくシフト演算を利用することができます。

There is no upper limit on the shift count.
シフト数に上限はない

They implement arithmetic shifts if the left operand is a signed integer and logical shifts if it is an unsigned integer.
左のオペランドが符号付き整数の場合は算術シフト、符号なし整数の場合は論理シフトです。
https://golang.org/ref/spec#Arithmetic_operators

まとめ

長くなりほとんどC言語の説明でしたが、皆さんこれからも安全なGoライフを!

関連記事

参考

sonatard
組み込みC言語ネットワークスタック開発者からGoバックエンドエンジニアにジョブチェンジしました。 最近はTypeScript, React(Hooks), GraphQL, SwiftUIに夢中。
https://github.com/sonatard/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away