M.Hiroi's Home Page

お気楽 Scala プログラミング入門

継承


Copyright (C) 2014-2024 Makoto Hiroi
All rights reserved.

はじめに

前回はオブジェクト指向の基本について簡単に説明しました。今回はオブジェクト指向機能の目玉ともいえる「継承」について取り上げます。まず最初に、一般的なオブジェクト指向で使われている継承について簡単に説明します。

●単一継承

継承には「単一継承」と「多重継承」の 2 種類があります。単一継承は、ただ一つのクラスからしか機能を継承することができません。したがって、クラスの階層は下図のような木構造 [*1] で表すことができます。

            A
          /|\
        /  |  \
      B    C    D
    /  \
  /      \
E          F

図 : 単一継承におけるクラスの階層

継承は何段階に渡って行われてもかまいません。たとえばクラス E の場合、スーパークラスが B で、B のスーパークラスが A に設定されています。サブクラスは複数あってもかまいません。

たとえば、A のサブクラスは B, C, D の 3 つがあり、B のサブクラスは E, F の 2 つがあります。上図では、クラス A のスーパークラスはありませんが、ほかのクラスはただ一つのスーパークラスを持っています。オブジェクト指向言語では Smalltalk, Java, Ruby, Scala が単一継承です。

-- note --------
[*1] 木 (tree) は階層的な関係を表すためのデータ構造です。身近な例ではディレクトリ (フォルダ) の階層構造が木にあたります。

●多重継承

これに対し、多重継承は複数のクラスを継承することができます。このため、クラスの階層は木構造ではなく、下図のようなグラフ [*2] で表すことができます。

              A
            /  \
          /      \
        B          C
      /  \      /  \
    /      \  /      \
  D          E          F

図 : 多重継承におけるクラスの階層

クラス E に注目してください。スーパークラスには B と C の 2 つがあります。多重継承では、単一継承と同じくサブクラスを複数持つことができ、なおかつスーパークラスも複数持つことができるのです。

C++や CLOS (Common Lisp Object System) は多重継承をサポートしています。スクリプト言語では Perl や Python が多重継承です。

Java や Scala の場合、基本的には単一継承ですが Java であれば「インターフェース (interface)」を使ってメソッドの「仕様」だけを、Scala であれば「トレイト (trait)」を使って「仕様」や「実装」を多重継承できるようになっています。トレイトについては次回以降で詳しく説明します。

-- note --------
[*2] グラフは木をより一般化したデータ構造です。数学のグラフ理論では、いくつかの点とそれを結ぶ線でできた図形を「グラフ」といいます。

●継承の仕組み

一般的なオブジェクト指向言語の場合、継承によって引き継がれる性質は定義されたインスタンス変数やメソッドになります。次の図を見てください。

     class
 ┌─ Foo  ─┐          ┌─ instance ─┐
 ├─────┤          ├───────┤
 │  変数 a  │────→│    変数 a    │
 ├─────┤          ├───────┤
 │  変数 b  │          │    変数 b    │
 └─────┘          └───────┘
method : get_a, get_b
      │
     継承
      ↓
 ┌─ Bar  ─┐          ┌─ instance ─┐
 ├─────┤────→├───────┤
 │  変数 c  │          │    変数 a    │
 └─────┘          ├───────┤
method : get_c()         │    変数 b    │
                         ├───────┤
                         │    変数 c    │
                         └───────┘

        図 : 一般的な継承

クラス Foo にはインスタンス変数 a, b とリーダーメソッド get_a, get_b が定義されています。次に、クラス Bar を定義します。Bar は Foo を継承し、Bar 固有のインスタンス変数 c とリーダーメソッド get_c を定義します。Foo と Bar のインスタンスを生成すると、上図に示したように、Bar のインスタンスにはクラス Foo で定義された変数 a, b も含まれます。このように、Foo のインスタンス変数が Bar に継承されます。

Foo のインスタンスを生成すると、もちろん変数 a, b は含まれていますが、Bar のインスタンスとメモリを共有することはありません。クラスはオブジェクトの設計図です。設計に共通な部分があったとしても、それから生み出されるインスタンスは別々の実体で、インスタンス変数を共有することはないのです。

