Scala クラスの自作の練習(Time, Duration, TimeRange)

プログラム内で下記のような計算を多く記述するとします。

  • ある人は08:00に移動を開始する
  • 移動先への所要時間は1時間30分かかる
  • 移動先には09:00~11:00の間に到着しなければならない
  • この場合、移動が間に合うかを判定する

上記には、時間に関連する内容に関して三つのクラスを作るとスマートでしょう。

  • Timeクラス : 特定の時刻を表す(08:00等)
  • Durationクラス : 経過時間を表す(1時間30分等)
  • TimeRangeクラス : 始めと終わりの二つの時刻の組を表す(09:00~11:00等)

この記事では上記のクラスを作成し、プログラムの記述をスマートにするためにいくつかのメソッドを作成します。

(なお、この内容はjava.util.Dateやjava.sql.Timeとは関係がないので注意してください。)

クラスファイルの作成

以下では、Time, Durationで内部的に利用する時間はDouble型として、単位は[時間]とします。

プログラム

Time.scala

case class Time(time: Double)

Duration.scala

case class Duration(time: Double)

TimeRange.scala

case class TimeRange(start: Time, end: Time)

Main.scala

object Main extends App {
  val time = Time(10)
  println("time: %s".format(time))
  println("time.time: %f".format(time.time))

  val duration = Duration(0.5)
  println("duration: %s".format(duration))
  println("duration.time: %f".format(duration.time))
}

出力

time: Time(10.0)
time.time: 10.000000
duration: Duration(0.5)
duration.time: 0.500000

この状態ですでにTime, Duration, TimeRangeクラスを利用することが出来ます。 ここで注目するのは、それぞれのクラスをcase classとすることによって、下記のことが可能になります。

  • インスタンスの生成時にTime(10)のように書ける
    • 通常のクラスだとnew Time(10)のようにnewが必要だが省略可能になる
  • duration.timeのように仮引数に外からでもアクセス可能になる
    • 通常のクラスだとclass Time(val time: Double)のように指定する必要がある
  • toStringが定義されており、いい感じに文字列で表現してくれる

通常のclassに比べ、case classはちょっとしたクラスを作るのにちょうど良い感じです。

toStringの改良

Time, Durationの内部のtimeはDoubleで管理していますが、 表示する際には10:00のような形の方が見やすいです。 ここではTime, Durationの2つがあるので、Time(10:00), Duration(00:30)と表示されるようにします。

プログラム

Time.scala(Duration.scalaもほぼ同様)

case class Time(time: Double) {

  override def toString = {
    val hour = Math.floor(time)
    val minute = (time - hour) * 60
    "Time(%02.0f:%02.0f)".format(hour, minute)
  }
}

toStringはcase classでクラスを生成した時点で実装されているので、overrideを付与します。

出力

Main.scalaはそのままで出力してみます。

time: Time(10:00)
time.time: 10.000000
duration: Duration(00:30)
duration.time: 0.500000

文字列からTime, Durationのインスタンスを生成する(case classでの複数コンストラクタ)

今度は逆に、Time("10:00")の形でインスタンスを生成できるようにします。 case classの場合、少し特殊で下記の形になります。

プログラム

Time.scala(Duration.scalaもほぼ同様)

case class Time(time: Double) {

  override def toString = {
    val hour = Math.floor(time)
    val minute = (time - hour) * 60
    "Time(%02.0f:%02.0f)".format(hour, minute)
  }
}

object Time {
  def apply(str: String) = {
    val split = str.split(":")
    val (hourStr, minuteStr) = (split(0), split(1))
    new Time(hourStr.toDouble + minuteStr.toDouble / 60)
  }
}

Main.scala

val time = Time("10:00")
println("time: %s".format(time))
println("time.time: %f".format(time.time))

Time(10)と記述した場合は、クラスメソッドのTime.apply(time: Double)が呼ばれ(これはcase class Time(time: Double)でcase classを作成した時点で自動的に追加されている)、その中で基本コンストラクタのnew Time(time: Double)が呼ばれています。 Time("10:00")と記述した場合はクラスメソッドのTime.apply(str: String)が呼ばれ、 その中で基本コンストラクタのnew Time(time: Double)が呼ばれています。

