kateinoigakukunのブログ

思考垂れ流しObserver

Swiftに搭載予定のC++相互運用機能

Swift Advent Calendar 2020 の1日目の記事です。

今年の頭頃からGoogleのSwift for Tensorflowチームが主体となって、SwiftとC++の相互運用機能の議論と実装が進んでいます。

まだ未実装な部分も多く今すぐに試せるわけではありませんが、マニフェストの内容や現在の実装状況から興味深い部分をピックアップして紹介したいと思います。

github.com

大まかな実装方針

Swiftは既にCとObjective-Cとの相互運用をサポートしています。ヘッダをClang Modulesとして読み込むことでSwiftから自然な形で外部言語のAPIを呼び出すことができます。

この機能はSwiftコンパイラにリンクされているlibclangToolingを通じて変換されたClangのASTをSwiftのASTに変換することで実現されています。主なコードはClangImporterモジュールに実装されています。

C++との相互運用も同様のアーキテクチャで実装が進んでいます。

API設計の指針

Ideally, users working in Swift should not feel any difference between native Swift APIs and imported C++ APIs.

https://github.com/apple/swift/blob/main/docs/CppInteroperabilityManifesto.md#goals

SwiftとC++からインポートされたAPIに違いを感じさせないような自然なやり取りを目指しているようです。

また、Rustのrust-bindgenのようなユーザーライブラリとして提供するのではなく、コンパイラに結合された形で提供する選択をすることで、エディタ連携やツーリングの面で滑らかな体験を実現したい、という意図が感じられます。

さらに、"Interop must not be a burden for API vendors."と掲げており、JNICLIFのようなグルーレイヤーをできるだけ作らないことを目指しているようです。

関数シグネチャの変換

関数のインポートは比較的直感的な変換になりそうです。

// C++側定義
void increment(int *value);

// Swift側から見たとき
func increment(_ value: inout Int)

// Swiftのユースケース
func useIncrement() {
  var i = 0
  increment(&i)
}

C++側のポインタ型はnullポインタを許容しますが、Swift側のinoutは決してnullにになることはなく、呼び出し側のSwiftの方が制約が強いため、安全に呼び出すことができます。

ただ、Swift から C++の関数を呼び出す場合は問題になりませんが、逆の場合、つまりC++からSwiftの関数を呼び出す場合問題になることがありそうです。例えば、次のようにC++側のvirtualメソッドをSwift側で実装することを考えてみましょう。

// C++ header.

class Incrementer {
public:
  virtual void increment(int *value) = 0;
};

// Swift side (compiler synthesized)
protocol Incrementer {
  func increment(_ value: inout Int)
}

// Swift side
struct MyIncrementer: Incrementer {
  func increment(_ value: inout Int) {
  }
}

この場合、仮想関数テーブルを経由してSwiftのincrementメソッドをC++から呼び出されることがありますが、C++のポインタ型はnullポインタを許容してしまうため、呼び出されたincrementはSwiftとしてありえない状態になります。マニフェストではこの問題に対して明確な対応は書かれていませんが、妥当な線として inout Int または UnsafePointer<Int>として変換できるようにする、という案が考えられます。

他にもいろいろなバリエーションがありますが、基本的にSwift側の制約の方が強いため、Swift → C++の呼び出しであれば安全に呼び出せそうです。

ムーブセマンティクス

提案自体は長らくOwnershipManifestにあるのですが、Swiftにはムーブセマンティクスが未だ実装されていません。そのため、RAIIパターンでよく使われるmove-onlyなC++の型を綺麗にSwiftの世界にマッピングできません。

moveonly型がSwiftでサポートされるまでの対応として、UnsafePointerに特別な制約を課し、コピーできないようにしつつ、 moveInitialize メソッドでムーブする、という方法が挙げられています。

// C++ header.

// `File` is a move-only C++ class.
class File {
private:
  int file_descriptor_;
public:
  File(std::string_view filename);
  File(const File &) = delete;
  File(File &&) = default;
  ~File();