クラス Bar にはメソッド get_c しか定義されていませんが、クラス Foo を継承することにより、メソッド get_a と get_b を呼び出すことができます。Bar のインスタンスに対して get_a を呼び出すと、クラス Bar には get_a が定義されていないのでスーパークラス Foo を調べ、そこで定義されている get_a が呼び出されます。もちろん、取り出される値は Bar のインスタンスにある変数 a の値です。このように、Foo のメソッドが Bar に継承されます。

●単一継承の使い方

それでは、具体的に Scala の継承を説明しましょう。スーパークラスは class 文で指定します。クラス名の後ろに extends を付けて、その後ろにスーバークラス名を指定します。Scala は単一継承が基本なので、指定できるスーパークラスは一つだけです。継承に必要な設定はこれだけです。

簡単な例として、上図のクラスを実際にプログラムしてみましょう。次の例を見てください。

scala> class Foo(var a: Int, var b: Int) {
     | println("call Foo")
     | def get_a(): Int = a
     | def get_b(): Int = b
     | def put_a(n: Int): Unit = a = n
     | def put_b(n: Int): Unit = b = n
     | }
// defined class Foo

scala> class Bar(a: Int, b: Int, var c: Int) extends Foo(a, b) {
     | println("call Bar")
     | def get_c(): Int = c
     | def put_c(n: Int): Unit = c = n
     | }
// defined class Bar

scala> val a = new Foo(1, 2)
call Foo
val a: Foo = Foo@ef718de

scala> val b = new Bar(10, 20, 30)
call Foo
call Bar
val b: Bar = Bar@20d19f2c

scala> a.get_a()
val res0: Int = 1

scala> a.get_b()
val res1: Int = 2

scala> b.get_a()
val res2: Int = 10

scala> b.get_b()
val res3: Int = 20

scala> b.get_c()
val res4: Int = 30

scala> a.get_c()
-- [E008] Not Found Error: ------------------------------------------------------
1 |a.get_c()
  |^^^^^^^
  |value get_c is not a member of Foo - did you mean a.get_a? or perhaps a.get_b?
1 error found

クラス Foo のコンストラクタ引数 a, b には var があるので、これらの引数はフィールド変数として扱われます。メソッド get_a と get_b の定義は簡単です。与えられたインスタンスから値を取り出すだけです。次にクラス Bar を定義します。extends の後ろにスーパークラス Foo を指定します。これで Foo のフィールド変数とメソッドを継承することができます。

Bar のコンストラクタ引数では引数 c に var があるので、引数 c がフィールド変数として扱われます。a, b はただの引数になります。フィールド変数 a, b の初期化は Foo のコンストラクタで行っていますね。このメソッドを呼び出すことができれば、わざわざ Bar のコンストラクタで a, b の初期化を行う必要はありません。この場合、extends Foo の後ろにコンストラクタ引数を指定してください。これで Foo のコンストラクタが呼び出されます。

Bar のインスタンスを生成すると、Foo のコンストラクタが呼び出されるので、call Foo が表示されてから call Bar が表示されます。スーパークラスのメソッドは Bar のインスタンスからでも呼び出すことができます。b.get_a は 10 になりますし、自クラスのメソッド b.get_c は 30 になります。なお、Foo のインスタンス a からサブクラスのメソッド get_c は呼び出すことができません。エラーになります。

●オーバーライド

継承はクラスに新しい機能を追加するだけではなく、メソッドをオーバーライドすることで機能を変更することができます。なお、オーバーライドと多重定義 (オーバーロード) はまったく異なる機能です。混同しないように注意してください。

それでは、簡単な例を示しましょう。フィールド変数の合計値を求めるメソッド sum を定義します。次のリストを見てください。

リスト : メソッドのオーバーライド

class Foo(var a: Int, var b: Int) {
  def get_a(): Int = a
  def get_b(): Int = b
  def put_a(n: Int): Unit = a = n
  def put_b(n: Int): Unit = b = n
  def sum(): Int = a + b
}

class Bar(a: Int, b: Int, var c: Int) extends Foo(a, b) {
  def get_c(): Int = c
  def put_c(n: Int): Unit = c = n
  override def sum(): Int = super.sum() + c
}

object sample0901 {
  def main(args: Array[String]): Unit = {
    val a = new Foo(1, 2)
    val b = new Bar(10, 20, 30)
    println(a.sum())
    println(b.sum())
  }
}

