LL脳がscalaの勉強を始めたよ その97


なかなか進まないScalaコップ本の28章の続きをやっていきますよ。今回こそは等価性評価メソッドequalsのオーバーライドをする際の注意点を終わらせる目標でー

等価メソッドの開発(最後)

equalsオーバーライド時の注意点は次の4つがありますが、今回は4番目の等価関係を表すものとしてequalsを定義出来ていないミスから見ていくことにしますねー

  • 誤ったシグネチャーでequalsを定義(済み)
  • hashCodeに変更を加えずにequalsだけを定義
  • ミュータブルなフィールドによってequalsを定義
  • 等価関係を表すものとしてequalsを定義出来ていない
数学における同値関係をあらわすものとしてequalsを定義出来ていない

equalsメソッドの大元であるscala.Anyでのequalsの決まりによれば、equalsはnullで無いオブジェクト同士の数学的同値関係を満たす必要があるとのことです。

ここで、数学的同値関係とは次の5つの条件を満たす必要があるみたいですね

  • 反射律を満たす
    • 任意のnull以外の値xについてx.equals(x)はtrue
  • 対称律を満たす
    • 任意のnull以外の値x, yについてx.equals(y)がtrueであればy.equals(x)もtrue
  • 推移律を満たす
    • 任意のnull以外の値x,y,zについてx.equals(y)がtrueかつy.equals(z)がtrueならばx.equals(z)もtrue
  • 首尾一貫していなければならない
    • 任意のnull以外の値x, yについて、x.equals(y)の複数回の呼び出しはequalsの比較に関する情報に変更が無い限り同じ結果
  • nullルール
    • null以外の値xについてx.equals(null)はfalse

なんだか反射律だの対称律だの懐かしくも苦い単語がでてきましたな(´・ω・`)ちなみに前回までに構築してきたPointクラスでは上記equalsメソッドをきっちり満たしているのです。

class Point(val x:Int, val y:Int){
  // 同じ座標のものは同じHashCodeになるように再定義します
  override def hashCode  = 41 * (41 + x) + y
  // equalsを再定義します
  override def equals(other:Any) = other match {
    case that:Point => this.x == that.x && this.y == that.y
    case _ => false
  }
}

ただし、Pointクラスのサブクラスを考慮すると話がめんどくさくなるそうです。例えば上記を継承したサブクラスColoredPointを作成してみます。ColoredPointにはColor型のcolorフィールドを追加するという拡張を行いますね。

// Enumerationオブジェクトを継承した列挙オブジェクトです
object Color extends Enumeration {
  val Red, Orange, Yellow, Green, Blue, Indigo, Violet = Value
}

// フィールドとしてcolorを追加します
class ColorPoint(x:Int, y:Int, val color:Color.Value) extends Point(x, y){
  override def equals(other:Any) =  other match {
    // 同じ色であるという等価条件を追加してやります
    case that:ColorPoint => this.color == that.color && super.equals(that)
    case _ => false
  }
  // PointのhashCodeを利用するのでhashCodeの再定義はいらないデス
}

それでは実行してみますよ

// まずは親クラスのPointオブジェクトを生成します
scala> val p = new Point(1,2)                  
p: Point = Point@6bc
// まずはサブクラスのColorPointオブジェクトを生成します
scala> val cp = new ColorPoint(1,2,Color.Red)
cp: ColorPoint = ColorPoint@6bc

// こちらの比較はtrueを返しますが
scala> p equals cp
res5: Boolean = true
// こちらはダメなので対称律違反です
scala> cp equals p 
res6: Boolean = false

上記はp equals cpという処理がPointのequalsを呼び出すため座標のみで評価を行うことになる一方、cp equals pではpがColorPointでない時点でfalseが返ってしまっていることにより対称律違反となっているのデス(´・ω・`)

この対称律違反によって引き起こされるコレクションでの悲劇はこんな感じになるみたいですね

// 結果がことなるcontainsの処理
scala> HashSet[Point] (p) contains cp     
res8: Boolean = true

