[Swift]時間の計算をプロトコルと演算子を用いて簡略化した

2014.12.09

時間を表すNSDateについて

NSDate は Foundation フレームワークで定義された時間のある一地点を示すクラスです。通常用いる分にはクラスに定義されたメソッドを用いて時間の間隔を表す NSTimeInterval とともに用いて一定の間隔後の NSDate を取得したり、ある NSDate のインスタンスが他の NSDate より過去かどうか調べたりできました。

NSDate *now = [NSDate new]; // 現在の時刻を取得する。
NSTimeInterval interval = 1000; // 時間の間隔(単位:秒)
NSDate *future = [now dateByAddingTimeInterval:interval]; // 一定間隔後の時間を取得する。
BOOL isPast = [now compare:future] == NSOrderedAscending; // 過去かどうか調べる。

サンプルコード

XCTest によるテストを含めた今回のサンプルプロジェクトをGithubにあげてあります。

まず何をしたいか

Swift にはカスタムで演算子を定義できる機能がありますが、これを用いて次のようなコードを書きたいと思いました。

  • 比較演算子 < を NSDate に対して用いることで時刻同士の過去と未来を判定したい。
  • 等値演算子 == を NSDate に対して用いることで時刻同士が同じかどうか判定したい。(ポインタ同士の比較ではなく)

今回のこの要求は Swift 標準APIの Comparable プロトコルや Equatable プロトコルに NSDate を準拠させることに対応しています。

EquatableプロトコルとComparableプロトコル

まず、標準APIの Equatable プロトコルは等値演算子を利用可能なプロトコルです。準拠するプロトコルはトップレベルに以下の形での演算子の実装を要求します。

func == (lhs: Self, rhs: Self) -> Bool

また、標準APIの Comparable プロトコルは比較演算子を利用可能なプロトコルです。準拠するプロトコルはトップレベルに以下の形での演算子の実装を要求します。

func < (lhs: Self, rhs: Self) -> Bool

ここで Self はプロトコルが適合する型を示します。例えば、既存の MyType 型を Comparable プロトコルに準拠させたい場合は次のような形での実装が考えられます。

extension MyType : Comparable {}
public func < (lhs: MyType, rhs: MyType) -> Bool { return lhs と rhs の比較結果 }
public func == (lhs: MyType, rhs: MyType) -> Bool { return lhs と rhs が等しいかどうか }

Comparable プロトコルは Equatable プロトコルを継承しているために、準拠する型については等値演算子 == の実装も必要になります。

標準APIのヘッダーを遡ると、比較演算子 < への要求は実際には_Comparableプロトコルで行われていますが、コメントによれば Apple はこの _Comparable プロトコルを直接用いることを推奨していません。あくまでもプログラマが実際に用いるのは Comparable プロトコルです。

これら2つの演算子を定義し、Comparable プロトコルに準拠させるだけでプログラマは MyType 型に対して演算子 <=, >, >=, != も使えるようになります。

NSDateをComparableプロトコルに準拠させる。

今回の NSDate の Comparable プロトコル準拠に関しては、既存のメソッドを演算子でラップすることですぐに達成できます。

import Foundation

/**
NSDate を比較する為の演算子です。

:param: left  左辺 NSDate
:param: right 右辺 NSDate

:returns: 右辺より左辺が過去であるかどうか
*/
public func < (left : NSDate, right : NSDate) -> Bool {
    return left.compare(right) == NSComparisonResult.OrderedAscending
}

/**
NSDate の等値性を確認する為の演算子です。

:param: left  左辺 NSDate
:param: right 右辺 NSDate

:returns: 右辺と左辺が同一時刻であるかどうか
*/
public func == (left : NSDate, right : NSDate) -> Bool {
    return left.isEqualToDate(right)
}

extension NSDate : Comparable {}

Strideableプロトコル

Swift の標準APIにおける Strideable プロトコルは大小比較と差の計算が可能なプロトコルです。

このプロトコルは準拠する型に対して Comparable プロトコルに予め準拠していることと、以下の2つのメソッド実装を要求します。

func distanceTo(other: Self) -> Stride
func advancedBy(n: Stride) -> Self

ここで用いられる Stride 型は差の計算に用いられる差分を表した型で、 SignedNumberType プロトコルに準拠した付属型(プロトコル内でtypealiasとして宣言される型)です。SignedNumberType プロトコルに対しては現在以下の8つの型が標準APIで準拠しています。

  • Double
  • Float
  • Float80
  • Int
  • Int16
  • Int32
  • Int64
  • Int8

大小比較に関しては Comparable プロトコルが担いますが、差の計算に関してはこれら2つの関数が担っています。

実際には、Strideable プロトコルは内部的には先ほどの Comparable プロトコルと同様の形で、Comparable プロトコルと _Strideable プロトコルに準拠しており、実際のメソッド要求は _Strideable プロトコルが担っていますが、あくまでも先程と同様にプログラマが使うのは Strideable プロトコルです。

Strideableプロトコルに準拠した型に対しては更に多くのトップレベルで定義された関数が利用可能になります。

stride(from: through: by:) 関数

func stride<T : Strideable>(from start: T, through end: T, by stride: T.Stride) -> StrideThrough<T>

この関数は from の引数からはじめて through の引数までの区間で by の引数で指定された間隔ごとに取得された値の集まりをStrideThrough<T>という型にくるんで返します。