object Time { ... }の中に書くのは、scalaではクラスメソッドをこの中に記述するためです。 (Javaのようにstaticを付けるわけではない)

こうすることで、Time(10)でもTime("10:00")でも同様にインスタンスが生成され、利用することが出来ます。

なお、Timeはcase classですが、 applyの中のnew Time(hourStr.toDouble + minuteStr.toDouble / 60)newは省略することは出来ません。 applyの中では基本コンストラクタを呼ばないと、apply内でapplyが無限に呼ばれてしまいます。

Time, Durationに加減乗除のメソッドを作成する

scalaでは、1+2などの+は演算子に見えるような記号は、実はIntクラスのメソッドとして定義されています。 同様に、Time, Durationクラスでも+メソッドなどを定義してしまえば、自由に使うことが出来ます。

プログラム

Time.scala (case class Time(time: Double) { ... }の中に記述)

def +(that: Duration) = Time(time + that.time)
def -(that: Duration) = Time(time - that.time)
def -(that: Time) = Duration(time - that.time)

Duration.scala

def +(that: Duration) = Duration(time + that.time)
def -(that: Duration) = Duration(time - that.time)
def *(x: Double) = Duration(time * x)
def /(x: Double) = Duration(time / x)

Main.scala

println(Time("09:00") + Duration("00:15")) // => Time(09:15)
println(Time("09:00") - Duration("00:15")) // => Time(08:45)
println(Time("09:00") - Time("08:45"))     // => Duration(00:15)
println(Duration("00:30") + Duration("00:20")) // => Duration(00:50)
println(Duration("00:30") - Duration("00:20")) // => Duration(00:10)
println(Duration("00:30") * 2)                 // => Duration(01:00)
println(Duration("00:30") / 2)                 // => Duration(00:15)

操作する対象に応じて、結果の型が変わるのが面白いところです。 また、Timeの-のように引数の型に応じてメソッドを多重定義することも可能です。

Time+Timeは定義されていません。時刻同士を単純に加算することは可能ですが、その値に意味がないからです。

(ですが、(time1 + time2) / 2のように二つの時刻の中心を求めるために加算することが考えられます。 しかし、Time同士の加算を認めないようにすると、プログラム中で誤って記述した際にコンパイルエラーを 出すことができるので、Time+Timeをあえて定義しないことにはメリットがあります。)

Time, Durationに比較のメソッドを作りたい

10:00より12:00の方が後だということを、 Time("10:00") < Time("12:00") // trueのように表せるようにします。

上記のように<, <=, ==, >=, >の五つのメソッドを定義しても良いのですが、 これを実現するのにより便利な方法があります。

scala.mathあるOrderedというトレイトをTimeにミックスインすることによって、 Orderedでの比較のメソッドを再利用することが出来ます。 Orderedには<, <=, ==, >=, >の五つのメソッドがありますが、 それらは共通のcompareメソッドでの結果を元にして処理しています。

つまり、Orderedトレイトのcompareメソッドをオーバーライドし、 Timeクラスのtimeを利用して比較することを記述するだけで、 <, <=, ==, >=, >の五つのメソッドが利用できるようになります。

これは説明するよりも例を見た方が早いと思います。 (実際見よう見真似で書いている)

プログラム

Time.scala(Duration.scalaもほぼ同様)

import scala.math.Ordered

case class Time(time: Double) extends Ordered[Time] {
  // (省略)

  override def compare(that: Time): Int = {
    this.time compare that.time
  }
}

Main.scala

println(if (Time("09:00") < Time("10:00")) true else false) // true

var sixtyMinutes = List.fill(60)(Duration("00:01"))
var oneHour = sixtyMinutes.foldLeft(Time(0)) { (t, d) => t + d }
println(oneHour) // Time(01:00)

println(if (oneHour == Time(1.0)) true else false) // false
println(Duration("00:01").time) // 0.016666666666666666
println(oneHour.time) // 1.0000000000000013

一行目のTime("09:00") < Time("10:00")のように期待通りに動きます。

比較が可能になったので、少し実験をしてみます。 sixtyMinutesではDuration("00:01")を60個並べたリストを作ります。 oneHourではTime(0)に対してsixtyMinutesの要素を繰り返し足します。 ここでoneHourを見てみるとTime(01:00)のように表示されます。

