🔰

Ruby/Railsで学ぶオブジェクト指向入門 継承とポリモーフィズムって何ぞや

に公開

はじめに

こんにちは!本記事では、Ruby/Railsを使ってオブジェクト指向プログラミングの基礎について記載しています。カプセル化とアクセス修飾子編の続きとなります。
https://zenn.dev/osakayakyu/articles/75303ace6a706e

4. 継承とクラスの拡張

4.1 継承とは?(親クラス・子クラスの関係)

継承(Inheritance)は、オブジェクト指向プログラミングの重要な概念の一つです。既存のクラス(親クラス/スーパークラス)の特性(属性とメソッド)を、新しいクラス(子クラス/サブクラス)が引き継ぐ仕組みです。

継承を使うことで以下のような利点があります:

  • コードの再利用性: 共通の機能を親クラスに定義することで、子クラスで同じコードを書く必要がなくなります。
  • コードの整理: 共通機能と特殊機能を階層的に整理できます。
  • 保守性の向上: 共通機能の変更は親クラスだけを修正すれば、すべての子クラスに反映されます。

Rubyでは、継承は < 記号を使って表現します:

class ParentClass
  # 親クラスの定義
end

class ChildClass < ParentClass
  # 子クラスの定義
  # ParentClassのすべての機能を継承
end

継承の具体例を見てみましょう:

# 親クラス
class Vehicle
  attr_accessor :color, :model, :year
  
  def initialize(color, model, year)
    @color = color
    @model = model
    @year = year
  end
  
  def start_engine
    puts "エンジンを始動します"
  end
  
  def stop_engine
    puts "エンジンを停止します"
  end
  
  def info
    "#{@year}年式 #{@color}#{@model}"
  end
end

# 子クラス
class Car < Vehicle
  attr_accessor :doors
  
  def initialize(color, model, year, doors)
    # 親クラスのinitializeを実行
    super(color, model, year)
    @doors = doors
  end
  
  def drive
    puts "車を運転します"
  end
end

# 子クラス
class Motorcycle < Vehicle
  attr_accessor :has_sidecar
  
  def initialize(color, model, year, has_sidecar)
    super(color, model, year)
    @has_sidecar = has_sidecar
  end
  
  def wheelie
    puts "ウィリーをします!"
  end
end

# 使用例
car = Car.new("赤", "フィット", 2020, 5)
motorcycle = Motorcycle.new("黒", "CB400", 2019, false)

# 親クラスから継承したメソッドを呼び出す
puts car.info  # => 2020年式 赤のフィット
puts motorcycle.info  # => 2019年式 黒のCB400

car.start_engine  # => エンジンを始動します
motorcycle.start_engine  # => エンジンを始動します

# 子クラス独自のメソッド
car.drive  # => 車を運転します
motorcycle.wheelie  # => ウィリーをします!

この例では、Vehicleクラスが親クラスとなり、共通の機能(色、モデル、年式、エンジンの操作など)を定義しています。CarMotorcycleはそれぞれVehicleを継承した子クラスで、親クラスの機能をすべて引き継ぎながら、独自の機能(車のドア数やウィリーなど)を追加しています。

Rubyにおける継承の特徴

  1. 単一継承: Rubyでは、一つのクラスは一つの親クラスしか持てません(単一継承)。
  2. Object クラス: 明示的に継承を指定しないクラスは、自動的にObjectクラスを継承します。
  3. 継承チェーン: 継承は連鎖的に行われます。例えば、C < B < Aという関係の場合、CはBを通じてAの機能も継承します。

継承階層の確認にはancestorsメソッドが便利です:

puts Car.ancestors.inspect
# => [Car, Vehicle, Object, Kernel, BasicObject]

4.2 super の使い方

superキーワードは、子クラスから親クラスのメソッドを呼び出すために使用します。これは、親クラスの機能を活用しながら、子クラスで拡張する場合に特に便利です。

主な使用パターンは以下の通りです:

  1. 引数をそのまま渡す super
  2. 特定の引数を指定する super(arg1, arg2, ...)
  3. 引数なしで呼び出す super()

