はじめに
例えば、お絵描きソフトを想定した場合。画面上に表示するオブジェクトは色々な種類が想定されます。例えば、円であったり長方形であったり三角形であったりです。当然それらは異なるデータを持ちますが、扱いを簡単にするためにポリモーフィズムにて、その扱いを抽象化する設計を目指すと思います。
例えば、こんな protocol
を想定してみます。全てのオブジェクトをShape
として扱う事とします。UIView
のように、空のView
にあまり意味がないので、Shape
はルートクラスではなく、protocol
とします。
protocol Shape {
}
class Circle: Shape {
var center: CGPoint
var radius: CGFloat
// ...
}
class Rectangle: Shape {
var origin: CGPoint
var size: CGSize
// ...
}
class RoundedRectangle: Rectangle {
var cornerRadius: CGFloat
// ...
}
さて、これらをArray
で保持するshapes
を想定して、これらを保存したい場合を想定して、その実現方法を考えてみる事とします。
もっとも最初に思いつくのは、Codable
を利用して、アーカイブする方法です。便宜上shapes
をContents
というクラスが扱うようにして、JSONEncoder
などでShape
を単純にアーカイブ&アンアーカイブしてみると、ある問題に気がつくはずです。
protocol Shape: Codable { // <-- here!!
}
class Circle: Shape {
}
class Rectangle: Shape {
}
class Contents: Codable {
var shapes: [Shape]
}
let a = Circle(center: CGPoint(x: 10, y: 20), radius: 30)
let b = Rectangle(origin: CGPoint(x: 40, y: 50), size: CGSize(width: 60, height: 70))
let contents = Contents(shapes: [a, b])
let encoder = JSONEncoder()
let data: Data = try encoder.encode(contents)
let decoder = JSONDecoder()
let object = try decoder.decode(Contents.self, from: data)
保存されたShape
を復元して確認すると、具象クラスのCircle
やRectangle
の情報が劣化して保存されていない事に気がつくはずです。実際は保存はされているのですが、復元時に劣化しています。
しかし復元時に個々のShape
の元々のタイプが何であったのか、知っていなければ、Contents
がShape
の型を個別に復元しようになんともなりません。Shape
を保存するときに、その型もなんらかの方法で保存するべきなのかもしれません。と、いう訳でこの問題が面倒な問題である事に気がつくわけです。
本記事では、このように、ポリモーフィズムの恩恵をうけつついかにオブジェクトの保存と復元を行うかについて記述を行います。
NSObject, NSCoding
それでは旧来そもそもどうやってポリモーフィズムの権化を保存、復元していたのでしょうか?AppKit
/UIKit
ベースであれば、NSObject
を基底クラスとしてNSCoding
に準拠、そしてNSKeyedArchiver
とNSKeyedUnarchiver
で保存復元していました。
では、この方針で試して見ましょう。
protocol Shape: NSObject, NSCoding {
init?(coder: NSCoder)
func encode(with coder: NSCoder)
}
class Circle: NSObject, Shape {
var center: CGPoint
var radius: CGFloat
init(center: CGPoint, radius: CGFloat) {
self.center = center
self.radius = radius
super.init()
}
required init?(coder: NSCoder) {
self.center = coder.decodeObject(forKey: "center") as! CGPoint
self.radius = coder.decodeObject(forKey: "radius") as! CGFloat
super.init()
}
func encode(with coder: NSCoder) {
coder.encode(self.center, forKey: "center")
coder.encode(self.radius, forKey: "radius")
}
override var description: String { "{Circle: \(self.center)-\(self.radius)}" }
}
class Rectangle: NSObject, Shape {
var origin: CGPoint
var size: CGSize
init(origin: CGPoint, size: CGSize) {
self.origin = origin
self.size = size
super.init()
}
required init?(coder: NSCoder) {
self.origin = coder.decodeObject(forKey: "origin") as! CGPoint
self.size = coder.decodeObject(forKey: "size") as! CGSize
super.init()
}
func encode(with coder: NSCoder) {
coder.encode(self.origin, forKey: "origin")
coder.encode(self.size, forKey: "size")
}
override var description: String { "{Rectangle: origin=\(self.origin), size=\(self.size)}" }
}
class Contents: NSObject, NSCoding {
var shapes: [Shape]
init(shapes: [Shape]) {
self.shapes = shapes
}
required init?(coder: NSCoder) {
self.shapes = coder.decodeObject(forKey: "shapes") as! [Shape]
}
func encode(with coder: NSCoder) {
coder.encode(self.shapes, forKey: "shapes")
}
override var description: String { "{Contents:" + self.shapes.map { $0.description }.joined(separator: ",") + "}" }
}
これで、[Shape]
の保存と復元は実現できました。保存や復元にここにオブジェクトの型をswitch
文などで仕分けする必要もなく、さすが古の技……と言いたいところですが、やはりNSObject
を基底にしなければならないという制限付きです。
let data = try! NSKeyedArchiver.archivedData(withRootObject: contents, requiringSecureCoding: false)
let object = try! NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data)
Codable
しかし、やはり今の時代、NSObject
から解放されたいと思うのも仕方ありません。では、もう一度最初のCodable
でなんとかならないか、考え直してみることにしましょう。
問題はデコード時に個々の具象クラスの型がわからない事に起因します。
let data = ...
let object = try? decoder.decode(Shape.self, from: data) // <-- ???
そこで、nestedUnkeyedContainer
を利用して、unkeyed containerを利用して、エンコード時にShape
の具象クラス名を文字列として、一緒にエンコードしてやろうという作戦です。Shape
の具象クラス名は"\(type(of: shape))"
で文字列にできます。
そして、ここがトリッキーなのですが、通常なら、container.encode(shape)
なのですが、これはなぜかエラーになるので、shape
インスタンス側にやらせます。shape.encode(to: container.superEncoder())
。
型名
のエンコード、shape
エンコードを繰り返すので、keyed container ではなく Unkeyed container を利用します。shape.encode()
には Encoder
を渡してあげないといけないので、container.superEncoder()
を渡してあげます。正直、このsuperEncoder()
はこれで動くかどうかは試してみるまでわかりませんでした。
class Contents: Codable {
// ...
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
var subcontainer = container.nestedUnkeyedContainer(forKey: .shapes)
for shape in self.shapes {
let typeString: String = "\(type(of: shape))"
try subcontainer.encode(typeString)
try shape.encode(to: subcontainer.superEncoder())
}
}
// ...
}
さて、今度はデコード側の処理です。unkeyed container
から型名を取得して、型に応じてデコードします。さらっと言ってしまいましたが、型の文字列からその型のオブジェクトをインスタンス化してあげる必要があります。NSObject
ベースであれば、NSClassFromString()
を使うてもあるかもおしれませんが、せっかくNSObject
と決別して、この道を選んだので、もう少し足掻いてみる事にします。
実は以前、ランタイムオブジェクトを色々調べていた時にこの手に便利なコードを書いたのを思い出したので、それを利用します。詳細は後述いたします。とにかく、これで、Shape
の具象クラス名からそのインスタンスを生成できます。
class Contents: Codable {
// ...
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
var subcontainer = try container.nestedUnkeyedContainer(forKey: .shapes)
let classes = Runtime.classes(conformTo: Shape.Type.self)
var shapes = [Shape]()
while (!subcontainer.isAtEnd) {
let type = try subcontainer.decode(String.self)
if let shapeType = classes.filter({ "\($0.self)" == type }).first as? Shape.Type {
let shape = try shapeType.init(from: subcontainer.superDecoder())
shapes.append(shape)
}
}
self.shapes = shapes
}
// ...
}
どさくさに紛れて、Runtime のクラス情報などを取得するユーティリティコードを紹介いたします。このコードで、Runtimeに含まれる全クラス、ある特定のクラスのサブクラス、特定のプロトコルに準拠したクラスにアクセスする事ができます。
例えば、UIView
の全サブクラスをAnyClass
として取得するには以下のように記述します。
let classes: [AnyClass] = Runtime.subclasses(of: UIView.self)
今回は、Shape
プロトコルの具象クラスを全て取得するので、こんな風に書きます。
let classes: [AnyClass] = Runtime.classes(conformTo: Shape.Type.self)
この中から、目的の具象クラスを探し出して、AnyClass
として用意します。そして、init()
でインスタンス化します。
let shapeType: AnyClass = ...
let decoder: Decoder = ...
let shape = try shapeType.init(from: decoder))
おさらいですが、今回Shape
のinit
はprotocol
でこんな感じで定義してあるので、それに習います。
protocol Shape: Codable, CustomStringConvertible {
init(from decoder: Decoder) throws
// ...
}
後半の説明は雑になってしまいましたがencode
時に具象クラスの型名も同時にエンコードしてあるので、decode
時は型名から、インスタンスを生成し、switch
文でcase
の羅列を用意することなく、Shape
の保存と復元を実現する事ができました。
Runtime ユーティリティのコードを上げておきます。gist
のコメントには簡単ですが、もう少し説明があります。
Runtime.swift
public class Runtime {
public static func allClasses() -> [AnyClass] {
let numberOfClasses = Int(objc_getClassList(nil, 0))
if numberOfClasses > 0 {
let classesPtr = UnsafeMutablePointer<AnyClass>.allocate(capacity: numberOfClasses)
let autoreleasingClasses = AutoreleasingUnsafeMutablePointer<AnyClass>(classesPtr)
let count = objc_getClassList(autoreleasingClasses, Int32(numberOfClasses))
assert(numberOfClasses == count)
defer { classesPtr.deallocate() }
let classes = (0 ..< numberOfClasses).map { classesPtr[$0] }
return classes
}
return []
}
public static func subclasses(of `class`: AnyClass) -> [AnyClass] {
return self.allClasses().filter {
var ancestor: AnyClass? = $0
while let type = ancestor {
if ObjectIdentifier(type) == ObjectIdentifier(`class`) { return true }
ancestor = class_getSuperclass(type)
}
return false
}
}
public static func classes(conformToProtocol `protocol`: Protocol) -> [AnyClass] {
let classes = self.allClasses().filter { `class` in
var subject: AnyClass? = `class`
while let `class` = subject {
if class_conformsToProtocol(`class`, `protocol`) { print(String(describing: `class`)); return true }
subject = class_getSuperclass(`class`)
}
return false
}
return classes
}
public static func classes<T>(conformTo: T.Type) -> [AnyClass] {
return self.allClasses().filter { $0 is T }
}
}
まとめ
コードが完成してすぐにこの記事を書き始めたので、よく検証されていない部分があるかと思います。不具合などを発見した場合は、修正リクエストなりいただけると幸いです。
プロダクションで利用する場合は、クラス名の変更や削除など、より注意深く実装する必要があるかと思います。
コード
https://gist.github.com/codelynx/428b27b3cfd58b8c7382346f1a4bc415
https://gist.github.com/codelynx/9a336a96b76a39b0922457c0cc84835d
https://gist.github.com/codelynx/f8c4d8f394ee64483cd05e60332253e2
ライセンス
ライセンスはMITライセンスとさせていただきます。
参考にした記事
環境
執筆している時点での環境は以下の通りです。
$ /usr/bin/xcodebuild -version
Xcode 12.5.1
Build version 12E507
$ swift --version
Apple Swift version 5.4.2 (swiftlang-1205.0.28.2 clang-1205.0.19.57)
Target: arm64-apple-darwin20.6.0