ただし、oneHour == Time(1.0)は期待とは逆にfalseとなります。 浮動小数点数の若干の誤差が出ていることが確認できます。

インスタンスメソッドとクラスメソッド

少し前に加減乗除のメソッドを作成したときに、Time+Timeは定義しませんでした。 やはり二つのTimeの中心を求めたいという声に応えて、betweenメソッドを作成します。

Time.scala

case class Time(time: Double) extends Ordered[Time] {
  // (省略)

  def between(that: Time): Time = {
    Time((this.time + that.time) / 2)
  }
}

object Time {
  // (省略)

  def between(t1: Time, t2: Time): Time = {
    Time((t1.time + t2.time) / 2)
  }
}

Main.scala

// Time同士の加算は未定義なのでエラー
// val middle1 = (Time("08:00") + Time("09:00") / 2)

val middle2 = Time(((Time("08:00").time + Time("09:00").time) / 2))
println(middle2) // Time(08:30)

val middle3 = Time.between(Time("08:00"), Time("09:00"))
println(middle3) // Time(08:30)

val middle4 = Time("08:00").between(Time("09:00"))
println(middle4) // Time(08:30)

middle1はエラーとなります。 middle2はbetweenメソッドを使わないで記述した例で、煩雑です。 middle3はクラスメソッドのbetweenを利用した例です。 middle4はインスタンスメソッドのbetweenを利用した例です。

2,3,4のどの方法でも、新しいTimeインスタンスが生成されるのには変わりないので、どれを利用しても大丈夫です。 Mainでの使われ方をイメージしてから、クラスメソッドにするかインスタンスメソッドにするかを決めるのが良いでしょう。

また、クラスメソッドとインスタンスメソッドが同名になっても問題ありません。何なら、引数の数と型が同じであっても大丈夫です。

TimeRangeクラスの作成

Time, Distanceクラスが整ったので、TimeRangeクラスを作成します。 また、便利そうなメソッドをいくつか追加してみました。

TimeRange.scala

case class TimeRange(start: Time, end: Time) {
  require(start <= end)

  def isInRange(target: Time): Boolean = {
    start <= target && target <= end
  }

  def hasIntersection(that: TimeRange): Boolean = {
    if (this.start <= that.start)
      that.start < this.end
    else
      this.start < that.end
  }

  override def toString = "%s to %s".format(start, end)
}

object TimeRange {
  def apply(start: String, end: String) = {
    new TimeRange(Time(start), Time(end))
  }
}

Main.scala(例1)

val tr1 = TimeRange("10:00", "12:00")
println(tr1)
val tr2 = TimeRange("10:00", "10:00")
println(tr2)
val tr3 = TimeRange("10:00", "09:00") // IllegalArgumentException
println(tr3)

TimeRangeクラスはTimeクラスを二つ集約したクラスです。

TimeRange("10:00", "12:00")と指定していますが、 TimeRange(Time("10:00"), Time("12:00"))でも同等です。

endの時刻がstartの時刻以上になるように、TimeRange.scalarequire(start <= end)と指定すると、 誤ったTimeRangeを作成したときにIllegalArgumentExceptionを吐くことが出来ます。

Main.scala(例2)

val time = Time("08:00")
val duration = Duration("01:30")
val timeRange = TimeRange("09:00", "11:00")

val ans = timeRange.isInRange(time + duration)
println(ans) // true

ここで、記事の最初にあった

  • ある人は08:00に移動を開始する
  • 移動先への所要時間は1時間30分かかる
  • 移動先には09:00~11:00の間に到着しなければならない
  • この場合、移動が間に合うかを判定する

をプログラムとして記述できるようになりました。

Main.scala(例3)

val ans2 = TimeRange("10:00", "12:00").hasIntersection(TimeRange("11:00", "13:00"))
println(ans2)

二つのTimeRangeに共通する部分があるかを調べるメソッドを作ってみた。

Time, Durationクラスの負数への対応

最後に、Duration("-02:00")のような指定をされたときや、 Time("10:00") - Time("12:00")のようなことがあったときのことを考えます。 内部的にはDoubleの加減乗除をしているだけなので大きな問題はないですが、 表示が崩れてしまうので、toStringとapplyを改良します。

Time.scala(Duration.scalaもほぼ同様)

