Make Numeric Refine a new AdditiveArithmetic Protocol
01 何が問題だったのか
標準ライブラリの Numeric プロトコルは、算術演算子(+、-、*、/)と整数リテラルによる初期化(ExpressibleByIntegerLiteral)をまとめて要求する設計になっていました。スカラ型(Int や Double など)はこの設計で自然に扱えますが、ベクトル型や行列型などの「足し算・引き算はできるが、掛け算の意味は特殊」な型に適用しようとすると、いくつかの問題が表面化します。
1. ベクトル型が Numeric に適合するのは数学的に不自然
Numeric が要求する演算をそろえることは、数学的にはおおよそ環(ring)であることに対応します。一方、ベクトル空間は環ではなく、ベクトル同士の掛け算(*)は一般には定義されません。Numeric の次の要件をベクトル型に提供してしまうと、定義からしておかしなことになります。
static func * (lhs: Self, rhs: Self) -> Self
static func *= (lhs: inout Self, rhs: Self)
2. 動的形状のベクトルでは整数リテラル変換が定義できない
Numeric に適合するには ExpressibleByIntegerLiteral への適合が必要で、そのためには init(integerLiteral:) を実装しなければなりません。ところが、形状(shape)が動的に決まるベクトル型では、スカラから初期化するためにも形状情報が要ります。
struct Vector<Scalar: Numeric>: Numeric {
// これは自然に書ける
init(repeating: Scalar, shape: [Int]) { ... }
// shape がわからないので意味を与えられない
init(integerLiteral: Int)
}
3. スカラ倍と要素ごとの乗算を共存させると型チェックが曖昧になる
ベクトル型はスカラ倍(Vector * Scalar)を定義上持ちます。機械学習などの分野では、慣例的にベクトル同士の * を要素ごとの乗算(element-wise)としてオーバーロードすることもよくあります。
static func * (lhs: Vector, rhs: Vector) -> Vector { ... }
static func * (lhs: Vector, rhs: Scalar) -> Vector { ... }
このときに Vector が ExpressibleByIntegerLiteral に適合していると、次のような素朴なコードが曖昧になってしまいます。
let x = Vector<Int>(...)
x * 1 // 曖昧: `x * Vector(integerLiteral: 1)` とも `x * (1 as Int)` とも解釈できる
つまり、「加減算だけ欲しい」「整数リテラルからの変換は要らない」「掛け算の意味は型ごとに違う」という型にとって、Numeric は要求が強すぎて適合できなかったり、適合させると不都合が出たりしていたわけです。
02 どのように解決されるのか
Numeric の振る舞いと要件はそのままに、より弱い新しいプロトコル AdditiveArithmetic を導入し、Numeric がこれを継承(refine)する形に整理します。AdditiveArithmetic は数学の加法群(additive group)にほぼ対応する抽象で、加減算の演算子とゼロ要素だけを要求します。
AdditiveArithmetic の定義
public protocol AdditiveArithmetic: Equatable {
/// ゼロ値
static var zero: Self { get }
static func + (lhs: Self, rhs: Self) -> Self
static func += (lhs: inout Self, rhs: Self) -> Self
static func - (lhs: Self, rhs: Self) -> Self
static func -= (lhs: inout Self, rhs: Self) -> Self
}
要件は「zero、+、+=、-、-=」だけで、整数リテラル変換も乗算も含まれません。
Numeric との関係
Numeric からは加減算系の要件が外れ、AdditiveArithmetic を継承する形になります。乗算と整数リテラル変換は引き続き Numeric 側に残ります。
public protocol Numeric: AdditiveArithmetic, ExpressibleByIntegerLiteral {
associatedtype Magnitude: Comparable, Numeric
init?<T>(exactly source: T) where T : BinaryInteger
var magnitude: Self.Magnitude { get }
static func * (lhs: Self, rhs: Self) -> Self
static func *= (lhs: inout Self, rhs: Self) -> Self
}
この結果、利用者目線では次のように使い分けができるようになります。
- スカラ型はこれまでどおり
Numeric(もしくはBinaryInteger、FloatingPointなど)に適合している。 - ベクトルや行列のように加減算は自然だが乗算の意味が特殊な型は、
AdditiveArithmeticにだけ適合できる。 - 「加減算とゼロさえあればよい」ジェネリックアルゴリズムは、
AdditiveArithmeticを制約に書けば、スカラとベクトルの両方に適用できる。
既存の Numeric 適合型は zero を書かなくてよい
Numeric に適合する既存の型が、改めて zero を実装しなくても済むように、ExpressibleByIntegerLiteral に制約した extension で既定実装が提供されます。
extension AdditiveArithmetic where Self: ExpressibleByIntegerLiteral {
public static var zero: Self {
return 0
}
}
これにより、Int や Double のような既存の Numeric 適合型は自動的に zero を得るため、ソースコードの書き換えは不要です。
単項 + の移設
単項 + はこれまで Numeric の extension として提供されていましたが、加法の一部なので AdditiveArithmetic の extension に移されます。
extension AdditiveArithmetic {
public static prefix func + (x: Self) -> Self {
return x
}
}
使い方の例
AdditiveArithmetic に対するジェネリックアルゴリズムは、スカラとベクトルの両方に使えます。たとえば「合計」を返す関数は次のように書けます。
func sum<T: AdditiveArithmetic>(_ values: [T]) -> T {
var result: T = .zero
for v in values {
result += v
}
return result
}
sum([1, 2, 3]) // 6(Int は Numeric 経由で AdditiveArithmetic)
sum([1.5, 2.5, 3.0]) // 7.0(Double も同様)
sum([Vector(...), Vector(...)]) // Vector が AdditiveArithmetic に適合していれば動く
ベクトル型を定義する側は、整数リテラルやスカラ乗算に関する面倒な制約に縛られることなく、加減算とゼロだけを提供して AdditiveArithmetic に適合させれば、上のようなジェネリック関数の恩恵を受けられるようになります。