基本的な使い方

class Parent
  def greet(name)
    puts "こんにちは、#{name}さん!"
  end
end

class Child < Parent
  def greet(name)
    puts "挨拶をします:"
    super  # 親クラスのgreetメソッドを呼び出し、nameをそのまま渡す
    puts "よろしくお願いします!"
  end
end

child = Child.new
child.greet("田中")
# 出力:
# 挨拶をします:
# こんにちは、田中さん!
# よろしくお願いします!

イニシャライザでの使用

継承を使う場合、子クラスのinitializeメソッドでsuperを呼び出すことが一般的です:

class Product
  attr_reader :name, :price
  
  def initialize(name, price)
    @name = name
    @price = price
  end
  
  def info
    "#{@name}: #{@price}円"
  end
end

class DiscountProduct < Product
  attr_reader :discount_percentage
  
  def initialize(name, price, discount_percentage)
    super(name, price)  # 親クラスのinitializeを呼び出し
    @discount_percentage = discount_percentage
  end
  
  def discounted_price
    @price * (1 - @discount_percentage / 100.0)
  end
  
  def info
    "#{super} (#{@discount_percentage}%オフ: #{discounted_price.round}円)"
  end
end

product = Product.new("ノートパソコン", 80000)
discount_product = DiscountProduct.new("ノートパソコン", 80000, 20)

puts product.info  # => ノートパソコン: 80000円
puts discount_product.info  # => ノートパソコン: 80000円 (20%オフ: 64000円)

この例では、DiscountProductクラスのイニシャライザでsuper(name, price)を呼び出し、親クラスの初期化処理を実行しています。また、infoメソッドでもsuperを使って親クラスのinfoメソッドの結果を取得し、それに追加情報を付け加えています。

引数なしの super()

メソッドが引数を受け取るが、親クラスのメソッドには引数を渡したくない場合、空の括弧を付けたsuper()を使用します:

class Parent
  def process
    puts "親の処理を実行"
  end
end

class Child < Parent
  def process(option = nil)
    puts "子の処理を実行: オプション #{option}"
    super()  # 引数なしで親のprocessを呼び出す
  end
end

child = Child.new
child.process("テスト")
# 出力:
# 子の処理を実行: オプション テスト
# 親の処理を実行

4.3 メソッドのオーバーライド

オーバーライド(再定義)とは、親クラスで定義されたメソッドを子クラスで再定義することです。子クラスのメソッドが親クラスのメソッドを「上書き」するイメージです。

メソッドをオーバーライドすることで、継承したクラスの振る舞いをカスタマイズできます。

基本的なオーバーライドの例:

class Animal
  def speak
    puts "動物が鳴きます"
  end
end

class Dog < Animal
  def speak  # 親クラスのspeakメソッドをオーバーライド
    puts "ワンワン!"
  end
end

class Cat < Animal
  def speak  # 親クラスのspeakメソッドをオーバーライド
    puts "ニャーン!"
  end
end

animal = Animal.new
dog = Dog.new
cat = Cat.new

animal.speak  # => 動物が鳴きます
dog.speak     # => ワンワン!
cat.speak     # => ニャーン!

この例では、AnimalクラスのspeakメソッドをDogCatクラスでそれぞれオーバーライドしています。各クラスのオブジェクトに対してspeakメソッドを呼び出すと、そのクラスで定義されたバージョンが実行されます。

親クラスのメソッドを拡張する

しばしば、親クラスのメソッドを完全に置き換えるのではなく、その機能を拡張したい場合があります。このような場合、superを使って親クラスのメソッドを呼び出し、その前後に新しい処理を追加します:

class BankAccount
  attr_reader :balance
  
  def initialize(initial_balance = 0)
    @balance = initial_balance
  end
  
  def deposit(amount)
    if amount > 0
      @balance += amount
      puts "#{amount}円を入金しました。残高: #{@balance}円"
      return true
    else
      puts "入金額は正の値である必要があります"
      return false
    end
  end
  
  def withdraw(amount)
    if amount > 0 && amount <= @balance
      @balance -= amount
      puts "#{amount}円を出金しました。残高: #{@balance}円"
      return true
    else
      puts "出金できません(金額が不正か残高不足です)"
      return false
    end
  end
end

class SavingsAccount < BankAccount
  attr_reader :interest_rate
  
  def initialize(initial_balance = 0, interest_rate = 0.05)
    super(initial_balance)  # 親クラスのinitializeを呼び出し
    @interest_rate = interest_rate
  end
  
  def add_interest
    interest = @balance * @interest_rate
    deposit(interest)  # 既存のdepositメソッドを再利用
    puts "金利 #{@interest_rate * 100}%分の#{interest}円を追加しました"
  end
  
  # 出金メソッドをオーバーライドして制限を追加
  def withdraw(amount)
    if @balance - amount < 1000
      puts "最低残高 1000円を下回るため、出金できません"
      return false
    else
      super  # 親クラスのwithdrawを呼び出し
    end
  end
end

# 使用例
savings = SavingsAccount.new(10000, 0.03)

savings.deposit(5000)   # => 5000円を入金しました。残高: 15000円
savings.add_interest    # => 450円を入金しました。残高: 15450円
                        # => 金利 3.0%分の450円を追加しました

savings.withdraw(14000) # => 最低残高 1000円を下回るため、出金できません
savings.withdraw(10000) # => 10000円を出金しました。残高: 5450円

この例では、SavingsAccountクラスがBankAccountクラスを継承し、以下のカスタマイズを行っています:

  1. 拡張: initializeメソッドにinterest_rateパラメータを追加
  2. 追加: 金利を追加するadd_interestメソッドを新規追加
  3. オーバーライド: withdrawメソッドを再定義して、最低残高のチェックを追加

withdrawメソッドのオーバーライドでは、条件をチェックした後、条件を満たす場合はsuperを呼び出して親クラスの実装を利用しています。

4.4 実践問題と解説(2問)

問題1: ECサイトの商品クラス階層

ECサイトの商品管理システムを作成します。以下の要件を満たすクラス階層を設計・実装してください:

  1. 基底クラスとしてProductを作成(商品ID、名前、価格、在庫数を持つ)
  2. PhysicalProductクラスをProductから派生(重量、サイズを追加、送料を計算)
  3. DigitalProductクラスをProductから派生(ダウンロードURLとファイルサイズを追加)
  4. SubscriptionクラスをProductから派生(月額料金と最低契約期間を追加)

各クラスにふさわしいメソッドを追加し、適切にオーバーライドしてください。

解答

class Product
  attr_reader :id, :name, :price
  attr_accessor :stock
  
  def initialize(id, name, price, stock)
    @id = id
    @name = name
    @price = price
    @stock = stock
  end
  
  def available?
    @stock > 0
  end
  
  def purchase(quantity = 1)
    if quantity <= @stock
      @stock -= quantity
      puts "#{@name}#{quantity}個 購入しました。残り在庫: #{@stock}個"
      calculate_price(quantity)
    else
      puts "在庫不足です。在庫数: #{@stock}、注文数: #{quantity}"
      0
    end
  end
  
  def calculate_price(quantity = 1)
    @price * quantity
  end
  
  def display_info
    availability = available? ? "在庫あり(#{@stock}個)" : "在庫切れ"
    "商品ID: #{@id}, 商品名: #{@name}, 価格: #{@price}円, #{availability}"
  end
end

class PhysicalProduct < Product
  attr_reader :weight, :dimensions
  
  def initialize(id, name, price, stock, weight, dimensions)
    super(id, name, price, stock)
    @weight = weight  # kg
    @dimensions = dimensions  # "幅x高さx奥行き" cm
  end
  
  def shipping_cost
    # 重量に基づいて送料を計算
    if @weight < 1
      500
    elsif @weight < 5
      800
    else
      1200
    end
  end
  
  def calculate_price(quantity = 1)
    # 商品代金に送料を追加
    super + shipping_cost
  end
  
  def display_info
    "#{super}, 重量: #{@weight}kg, サイズ: #{@dimensions}cm, 送料: #{shipping_cost}円"
  end
