Swift Digest
SE-0307 | Swift Evolution

Allow interchangeable use of CGFloat and Double types

Proposal
SE-0307
Authors
Pavel Yaskevich
Review Manager
Ted Kremenek
Status
Implemented (Swift 5.5)

01 何が問題だったのか

Swift では Objective-C と違って暗黙の数値変換が基本的に行われないため、DoubleCGFloat が別々の型として扱われます。CGFloat は Apple プラットフォーム上で、64 ビット環境では Double、32 ビット環境では Float のサイズになる浮動小数点型ですが、Swift の型システムから見れば Double とは別の名前付きの型で、相互に代入や受け渡しをするには必ず明示的な初期化子を介した変換が必要でした。

この分断は、CoreGraphics・UIKit・AppKit など CGFloat を引数に取る API と、Foundation の TimeIntervalDouble のタイプエイリアス)や SwiftUI など Double を前提にする API を同時に使う場面で強い摩擦を生みます。さらに Swift では浮動小数点リテラルのデフォルト型が Double であるため、CGFloat を取る API にリテラルや計算結果を渡すたびに CGFloat(...) を書く必要があり、変数宣言の型選びも一貫させにくい状況でした。

import UIKit

struct Progress {
    let startTime: Date
    let duration: TimeInterval  // Double のタイプエイリアス

    func drawPath(in rect: CGRect) -> UIBezierPath {
        let elapsedTime = Date().timeIntervalSince(startTime)  // Double
        let progress = min(elapsedTime / duration, 1.0)        // Double

        let path = CGMutablePath()
        let center = CGPoint(x: rect.midX, y: rect.midY)
        path.move(to: center)
        path.addLine(to: CGPoint(x: center.x, y: 0))

        let adjustment = .pi / 2.0  // Double
        // CGMutablePath の API は CGFloat を要求するため、
        // Double の値を都度 CGFloat(...) で包み直す必要があった
        path.addRelativeArc(center: center, radius: rect.width / 2.0,
                            startAngle: CGFloat(0.0 - adjustment),
                            delta: CGFloat(2.0 * .pi * progress))
        path.closeSubpath()
        return UIBezierPath(cgPath: path)
    }
}

Swift が登場した当初、iOS デバイスの多くがまだ 32 ビットだったこともあり、CGFloat を独立した型として残す判断がなされた経緯があります。しかし 64 ビット環境が主流となった現在では、ほとんどの状況で CGFloat は事実上 Double と同じサイズ・精度であり、型が別であることによるコストだけが残ってしまっています。SwiftUI をはじめとする新しい API が Double に標準化していることもあり、プロジェクト内で DoubleCGFloat のどちらを使うべきかが定まらず、型が混在したまま API 境界で変換を繰り返す、という状況が広く見られました。

02 どのように解決されるのか

DoubleCGFloat のあいだに、型チェッカが自動的に挿入する暗黙の変換を追加し、両者を実質的に相互に使えるようにします。変換は必要な箇所に Double.init(_: CGFloat) もしくは CGFloat.init(_: Double) の呼び出しがコンパイラによって差し込まれる形で実現されるもので、ランタイムに新しい仕組みが入るわけではありません。位置づけとしては、NSStringCFString などと同様の、相互運用性のために用意された「的を絞った暗黙変換」の仲間です。

使い方

利用者から見ると、これまで CGFloat(...) / Double(...) で明示的に包んでいた変換が不要になります。先ほどの例は次のように書けます。

path.addRelativeArc(center: center, radius: rect.width / 2.0,
                    startAngle: 0.0 - adjustment,
                    delta: 2.0 * .pi * progress)

リテラルや Double 値を CGFloat の引数にそのまま渡せますし、逆に CGFloat 値を Double を取る関数や演算子にそのまま渡すこともできます。変数の型が DoubleCGFloat かを都度気にしなくても、API 境界で自然につながるようになります。

変換の挙動

この暗黙変換は、既存のコードの意味をできるだけ保ちながら精度の損失を最小化するよう設計されています。型チェッカは次のような規則で変換を差し込みます。

  • Double が優先される。両方向に解決しうる場合は、Double を受け取るオーバーロードが優先されます。曖昧さの原因を減らすためです。
  • 必要なときだけ挿入されるDoubleCGFloat の変換なしで型チェックが通る式には、暗黙変換は差し込まれません。
  • widening(CGFloatDouble)が narrowing(DoubleCGFloat)より優先される。32 ビット環境では CGFloatFloat 相当になるため、DoubleCGFloat は精度を落とす可能性があります。複数回の widening と 1 回の narrowing のどちらかを選べる場合は前者が選ばれ、narrowing が避けられない場合も可能な限り遅い段階(最終的な結果の代入時など)に挿入されます。

次の例で、xCGFloatyDouble のとき、

let _: CGFloat = x / y

型チェッカは CGFloat(x) / y のように最初で narrowing するのではなく、CGFloat(x / Double(y)) のように最後に narrowing する形に解決します。

同名のオーバーロードが Double 版と CGFloat 版の両方存在する場合も、この変換によって呼び分けが行われます。

func sum(_: Double, _: Double) -> Double { ... }
func sum(_: CGFloat, _: CGFloat) -> CGFloat { ... }

var x: Double = 0
var y: CGFloat = 42

_ = sum(x, y)
// Double 版が選ばれ、 sum(x, Double(y)) として型チェックされる

文脈型が CGFloat であっても、narrowing の回数が最小になる解が選ばれるため、

let _: CGFloat = sum(x, y)

sum(CGFloat(x), y) ではなく CGFloat(sum(x, Double(y))) として解決されます。

暗黙変換が許されない場所

無節操に変換が挿入されると、かえって意図が見えづらくなったり、隠れたコストを生んでしまいます。そのため次の場所では暗黙変換は行われません。

  • CGFloat イニシャライザの引数CGFloat(someDouble) のように明示的に変換している箇所には、さらに暗黙変換を被せません。
  • コレクション要素。配列・集合・辞書のキーや値の型が DoubleCGFloat で食い違っていても、暗黙変換は入りません。要素ごとに変換コストがかかるため、利用側で map などを使って明示的に変換することを求めます。
  • 明示的なキャストや実行時チェックasas?as!istry を伴うキャストには適用されません。
  • すでに暗黙変換が挿入された位置。他の型を介して Double何かCGFloat のように繰り返し変換されることを防ぎます。

精度への注意

64 ビットプラットフォームでは CGFloatDouble は同じサイズなので、この変換によって精度が失われることはありません。32 ビットプラットフォームでは DoubleCGFloat の変換が Float への narrowing になり、精度が落ちます。ただしこれはもともと明示的な CGFloat(...) 変換で同じ精度損失が発生していた箇所であり、型チェッカが narrowing をできるだけ遅い段階に寄せることで、実質的な精度は従来より悪くならないように配慮されています。