case class Time(time: Double) extends Ordered[Time] {
  // (省略)

  override def toString = {
    val timeAbs = Math.abs(time)
    val hour = Math.floor(timeAbs)
    val minute = (timeAbs - hour) * 60
    if (time >= 0)
      "Time(%02.0f:%02.0f)".format(hour, minute)
    else
      "Time(-%02.0f:%02.0f)".format(hour, minute)
  }
}

object Time {
  def apply(str: String) = {
    if (str.head != '-') {
      val split = str.split(":")
      val (hourStr, minuteStr) = (split(0), split(1))
      new Time(hourStr.toDouble + minuteStr.toDouble / 60)
    } else {
      val split = str.tail.split(":")
      val (hourStr, minuteStr) = (split(0), split(1))
      new Time(-1 * (hourStr.toDouble + minuteStr.toDouble / 60))
    }
  }
}

まとめ

結構長くなってしまいましたが、当初の目的を割とスマートに書けたのではないでしょうか。

最後に完成形のプログラムをまとめて載せます。

Time.scala

import scala.math.Ordered

case class Time(time: Double) extends Ordered[Time] {
  def +(that: Duration) = Time(time + that.time)
  def -(that: Duration) = Time(time - that.time)
  def -(that: Time) = Duration(time - that.time)

  override def compare(that: Time): Int = {
    this.time compare that.time
  }

  def between(that: Time): Time = {
    Time((this.time + that.time) / 2)
  }

  override def toString = {
    val timeAbs = Math.abs(time)
    val hour = Math.floor(timeAbs)
    val minute = (timeAbs - hour) * 60
    if (time >= 0)
      "Time(%02.0f:%02.0f)".format(hour, minute)
    else
      "Time(-%02.0f:%02.0f)".format(hour, minute)
  }
}

object Time {
  def apply(str: String) = {
    if (str.head != '-') {
      val split = str.split(":")
      val (hourStr, minuteStr) = (split(0), split(1))
      new Time(hourStr.toDouble + minuteStr.toDouble / 60)
    } else {
      val split = str.tail.split(":")
      val (hourStr, minuteStr) = (split(0), split(1))
      new Time(-1 * (hourStr.toDouble + minuteStr.toDouble / 60))
    }
  }

  def between(t1: Time, t2: Time): Time = {
    Time((t1.time + t2.time) / 2)
  }
}

Duration.scala

import scala.math.Ordered

case class Duration(time: Double) extends Ordered[Duration] {
  def +(that: Duration) = Duration(time + that.time)
  def -(that: Duration) = Duration(time - that.time)
  def *(x: Double) = Duration(time * x)
  def /(x: Double) = Duration(time / x)

  override def compare(that: Duration): Int = {
    this.time compare that.time
  }

  override def toString = {
    val timeAbs = Math.abs(time)
    val hour = Math.floor(timeAbs)
    val minute = (timeAbs - hour) * 60
    if (time >= 0)
      "Duration(%02.0f:%02.0f)".format(hour, minute)
    else
      "Duration(-%02.0f:%02.0f)".format(hour, minute)
  }
}

object Duration {
  def apply(str: String) = {
    if (str.head != '-') {
      val split = str.split(":")
      val (hourStr, minuteStr) = (split(0), split(1))
      new Duration(hourStr.toDouble + minuteStr.toDouble / 60)
    } else {
      val split = str.tail.split(":")
      val (hourStr, minuteStr) = (split(0), split(1))
      new Duration(-1 * (hourStr.toDouble + minuteStr.toDouble / 60))
    }
  }
}

TimeRange.scala

case class TimeRange(start: Time, end: Time) {
  require(start <= end)

  def isInRange(target: Time): Boolean = {
    start <= target && target <= end
  }

  def hasIntersection(that: TimeRange): Boolean = {
    if (this.start <= that.start)
      that.start < this.end
    else
      this.start < that.end
  }

  override def toString = "%s to %s".format(start, end)
}

object TimeRange {
  def apply(start: String, end: String) = {
    new TimeRange(Time(start), Time(end))
  }
}

Main.scala

object Main extends App {

  val time = Time("08:00")
  val duration = Duration("01:30")
  val timeRange = TimeRange("09:00", "11:00")

  val ans = timeRange.isInRange(time + duration)
  println(ans) // true

}