end

class DigitalProduct < Product
  attr_reader :download_url, :file_size
  
  def initialize(id, name, price, stock, download_url, file_size)
    super(id, name, price, stock)
    @download_url = download_url
    @file_size = file_size  # MB
  end
  
  # デジタル商品は在庫の概念を上書き
  def available?
    true  # デジタル商品は常に利用可能
  end
  
  def purchase(quantity = 1)
    puts "#{@name} をダウンロード購入しました。"
    puts "ダウンロードURL: #{@download_url}"
    calculate_price(quantity)
  end
  
  def calculate_price(quantity = 1)
    # デジタル商品は送料無料
    super
  end
  
  def display_info
    "#{super}, タイプ: デジタル, ファイルサイズ: #{@file_size}MB, ダウンロードURL: #{@download_url}"
  end
end

class Subscription < Product
  attr_reader :monthly_fee, :minimum_months
  
  def initialize(id, name, price, stock, monthly_fee, minimum_months)
    super(id, name, price, stock)
    @monthly_fee = monthly_fee
    @minimum_months = minimum_months
  end
  
  def purchase(duration_months = nil)
    duration_months ||= @minimum_months  # 指定がなければ最低契約期間
    
    if duration_months < @minimum_months
      puts "最低契約期間(#{@minimum_months}ヶ月)以上の契約が必要です"
      return 0
    end
    
    if @stock > 0
      @stock -= 1
      total = calculate_price(duration_months)
      puts "#{@name}#{duration_months}ヶ月間 契約しました。料金: #{total}円"
      total
    else
      puts "現在、新規契約を受け付けていません"
      0
    end
  end
  
  def calculate_price(duration_months = @minimum_months)
    # 初期費用 + 月額料金 × 契約月数
    @price + (@monthly_fee * duration_months)
  end
  
  def display_info
    "#{super}, タイプ: サブスクリプション, 月額: #{@monthly_fee}円, 最低契約期間: #{@minimum_months}ヶ月"
  end
end

# 使用例
laptop = PhysicalProduct.new(
  "P001", "ノートパソコン", 80000, 5, 2.5, "35x25x3"
)

ebook = DigitalProduct.new(
  "D001", "プログラミング入門書", 2800, 999, "https://example.com/downloads/ebook1", 15
)

music_service = Subscription.new(
  "S001", "音楽聴き放題サービス", 1000, 100, 980, 6
)

# 情報表示
puts laptop.display_info
puts ebook.display_info
puts music_service.display_info

# 購入
laptop_price = laptop.purchase
puts "ノートパソコン 合計金額: #{laptop_price}円"

ebook_price = ebook.purchase
puts "eBook 合計金額: #{ebook_price}円"

subscription_price = music_service.purchase(12)  # 12ヶ月間契約
puts "音楽サービス 合計金額: #{subscription_price}円"

# 最低契約期間未満の契約を試みる
music_service.purchase(3)  # 最低契約期間未満

解説

この実装では、継承を使って様々な種類の商品をモデル化しています。

  1. Product(基底クラス)

    • すべての商品に共通する属性(ID、名前、価格、在庫数)を持つ
    • 基本的な機能(在庫確認、購入、価格計算、情報表示)を提供する
  2. PhysicalProduct(物理商品)

    • 物理的な商品特有の属性(重量、サイズ)を追加
    • 送料計算機能を追加
    • 価格計算をオーバーライドして送料を加算
    • 情報表示をオーバーライドして追加情報を表示
  3. DigitalProduct(デジタル商品)

    • デジタル商品特有の属性(ダウンロードURL、ファイルサイズ)を追加
    • 在庫の概念を書き換え(常に利用可能)
    • 購入処理をオーバーライドしてダウンロード情報を提供
    • 情報表示をオーバーライドして追加情報を表示
  4. Subscription(サブスクリプション)

    • サブスクリプション特有の属性(月額料金、最低契約期間)を追加
    • 購入処理をオーバーライドして契約期間を扱う
    • 価格計算をオーバーライドして月額料金を反映
    • 情報表示をオーバーライドして追加情報を表示