scala> HashSet[Point] (cp) contains p 
res9: Boolean = false

...まあ、予想はできていた内容ではあるのですが、containsでも対称律が満たせなくなるわけですね。とりあえず上記equalsの対称律問題をなんとか直してみますかねー

class ColorPoint(x:Int, y:Int, val color:Color.Value) extends Point(x, y){
  override def equals(other:Any) = other match {
    // 渡ってきたものがColorPointの時とPointの場合で処理をわけてやりますネ
    case that:ColorPoint => (this.color == that.color) && super.equals(that)
    case that:Point => that equals this
    case _ => false
  }
}

とりあえず試してみますかね

// オブジェクトを生成しますね
scala> val p = new Point(1,2)                
p: Point = Point@6bc

scala> val cp = new ColorPoint(1,2,Color.Red)
cp: ColorPoint = ColorPoint@6bc

// 対称律はOKデスネ
scala> p equals cp                           
res14: Boolean = true

scala> cp equals p
res15: Boolean = true

// 上記が満たせたのでコレクションの挙動も修正出来ましたな
scala> HashSet[Point] (p) contains cp        
res16: Boolean = true

scala> HashSet[Point] (cp) contains p 
res17: Boolean = true

しかしながら次のような処理を行うと推移律を満たしていないのです(´・ω・`)

// 色なしです
scala> val p = new Point(1,2)                  
p: Point = Point@6bc
// 色付きです
scala> val cp1 = new ColorPoint(1,2,Color.Red) 
cp1: ColorPoint = ColorPoint@6bc
// cp1と色だけが異なるcp2を生成します
scala> val cp2 = new ColorPoint(1,2,Color.Blue)
cp2: ColorPoint = ColorPoint@6bc

// これは等しいですね
scala> cp1 == p
res19: Boolean = true
// これも等しいですね
scala> p == cp2
res20: Boolean = true
// この2つが等しくないのは正しいのですが…残念ながら推移律違反です
scala> cp1 == cp2
res21: Boolean = false

...と、ここで「equalsの一般性を上げるという方向は、どうやら袋小路のようだ」とコップ本でもさじを投げてしまったので、equalsを厳密にする方向でに進むことにします。

まず最初に異なるクラスのオブジェクトは異なる、という定義にしてしまうことを考えてみます。例えばこんな感じの定義になりますかね(´・ω・`)

class Point(val x:Int, val y:Int){
  override def hashCode = 41 * (41 + x) + y
  override def equals(other:Any) = other match {
    case that:Point => 
      this.x == that.x && this.y == that.y &&
      // クラスの比較をしてやりますね
      this.getClass == that.getClass
    case _ => false
  }
}

class ColorPoint(x:Int, y:Int, val color:Color.Value) extends Point(x, y){
  override def equals(other:Any) = other match {
    case that:ColorPoint => (this.color == that.color) && super.equals(that)
    // Point クラスは使わないことにします
    case _ => false
  }
}

