問題のコード (Swift 1.2)
class TestClass {
class func test() -> Bool {
return Int(count() - 1) >= 0
}
class func count() -> UInt {
return 0
}
}
上記のようなコードなのですが、実行すると、以下の場所で、EXC_BAD_INSTRUCTION (code=EXC_i386_INVOP, subcode=0x0)
という実行時エラーが発生してクラッシュします。
これはシミュレータで実行しているので i386の例外になっていますが、実機(ARM)の場合も同様の EXC_BREAKPOINT (code=1, subcode=0x100011111)
のような例外が発生します。
原因究明
よくわからないので、アセンブラレベルで一体何が起こっているのか見てみました。XcodeのDebugメニューからDebug Workflowの Always Show Disassemblyを選んでみましょう。
すると、デバッガのソースコード表示が Swiftではなく、ディスアセンブルされたアセンブラのコードとしてみることができます。
クラッシュしているのは ud2という命令です。i386の命令セットにはあまり詳しくないのですが、Wikipediaによると、未定義命令のようです。
Instruction | Meaning | Notes |
---|---|---|
UD2 | Undefined | Instruction Generates an invalid opcode. This instruction is provided for software testing to explicitly generate an invalid opcode. The opcode for this instruction is reserved for this purpose. |
おかしなことが起こったのであえて実行し、プログラムを停止させているようです。23行目のud2の直前は retqですが、これはサブルーチンのリターンコードです。となるとどこからかジャンプしてきているはずです。よく見ると13行目にジャンプ命令があり、ud2のアドレスを指しています。
0x106e18d07 <+39>: jb 0x106e18d25 ; <+69> at TestClass.swift:13
jbは、Jump If Belowという命令で、直前の算術演算の結果が負の数になったときに成立するジャンプ命令です。ここでいう 直前の算術演算 は9行目の
0x106e18cf7 <+23>: subq $0x1, %rax
で、raxレジスタから1を引いているところですね。この -1している raxレジスタの中身は、直前のサブルーチンコールの呼び出し結果が入っているようです。
もうお分かりだと思いますが、これはまさに Swiftの count() - 1
の部分に相当しています。つまり、コンパイラは count() - 1 の結果が負の数になったらエラー という機械語を意図的に生成しているのです。count()が返す結果は UIntなので、 符号なし整数の演算結果が負の数になってしまったら実行時エラーにする というわけですね。
C言語に慣れているひとだと unsignedと signedというのは単なる数字の見方の問題だということを知っているので、「最終的に評価するときまでに正しい型にキャストすればいいや」くらいに思っていることが多いですし、状況によっては積極的に整数型の取りうる範囲をオーバーフローさせるようなコードも書いてしまいます。ところが、Swiftの場合は「計算の途中であってもunsignedの型が負の数になるのは許さん!」というスタンスのようです。
実行時の効率から言っても、算術演算のたびに毎回オーバーフローをチェックするのはコストが大きいと思いますが、面白いこだわりですね。
最大値を超える場合はどうなる?
ここで疑問が湧きました。もしかして、 整数型の最大値をオーバーするときも実行時エラーになるのではないでしょうか? 試してみます。
class TestClass {
class func test() -> UInt {
return count() + 1 // ここで EXC_BAD_INSTRUCTION
}
class func count() -> UInt {
return UInt.max
}
}
予想通りです。C言語の場合だt、UInt.maxに1を足したら0に戻ってきますが、Swiftはオーバーフローを許さないようです。UIntに限らず、Int.maxに1を足したり、Int.minから1を引いたりしても実行時エラーになります。
このあたりはC言語に慣れている方は要注意ですね。
公式の仕様
この挙動は Swiftの仕様書にもちゃんと書かれていました。
Arithmetic operators (+, -, *, /, % and so forth) detect and disallow value overflow, to avoid unexpected results when working with numbers that become larger or smaller than the allowed value range of the type that stores them.
ということです。さらに、
You can opt in to value overflow behavior by using Swift’s overflow operators, as described in Overflow Operators.
という興味深い記述もあります。Overflow Operatorとはなんでしょうか?
Swiftの Overflow Operator
上記リンク先によると、算術演算の結果をOverflowさせたい場合は明示的に Overflow Operatorを使うことができるとあります。例えば以下のようなものです。
- Overflow addition (&+)
- Overflow subtraction (&-)
- Overflow multiplication (&*)
面白いですね。
修正方法
問題が起こらないようにするためには count()の値そのものを先にIntにしてから負の数を許容するようにしてやる必要があります。
class TestClass {
class func test() -> Bool {
return Int(count()) - 1 >= 0
}
※ count()がIntの値域に収まっているという前提が必要です
もちろん、Overflow Operatorを使えばエラーにはなりませんが比較演算が期待と異なることになってしまいます。
class TestClass {
class func test() -> Bool {
return count() &- 1 >= 0 // 常に true
}
0 &- 1
の結果は UInt.maxなので、上記比較式は常に trueです。このように、オーバーフローを許容したいわけではなく、 ちゃんと負の数にしたい 場合は Intにキャストしてやる必要があります。
Swift 1.1までの場合
実はこの問題に遭遇したのは Swift 1.2にあげたタイミングだったのですが、 整数型の値域をオーバーフローすると実行時エラーになる というのはSwift 1.2になる前から存在している仕様です。
ところが、Swift 1.1までだと以下のようなコードは問題なくビルドでき、実行時エラーにもならないのです。
class SwiftClass {
class func test() -> Bool {
return count() - 1 >= 0 // Swift 1.1までは問題ない
}
class func count() -> UInt {
return 0
}
}
他にも、Swift1.1だと以下のようなコードも問題なく実行できてしまいます。
let b:Int = count() - 1 // bには -1が入る
でも、こちらは実行時エラーになります。
let c = Int(count() - 1) // EXC_BAD_INSTRUCTION
このような挙動の不安定さを嫌って、Swift 1.2では演算の途中であっても値域の判定が入るようになったものとおもわれます。
Objective-C の NSUIntegerとの棲み分けについて
NSArrayの countメソッドなどは NSUIntegerを返します。ですので、注意していないと、思わぬところで unsigned intの値を引き算して overflowを引き起こしかねません。
と、心配したのですが、 Swiftから見える NSArrayの countは UIntではなくて Intになっています。 Appleもさすがにつかいにくいとおもったのでしょうかね。
当然、Swiftネイティブの Arrayの countなども Intになっているので、普通に使う分には問題なさそうです。
ただ、自作のライブラリ等で、Cocoaの APIに倣って countの型を NSUIntegerにしていたりすると、この問題に遭遇しやすいと思います。ご注意ください。
正直、countプロパティの型はあえて unsignedにする必要は無いように思います。普通の処理系ではポインタサイズとIntのサイズは一致していますので、unsigned intでなければ格納できないような長さのArrayが使われることはまずありえません。
おそらくAppleもそのように考え、Swiftから NSArrayを使った場合には countの型が Intになるようにしているのでしょう。