結果はthroughの引数で指定された値も含む可能性があります。

StrideThroughSequenceTypeに準拠した型で、map, filter, reduce 関数等を利用できます。更に、各要素については Comparable プロトコルに準拠しているため、maxElement, minElement, sorted 関数等も利用できます。

stride(from: to: by:) 関数

func stride<T : Strideable>(from start: T, to end: T, by stride: T.Stride) -> StrideTo<T>

この関数は from の引数からはじめて to の引数までの区間で by の引数で指定された間隔ごとに取得された値の集まりをStrideTo<T>という型にくるんで返します。

結果はtoの引数で指定された値は含みません。

この型も SequenceTypeに準拠した型で、更に各要素について Comparable プロトコルに準拠しているために先ほどと同じように map, filter, reduce, maxElement, minElement, sorted 関数等も利用できます。

さらに Strideable 準拠型は以下の演算子が利用できます。

  • +<T : Strideable>(lhs: T, rhs: T.Stride) -> T : 左辺に右辺の差分を加えた値を返します。
  • +<T : Strideable>(lhs: T.Stride, rhs: T) -> T : 右辺に左辺の差分を加えた値を返します。
  • +=<T : Strideable>(inout lhs: T, rhs: T.Stride) : 代入先に差分を加えて新たな値とします。
  • -<T : Strideable>(lhs: T, rhs: T) -> T.Stride : 左辺から右辺を引いた差分を返します。
  • -<T : Strideable>(lhs: T, rhs: T.Stride) -> T : 左辺から右辺の差分を引いた値を返します。
  • -=<T : Strideable>(inout lhs: T, rhs: T.Stride) : 代入先から差分を引いて新たな値とします。

NSDateをStrideableプロトコルに準拠させる

先ほどに実装を修正する形で次のように宣言を行えば NSDate を Strideable プロトコルに準拠できます。

import Foundation

/**
NSDate を比較する為の演算子です。

:param: left  左辺 NSDate
:param: right 右辺 NSDate

:returns: 右辺より左辺が過去であるかどうか
*/
public func < (left : NSDate, right : NSDate) -> Bool {
    return left.compare(right) == NSComparisonResult.OrderedAscending
}

/**
NSDate の等値性を確認する為の演算子です。

:param: left  左辺 NSDate
:param: right 右辺 NSDate

:returns: 右辺と左辺が同一時刻であるかどうか
*/
public func == (left : NSDate, right : NSDate) -> Bool {
    return left.isEqualToDate(right)
}

/**
NSDate を Strideable プロトコルに準拠させる為の実装です。
*/
extension NSDate : Strideable {
    
    typealias Stride = NSTimeInterval
    
    /**
    指定の NSTimeInterval 秒進んだ NSDate を返します。
    
    :param: n 何秒進むか
    
    :returns: 指定秒進んだ NSDate
    */
    public func advancedBy(n: NSTimeInterval) -> Self {
        return self.dynamicType.init(timeInterval: n, sinceDate: self)
    }
    
    /**
    指定の NSDate がどのくらいの秒数離れているかを NSTimeInterval で返します。
    
    :param: other 指定の NSDate
    
    :returns: 何秒離れているかを示す NSTimeInterval
    */
    public func distanceTo(other: NSDate) -> NSTimeInterval {
        return other.timeIntervalSinceDate(self)
    }
}

ここで advancedBy メソッドの実装の際、dynamicTypeを用いるのは、NSDateが継承された時にうまく自分自身の型を返さない可能性があるためです(実際に継承したものを使用することはあまり想定できませんが)

実際に使ってみる

Equatable&Comparable

let formatter = NSDateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
formatter.timeZone = NSTimeZone(abbreviation: "GMT")

let dateFor20010101 = formatter.dateFromString("2001-01-01 00:00:00")!
let dateFor20010102 = formatter.dateFromString("2001-01-02 00:00:00")!

// 比較演算子を用いて比較できる。
dateFor20010101 < dateFor20010102 // true
dateFor20010102 > dateFor20010101 // true

// 等値演算子も時刻同士が等しいかの判定に利用できる。
dateFor20010101 == dateFor20010102 // false

Strideable

let formatter = NSDateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
formatter.timeZone = NSTimeZone(abbreviation: "GMT")

let dateFor20010101000000 = formatter.dateFromString("2001-01-01 00:00:00")!
let dateFor20010101100000 = formatter.dateFromString("2001-01-01 10:00:00")!

// 0時から10時までの一時間ごとの時刻の集まり(10時含む)を取得する。
let strideThrough = stride(from: dateFor20010101000000, through: dateFor20010101100000, by: 60 * 60)

// 0時から10時までの一時間ごとの時刻の集まり(10時含まず)を取得する。
let strideTo = stride(from: dateFor20010101000000, to: dateFor20010101100000, by: 60 * 60)

// 時刻と時間差分を演算子を用いて足し合わせる。
dateFor20010101000000 + 60 * 60 * 10 == dateFor20010101100000 
60 * 60 * 10 + dateFor20010101000000 == dateFor20010101100000 

// 時刻から時間差分を引いたり、時刻から時刻を引いて時間差分を取得できる。
dateFor20010101100000 - 60 * 60 * 10 == dateFor20010101000000
dateFor20010101100000 - dateFor20010101000000 == NSTimeInterval(60 * 60 * 10)

参考サイト