クラス Foo でメソッド sum を定義します。そして、クラス Bar でメソッド sum をオーバーライドします。オーバーライドはスーパークラスにあるメソッドと同じ名前のメソッドを定義するだけです。Scala の場合、def の前にキーワード override を指定する必要があります。

Scala は Java と同様に super を使ってスーパークラスのメソッドを呼び出すことができます。

super.method(args, ...)

Bar のメソッド sum では、super.sum でスーパークラスのメソッド sum を呼び出して、その結果にフィールド変数 c の値を足し算します。実行結果は次のようになります。

$ scalac sample0901.scala
$ scala sample0901
3
60

正常に動作していますね。

●型の継承

Scala の場合、Java と同様にクラス名は型を表す識別子として利用することができます。継承はフィールド変数やメソッドに作用するだけではなく、型にも作用します。サブクラスに属するインスタンスは型も継承されるため、スーパークラスの型として取り扱うことができるのです。インスタンスが属するクラスを判定するメソッド isInstanceOf を使って調べてみましょう。

obj.isInstanceOf[型] => Boolean

次の例を見てください。

scala> class Foo
// defined class Foo

scala> class Bar extends Foo
// defined class Bar

scala> class Baz extends Bar
// defined class Baz

scala> val a = new Foo
val a: Foo = Foo@6075b369

scala> val b = new Bar
val b: Bar = Bar@42730828

scala> val c = new Baz
val c: Baz = Baz@1640f20f

scala> a.isInstanceOf[Foo]
val res0: Boolean = true

scala> b.isInstanceOf[Foo]
val res1: Boolean = true

scala> c.isInstanceOf[Foo]
val res2: Boolean = true

scala> a.isInstanceOf[Bar]
val res3: Boolean = false

scala> b.isInstanceOf[Bar]
val res4: Boolean = true

scala> c.isInstanceOf[Bar]
val res5: Boolean = true

scala> a.isInstanceOf[Baz]
val res6: Boolean = false

scala> b.isInstanceOf[Baz]
val res7: Boolean = false

scala> c.isInstanceOf[Baz]
val res8: Boolean = true

クラス Bar はクラス Foo を継承しています。Foo のインスタンス a は isInstanceOf でチェックすると、Foo では true になり、Bar では false になります。ところが、Bar のインスタンス b は、Bar で true になるのは当然ですが、Foo のサブクラスなので型が継承され、Fooでも true になります。そして、Bar を継承したクラス Baz のインスタンスを作って、それを isInstanceOf でチェックすると、Foo, Bar, Baz のどれでも true になります。

  ┌──────────┐
  │        Foo         │
  │  ┌──────┐  │
  │  │    Bar     │  │
  │  │  ┌──┐  │  │
  │  │  │Baz │  │  │
  │  │  │    │  │  │
  │  │  └──┘  │  │
  │  │            │  │
  │  └──────┘  │
  │                    │
  └──────────┘

図 : クラスとサブクラスの関係

このように、クラスを単一継承してサブクラスを作ると、サブクラスはスーパークラスの部分集合として考えることができます。上の図を見てください。サブクラス Baz は Bar や Foo に含まれているので、そのインスタンスから Bar や Foo のメソッドを呼び出することができるわけです。

●継承とオーバーライドの制限

クラスを定義するとき、class の前に final を付けると、そのクラスを継承したサブクラスを作ることはできません。つまり、継承を禁止することができます。また、メソッドを定義するとき def の前に final を付けると、サブクラスでオーバーライドすることができなくなります。これらの機能は Java と同じです。

●抽象クラス

クラスでメソッドを定義するとき、メソッドの型だけを宣言することができます。これを「抽象メソッド (abstract method)」といいます。そして、抽象メソッドを持つクラスを「抽象クラス (abstract class)」といい、new でインスタンスを生成することはできません。なお、Scala はメソッドだけではなくフィールド変数も抽象フィールド変数として定義することができます。

抽象クラスは次のように定義します。

abstract class クラス名 {
  ...

  val 名前: 型
  var 名前: 型
  def 名前(引数: 型, ...): 型

  ...
}