  File& operator=(const File &) = delete;
  File& operator=(File &&) = default;

  std::string ReadAll();
};

// Swift side (compiler synthesized)
struct File {
  public init(_ filename: std.string_view)
  public func ReadAll() -> std.string
}

// Swift side
func useFile(_ f: UnsafeMutablePointer<File>) {
  // var f2 = f.pointee // compile-time error: can only call a method on 'pointee'.
  print(f.pointee.ReadAll()) // OK

  // Move `f` to a different memory location.
  var f2 = UnsafeMutablePointer<File>.allocate(capacity: 1)
  f2.moveInitialize(from: f, count: 1)
  // `f` is left uninitialized now.

  print(f2.pointee.ReadAll()) // OK
  f2.deallocate() // OK
  // The file is closed now.
}

Swiftにムーブセマンティクスが完全に実装されると次のような形になりそうです。

// Swift side (compiler synthesized)
moveonly struct File {
  public init(_ filename: std.string_view)
  public deinit

  public func ReadAll() -> std.string
}

// Swift side
func useOneFile(_ f: File) {
  print(f.ReadAll()) // OK

  // Move `f` to a different memory location.
  var f2 = move(f)

  // print(f.ReadAll()) // compile-time error: can't access `f`, its value was moved

  print(f2.ReadAll()) // OK
  endScope(f2) // OK
  // The file is closed now.
}

StructとClassのインポート

C++のstructとclassは基本的に同じ機能を提供するためclassについてのみ話します。

C++のclassはSwift的に見ると基本的に値型と同じ振る舞いをするのでSwiftのstructとしてインポートするのが妥当だろうとされています。

ただ、このインポートでは表現できないC++の機能がいくつかあります。

例えば、C++のclassは継承をサポートしていますが、Swiftのstructは継承ができず値型同士のサブタイプ関係は存在しません。そのため、C++のclass型を値レベルで自然にキャストすることはできません。

しかし、そもそも値型同士のサブタイプ関係にはObject slicingの問題があり、ミスが起こりやすいとされています。そのため、安全な操作でないことを示すためにUnsafePointer経由で表現することになりそうです。

// C++ header.
class Base {
    int value1;
};

class Derived : public Base {
    int value2;
};

// Swift side (compiler synthesized)
struct Base {
  var value1: Int32
}

struct Derived {
  var value1: Int32
  var value2: Int32
}

// Swift side
var derived: Derived = ...
var base: Base = UnsafeRawPointer(&derived).load(as: Base.self)

値レベルでのキャストはunsafeな操作ですが、ポインタ同士のキャストはどうでしょうか?

例えば UnsafePointer<Derived>UnsafePointer<Base> の変換はSwiftとしてはサブタイプ関係がないので違法ですが、実体であるC++としては合法なキャストです。

これを実装するプランとして

  1. as 演算子を拡張してC++型のUnsafePointerのキャストを特別扱いする
  2. キャスト用のメソッドをC++の型に生成する
  3. C++型のキャストのためのcompiler intrinsicsな関数を提供する

が挙げられています。

1番のプランは、すでにキャストロジックが複雑になりすぎていることから見送られそうです。

2番のプランはC++からインポートした型に以下のよなAPIを自動生成する、というものです。

// Swift side (compiler synthesized)
public struct Derived {
  @returnsInnerPointer
  public var asBase: UnsafePointer<Base> { get }

  public static func downcast(from basePointer: UnsafePointer<Base>) -> UnsafePointer<Derived>?
}

public struct Base {
  public static func upcast(from basePointer: UnsafePointer<Derived>) -> UnsafePointer<Base>
}

// Usage example.

func useBasePtr(_ basePtr: UnsafePointer<Base>) { ... }
func useDerivedPtr(_ derivedPtr: UnsafePointer<Derived>) { ... }