この例では、以下のような継承の特性が示されています:

  • 共通機能の継承:すべての子クラスがProductの基本機能を引き継いでいる
  • 属性の拡張:各子クラスが独自の属性を追加している
  • メソッドのオーバーライド:各子クラスが親クラスのメソッドを必要に応じて上書きしている
  • ポリモーフィズム:同じメソッド名(purchase, calculate_price, display_infoなど)で異なる動作を実現している

さらに、superを使って親クラスの機能を拡張する方法も示されています(例:PhysicalProduct.calculate_priceメソッド)。

問題2: 形状クラス階層の作成

様々な図形の面積と周囲の長さ(周長)を計算するクラス階層を作成してください。以下の要件を満たす必要があります:

  1. 基底クラスとしてShapeを作成
  2. Rectangle(長方形)、Circle(円)、Triangle(三角形)の各クラスをShapeから派生
  3. 各クラスに適切なプロパティを持たせる
  4. 各クラスに面積計算メソッドareaと周長計算メソッドperimeterを実装
  5. 任意の図形の配列に対して、総面積と総周長を計算できるようにする

解答

class Shape
  def initialize
    # 基底クラスは直接インスタンス化されない想定
    raise "Shape クラスは抽象クラスです" if self.class == Shape
  end
  
  def area
    # サブクラスでオーバーライドされるべき
    raise NotImplementedError, "サブクラスで area メソッドを実装する必要があります"
  end
  
  def perimeter
    # サブクラスでオーバーライドされるべき
    raise NotImplementedError, "サブクラスで perimeter メソッドを実装する必要があります"
  end
  
  def to_s
    "#{self.class}:面積 = #{area}, 周長 = #{perimeter}"
  end
end

class Rectangle < Shape
  attr_reader :width, :height
  
  def initialize(width, height)
    @width = width
    @height = height
  end
  
  def area
    @width * @height
  end
  
  def perimeter
    2 * (@width + @height)
  end
  
  def to_s
    "長方形(幅 #{@width}、高さ #{@height}):面積 = #{area}, 周長 = #{perimeter}"
  end
end

class Square < Rectangle
  def initialize(side)
    super(side, side)
  end
  
  def to_s
    "正方形(一辺 #{@width}):面積 = #{area}, 周長 = #{perimeter}"
  end
end

class Circle < Shape
  attr_reader :radius
  
  def initialize(radius)
    @radius = radius
  end
  
  def area
    Math::PI * @radius ** 2
  end
  
  def perimeter
    2 * Math::PI * @radius
  end
  
  def to_s
    "円(半径 #{@radius}):面積 = #{area.round(2)}, 周長 = #{perimeter.round(2)}"
  end
end

class Triangle < Shape
  attr_reader :side_a, :side_b, :side_c
  
  def initialize(side_a, side_b, side_c)
    @side_a = side_a
    @side_b = side_b
    @side_c = side_c
    
    # 三角形の成立条件をチェック
    if !valid_triangle?
      raise ArgumentError, "与えられた3辺では三角形を構成できません"
    end
  end
  
  def area
    # ヘロンの公式で面積を計算
    s = perimeter / 2.0
    Math.sqrt(s * (s - @side_a) * (s - @side_b) * (s - @side_c))
  end
  
  def perimeter
    @side_a + @side_b + @side_c
  end
  
  def to_s
    "三角形(辺 #{@side_a}, #{@side_b}, #{@side_c}):面積 = #{area.round(2)}, 周長 = #{perimeter}"
  end
  
  private
  
  def valid_triangle?
    # 三角形の成立条件:いずれの2辺の和も残りの1辺より大きい
    (@side_a + @side_b > @side_c) &&
    (@side_b + @side_c > @side_a) &&
    (@side_c + @side_a > @side_b)
  end
