1
3

More than 3 years have passed since last update.

ポリモーフィズムを利用した多種データの保存と復元方法の考察

Last updated at Posted at 2021-09-04

はじめに

例えば、お絵描きソフトを想定した場合。画面上に表示するオブジェクトは色々な種類が想定されます。例えば、円であったり長方形であったり三角形であったりです。当然それらは異なるデータを持ちますが、扱いを簡単にするためにポリモーフィズムにて、その扱いを抽象化する設計を目指すと思います。

例えば、こんな 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を利用して、アーカイブする方法です。便宜上shapesContentsというクラスが扱うようにして、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を復元して確認すると、具象クラスのCircleRectangleの情報が劣化して保存されていない事に気がつくはずです。実際は保存はされているのですが、復元時に劣化しています。

しかし復元時に個々のShapeの元々のタイプが何であったのか、知っていなければ、ContentsShapeの型を個別に復元しようになんともなりません。Shapeを保存するときに、その型もなんらかの方法で保存するべきなのかもしれません。と、いう訳でこの問題が面倒な問題である事に気がつくわけです。

本記事では、このように、ポリモーフィズムの恩恵をうけつついかにオブジェクトの保存と復元を行うかについて記述を行います。

NSObject, NSCoding

それでは旧来そもそもどうやってポリモーフィズムの権化を保存、復元していたのでしょうか?AppKit/UIKitベースであれば、NSObjectを基底クラスとしてNSCodingに準拠、そしてNSKeyedArchiverNSKeyedUnarchiverで保存復元していました。

では、この方針で試して見ましょう。

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))

おさらいですが、今回Shapeinitprotocolでこんな感じで定義してあるので、それに習います。

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
1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3