func testDowncast() {
  var d = Derived()
  useBasePtr(d.asBase)

  // or:
  useBasePtr(Base.upcast(from: &d))
}

func testUpcast(_ basePtr: UnsafePointer<Base>) {
  if let derivedPtr: UnsafePointer<Derived> = Derived.downcast(from: basePtr) {
    useDerivedPtr(derivedPtr)
  }
}

3番目のプランは以下のような特別なキャスト用の関数を提供するという方法です。2番目のプランではキャストメソッドの自動生成時にキャスト可能な型のペアを制限する形になっていますが、このプランでは型チェック時にC++側の定義を元にSourceとDestinationに派生関係があるか否かを調べるようです。

public func cxxUpcast<Source, Destination>(
  _ source: UnsafePointer<Source>,
  to type: Destination.Type
) -> UnsafePointer<Destination>

public func cxxDowncast<Source, Destination>(
  _ source: UnsafePointer<Source>,
  to type: Destination.Type
) -> UnsafePointer<Destination>

public func cxxDynamicDowncast<Source, Destination>(
  _ source: UnsafePointer<Source>,
  to type: Destination.Type
) -> UnsafePointer<Destination>?

テンプレート関数のインポート

C++のtypenameテンプレートをSwiftのジェネリック関数としてインポートする機能が提案されており、実際に部分的に実装されています。

// C++ header.
template<typename T>
void functionTemplate(T t) { ... }

// Swift side (compiler synthesized)
func functionTemplate<T>(_ t: T)

単純な変換に見えるかもしれませんが、C++のテンプレートとSwiftのジェネリック関数には実行モデルに大きな違いがあります。

C++のテンプレートはコンパイル時に静的に型パラメータが決定され展開されますが、対してSwiftのジェネリック関数は型パラメータは実行時に差し替えることが可能です。スペシャライズはあくまで最適化の1つであり、C++のように常に展開されるわけではありません。

つまり、Swiftネイティブなジェネリック関数はpublicな関数としてモジュール外部から型パラメータを注入して使うことができますが、C++由来のジェネリック関数は必ずモジュール内でスペシャライズされる必要があります。

これらを区別するために @_must_specialize アトリビュートを導入し、C++由来のジェネリック関数に付ける提案がされています。

// C++ header.
template<typename T>
void functionTemplate(T t) { ... }

// Swift side (compiler synthesized)
@_must_specialize
func functionTemplate<T>(_ t: T)

テンプレートの展開はClangImporterが型パラメータをSwift型からC++型に変換した上で、Clangの意味解析モジュールでテンプレートをインスタンス化することで実装されています。

例外

-fno-exceptions オプションが付いていない場合、全てのC++関数が例外を投げる可能性があるため、素直にSwiftとして表現するとthrowsだらけになってしまいます。C++にはnoexcept というキーワードもありますが、マニフェストによると慣用的なC++ではないそうです。nothrowが付いてないとしても実態は例外を投げない、といったことが多いので、デフォルトをthrowsにするのは使い勝手が悪そうです。

そこで提案されているのが throws! というキーワードをSwift側に追加するプランです。 throws! はdo-catchで例外をキャッチすることも可能だが、キャッチしないことも可能、を示します。例外が投げられたがキャッチされなかった場合、プログラムは終了するようです。

これについては現在Pitchが出ています。

標準ライブラリ型のブリッジ

Objective-Cとの相互運用ではNSArrayやNSDictionaryなどのNSプレフィックスの付いたObjective-Cの型とSwiftの型の変換がサポートされています。このブリッジングはSwift側の型がObjective-Cの型の所有権を奪うことでほぼノーコストで行われています。

しかし、同じような仕組みを提供するためにはABIの変更が必要となってしまうようです。

まとめ

Interoperabilityの仕様には細かい決めの問題が大量にあって言語デザインのセンスが問われそうだなと思いました。

今回紹介した機能は実装が予定されている機能の一部だけなので、興味がある方は実装やManifestを見ると面白いと思います。