この定義だとPointオブジェクトとColorPointオブジェクトは異なることになるので、先ほどの推移律違反は回避できますし、最初のほうでやった対称律違反も回避できるわけですね。でもオブジェクト指向的に親クラスと別個にしてしまうのはどうなんだろう…と悩んでしまう気持ちもありますな(´・ω・`)

なお、次のような無名サブクラスも違うものとしてあつかってしまうという事実があったりするので、若干乱暴すぎるきらいがありますな。

scala> val pAnon = new Point(1,1){ override val y = 5 }
pAnon: Point = $anon$1@6bf

scala> val p = new Point(1,1)
p: Point = Point@6bb

// 無名サブクラスとも異なるという定義になってしまいます
scala> p == pAnon
res23: Boolean = false

…とここで、コップ本的にも無名サブクラスに対応できないのは無しだろうということで、またもやこの方向性をあきらめて新たなメソッドを追加再定義することで上記の問題を書いけるする方法にすすむみたいです。

具体的にはequalsを再定義する全てのクラスにcanEqualメソッドを追加すればいいみたいです。このメソッドはパラメータで渡されるオブジェクトがcanEqualを再定義したクラスのインスタンスならtrueを返して、そうでなければfalseを返すものみたいです
これをequalsメソッドから呼び出すことでオブジェクト同士が双方向に比較可能だとか…とりあえずサンプルを書いてみましょうかね。

class Point(val x:Int, val y:Int){
  override def hashCode = 41 * (41 + x) + y
  override def equals(other:Any) = other match {
    case that:Point => 
      // 要素比較の前にcanEqualを呼び出します
      (that canEqual this) &&
      (this.x == that.x) && (this.y == that.y)
    case _ => false
  }
  // オブジェクトの判定をしてやります
  def canEqual(other:Any) = other.isInstanceOf[Point]
}

上記サンプルでは等価判定の際に同じPointオブジェクトかどうかを判定するような形式ですね。コレに対応したColorPointクラスは次のようになりますね。

class ColorPoint(x:Int, y:Int, val color:Color.Value) extends Point(x, y){
  // hashCodeも再定義してやります
  override def hashCode = 41 * super.hashCode + color.hashCode
  override def equals(other:Any) = other match {
    case that:ColorPoint =>
      // canEqualsによるオブジェクト比較条件を導入します
      (that canEqual this) &&
      super.equals(that) && (this.color == that.color) 
    case _ => false
  }
   // オブジェクトがColorPointかどうかを比較しますね
  override def canEqual(other:Any) =
    other.isInstanceOf[ColorPoint]
}

とりあえず試してみますよー

// 各オブジェクトを生成します
scala> val p = new Point(1,2)                
p: Point = Point@6bc

scala> val cp = new ColorPoint(1,2,Color.Red)
cp: ColorPoint = ColorPoint@1141c

scala> val pAnon = new Point(1,1) { override val y = 5}
pAnon: Point = $anon$1@6bf

// 比較用にリストを生成します
scala> val coll = List(p)
coll: List[Point] = List(Point@6bc)

// PointとPointの比較なので等しいです
scala> coll contains p
res24: Boolean = true
// PointとColorPointの比較なので等しくないです(´・ω・`)
scala> coll contains cp
res25: Boolean = false
// 無名クラスは等しくなりますです
scala> coll contains pAnon
res26: Boolean = false

// 実際に==で比較した結果ですね
scala> p == cp
res28: Boolean = false
// 無名クラスはPointの等価が適用されるので等しくなりますね
scala> p == pAnon
res29: Boolean = false

実際の動作としてはPoint equals ColorPointの場合はcanEqualでfalseが帰るしColorPoint equals Pointではパターンマッチ部分でfalseになるようになっておりますね。ただし無名サブクラスは等価比較が再定義されていないのでPointの定義で等価比較される→無事通過となるわけですね、オブジェクトも同じだし(´・ω・`)

ちなみに上記アプローチにはLSP(リスコフ置換原則)違反だ!という批判がでる可能性があるみたいです。LSPによるとスーパークラスインスタンスが要求されている場面ではサブクラスインスタンスで代用できなければならないらしいのですが、上記の例だとサブクラスのインスタンススーパークラスインスタンスと等価にならないので駄目なんだとか(´・ω・`)

…でもコップ本的には、LSPの真の意味としてははサブクラスとはスーパークラスの契約を満たすことを要求するものであって、スーパークラスのと同じように振舞うように要求するするものではないからこれでOKとのことです。イイノカ(´・ω・`)?

コップ本的には今回扱ったサンプルで言えば「同じ座標でも色が違う点は異なるものと定義したんだから、その仕様を満たしてるんだからこの動作が正しいでしょ?」(すごく乱暴にまとめました)ってことみたいです。まあタダのPointクラスを無色と解釈しちゃえばそういう風にも取れるよな…と(´・ω・`)

いじょー

とりあえず今回はココマデです。ようやくミス関連が終わったので次回はパラメータ化された型のう等価性の定義からやっていきますねー