抽象フィールド変数と抽象メソッドは名前と型を指定するだけで、= 以降の式や値は書きません。抽象フィールド変数、抽象メソッドを持つクラスは class の前に必ず abstract を宣言してください。なお、Java と違って、メソッドの定義で abstract をつける必要はありません。また、抽象メソッドがないクラスでも abstract を宣言することができます。もちろん、そのクラスは抽象クラスになります。

抽象クラスは継承されることを前提としたクラスで、抽象フィールド変数や抽象メソッドはサブクラスにおいて具体的に定義されます。抽象クラスでは、サブクラス共通のメソッドを定義します。このとき、抽象メソッドを呼び出してもかまいません。サブクラスのインスタンスが生成されるとき、そのサブクラスでは抽象メソッドが具体化されているはずなので、実際にはサブクラスのメソッドが呼び出されることになります。

簡単な例を示しましょう。

scala> abstract class Foo { def foo(): Unit }
// defined class Foo

scala> class Foo1 extends Foo { def foo(): Unit = println("Foo!!") }
// defined class Foo1

scala> val a = new Foo1
val a: Foo1 = Foo1@61191222

scala> a.foo()
Foo!!

クラス Foo のメソッド foo は抽象メソッドなので、Foo は抽象クラスになります。abstract を付けないとエラーになります。クラス Foo1 は Foo を継承していて、抽象メソッド foo をオーバーライドしています。抽象メソッドをオーバーライドする場合は、キーワード override を省略してもかまいません。もちろん、override を付けても大丈夫です。これで、Foo の抽象メソッドをすべてオーバーライドしたので、クラス Foo1 は具象クラスとなり、new でインスタンスを生成することができ、メソッド foo を呼び出すことができます。

●図形のクラス

それでは簡単な例題として、図形の面積を求めるプログラムを作ってみましょう。次のリストを見てください。

リスト : 図形のクラス

abstract class Figure {
  def kindOf: String
  def area: Double
  def print(): Unit = println(kindOf + ": area = " + area)
}

class Triangle(val altiude: Double, val base: Double) extends Figure {
  def kindOf: String = "Triangle"
  def area: Double = altiude * base / 2
}

class Rectangle(val width: Double, val height: Double) extends Figure {
  def kindOf: String = "Rectangle"
  def area: Double = width * height
}

class Circle(val radius: Double) extends Figure {
  def kindOf: String = "Circle"
  def area: Double = radius * radius * Math.PI
}

object sample0902 {
  def main(args: Array[String]): Unit = {
    val a = new Triangle(2, 2)
    val b = new Rectangle(2, 2)
    val c = new Circle(2)
    a.print()
    b.print()
    c.print()
  }
}

クラス Figure は抽象クラスです。メソッド kindOf と area が抽象メソッドで、kindOf は図形の種類を文字列で返し、area は図形の面積を計算して返します。kindOf と area はサブクラスで定義します。

print は図形の種別と面積を表示するメソッドです。ここで、抽象メソッド kindOf と area を呼び出しています。実際には、print を呼び出したインスタンスが属するクラスのメソッドが呼び出されます。つまり、ポリモーフィズムにより適切なメソッドが呼び出されるわけです。

クラス Triangle, Rectangle, Circle は Figure を継承します。どのクラスも抽象メソッド kindOf と area を具体化しています。なお、スーパークラスの抽象メソッドをすべて具体化しないと、そのサブクラスも抽象クラスになるため、コンパイルでエラーになります。ご注意ください。

それでは実行例を示します。

$ scalac sample0902.scala
$ scala sample0902
Triangle: area = 2.0
Rectangle: area = 4.0
Circle: area = 12.566370614359172

正常に動作していますね。

●キャストとポリモーフィズム

Java や Scala では、サブクラスのインスタンスをスーパークラスのデータ型に変換することができます。型変換のことを「キャスト」といいます。特に、サブクラスをスーパークラスに変換することを「アップキャスト」といい、スーパークラスのインスタンスをサブクラスの型に変換することを「ダウンキャスト」といいます。

Java や Scala の場合、サブクラスのインスタンスをスーパークラスの変数 (メソッドの引数) や配列などに代入するとき、自動的にアップキャストが行われます。コンパイルエラーは発生しません。これに対し、スーパークラスのインスタンスをサブクラスの変数などに代入するとき、プログラマが明示的にキャストしないとコンパイルエラーになります。

簡単な例を示しましょう。