end

# 図形の総面積と総周長を計算するクラスメソッド
class ShapeCalculator
  def self.total_area(shapes)
    total = 0
    shapes.each do |shape|
      total += shape.area
    end
    total
  end
  
  def self.total_perimeter(shapes)
    total = 0
    shapes.each do |shape|
      total += shape.perimeter
    end
    total
  end
  
  # 上記のメソッドをより簡潔に書いた版
  def self.total_area_short(shapes)
    shapes.sum(&:area)
  end
  
  def self.total_perimeter_short(shapes)
    shapes.sum(&:perimeter)
  end
end

# 使用例
begin
  # Shape.new  # エラーが発生する
  
  rectangle = Rectangle.new(5, 4)
  square = Square.new(5)
  circle = Circle.new(3)
  triangle = Triangle.new(3, 4, 5)
  
  puts rectangle
  puts square
  puts circle
  puts triangle
  
  # 無効な三角形
  # invalid_triangle = Triangle.new(1, 2, 10)  # エラーが発生する
  
  shapes = [rectangle, square, circle, triangle]
  
  total_area = ShapeCalculator.total_area(shapes)
  total_perimeter = ShapeCalculator.total_perimeter(shapes)
  
  puts "全図形の総面積: #{total_area.round(2)}"
  puts "全図形の総周長: #{total_perimeter.round(2)}"
  
rescue => e
  puts "エラーが発生しました: #{e.message}"
end

解説

この実装では、図形を表す抽象基底クラスShapeと、それを継承する具体的な図形クラス(Rectangle, Circle, Triangle)を定義しています。さらに、Rectangleを継承したSquareクラスも追加しています。

  1. Shape(基底クラス)

    • 抽象クラスとして機能し、直接インスタンス化されないよう設計
    • areaperimeterメソッドを定義し、サブクラスでオーバーライドするよう要求
    • 共通のto_sメソッドを提供
  2. Rectangle(長方形)

    • 幅と高さを属性として持つ
    • 面積と周長を計算するメソッドを実装
  3. Square(正方形)

    • Rectangleの特殊ケースとして実装(幅と高さが等しい)
    • コンストラクタでは一辺の長さだけを受け取り、親クラスのコンストラクタに同じ値を渡す
  4. Circle(円)

    • 半径を属性として持つ
    • 円周率(Math::PI)を使って面積と周長を計算
  5. Triangle(三角形)

    • 3辺の長さを属性として持つ
    • 三角形の成立条件をチェック
    • ヘロンの公式を使って面積を計算
  6. ShapeCalculator(ユーティリティクラス):

    • 図形の配列を受け取り、総面積と総周長を計算するクラスメソッドを提供

この例では、継承とポリモーフィズムを活用して、異なる図形に対して同じインターフェース(areaperimeterメソッド)を提供しています。これにより、図形の種類を意識せずに面積と周長を計算できます。

また、この実装では以下のようなオブジェクト指向の概念が示されています:

  • 抽象クラスShapeクラスは抽象クラスとして機能し、直接インスタンス化できないようにしています。
  • メソッドの要求:基底クラスでNotImplementedErrorを発生させることで、サブクラスでメソッドを実装するよう促しています。
  • 継承の階層SquareRectangleのサブクラスとして実装され、継承の階層構造を示しています。
  • 入力値の検証Triangleクラスでは、三角形の成立条件をチェックしています。
  • ポリモーフィズムShapeCalculatorクラスのメソッドは、図形の具体的な種類を気にせず、共通インターフェースを使って計算を行っています。

終わりに

ここまでお読み頂きありがとうございました!!それでは5章の記事でまたお会いしましょう!

追記(2025年月4月28日)

本記事の5章目となるインターフェースと抽象クラスについての記事を執筆しました!
ぜひお読みくださいませ!!
https://zenn.dev/osakayakyu/articles/fbc7919edf16d5

Discussion