scala> class Foo {
     | def display(): Unit = println("Foo")
     | }
     |
     | class Bar1 extends Foo {
     | override def display(): Unit = println("Bar1")
     | }
     |
     | class Bar2 extends Foo {
     | override def display(): Unit = println("Bar2")
     | }
     |
     | class Baz {
     | def display(): Unit = println("Baz")
     | }
// defined class Foo
// defined class Bar1
// defined class Bar2
// defined class Baz

scala> val a = new Foo
val a: Foo = Foo@66223d94

scala> val b = new Bar1
val b: Bar1 = Bar1@3c9ef6e9

scala> val c = new Bar2
val c: Bar2 = Bar2@16f7f59f

scala> val d = new Baz
val d: Baz = Baz@54463380

scala> val a1: Foo = b      // アップキャスト
val a1: Foo = Bar1@3c9ef6e9

scala> val a2: Foo = c      // アップキャスト
val a2: Foo = Bar2@16f7f59f

scala> a.display()
Foo

scala> a1.display()        // ポリモーフィズム
Bar1

scala> a2.display()        // ポリモーフィズム
Bar2

各クラスのインスタンスを生成して変数 a, b, c, d にセットします。Bar1, Bar2 は Foo のサブクラスなので、Foo の変数 a1 に b を、a2 に c を代入することができます。これがアップキャストです。そして、a からメソッド display を呼び出すと Foo と表示されますが、a1, a2 から display を呼び出すと、ポリモーフィズムが働いて Bar1, Bar2 の display が呼び出され、Bar1 と Bar2 が表示されます。

scala> a1.isInstanceOf[Bar1]
val res3: Boolean = true

scala> val b1: Bar1 = a1.asInstanceOf[Bar1]
val b1: Bar1 = Bar1@3c9ef6e9

scala> b1.display()
Bar1

逆に、Bar1 の変数 b1 に a1 を代入するには明示的にキャストする必要があります。これがダウンキャストです。Scala の場合、isInstanceOf[Bar1] でチェックして、true であればダウンキャストすることができます。型変換はメソッド asInstanceOf[型] で行います。これで変数 b1 に a1 を代入することができます。そして、b1 からメソッド display を呼び出すと Bar1 と表示されます。

scala> d.isInstanceOf[Foo]
1 warning found
-- Warning: --------------------------------------------------------------
1 |d.isInstanceOf[Foo]
  |^
  |this will always yield false since type Baz and class Foo are unrelated
val res1: Boolean = false

scala> val a3: Foo = d.asInstanceOf[Foo]
java.lang.ClassCastException: class rs$line$1$Baz cannot be cast to class 
rs$line$1$Foo (rs$line$1$Baz and rs$line$1$Foo are in unnamed module of 
loader dotty.tools.repl.AbstractFileClassLoader @6719a5b8)
  ... 33 elided

それから、Foo と継承関係のない Baz のインスタンスは、キャストしても Foo や Bar の変数に代入することはできません。ご注意くださいませ。

次に、図形のオブジェクトをリストにまとめて格納することを考えてみましょう。Triangle, Rectangle, Circle は型が違うので、同じリストに格納することはできません。この場合、スーパークラス Figure に型変換すると同じリストに格納することができます。次のリストを見てください。

リスト : キャスト (2)

object sample0903 {
  def sumOfArea(xs: List[Figure]): Double = {
    var sum = 0.0
    for (x <- xs) sum += x.area
    sum
  }
  
  def main(args: Array[String]): Unit = {
    val a = new Triangle(2, 2)
    val b = new Rectangle(2, 2)
    val c = new Circle(2)
    a.print()
    b.print()
    c.print()
    println(sumOfArea(List(a, b, c)))
  }
}
$ scalac sample0903.scala
$ scala sample0903
Triangle: area = 2.0
Rectangle: area = 4.0
Circle: area = 12.566370614359172
18.566370614359172

関数 sumOfFigure は図形の面積の合計値を求めます。型変換した場合、サブクラスの情報は失われるため、サブクラス独自のメソッドを呼び出すことはできません。型変換したスーパークラスのメソッドしか利用できませんが、ポリモーフィズムによりサブクラスのメソッドが呼び出されるため、図形の面積を正しく計算することができます。


初版 2014 年 8 月 16 日
改訂 2024 年 12 月 20 日