Ruby/Railsで学ぶオブジェクト指向入門 継承とポリモーフィズムって何ぞや
はじめに
こんにちは!本記事では、Ruby/Railsを使ってオブジェクト指向プログラミングの基礎について記載しています。カプセル化とアクセス修飾子編の続きとなります。
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
クラスが親クラスとなり、共通の機能(色、モデル、年式、エンジンの操作など)を定義しています。Car
とMotorcycle
はそれぞれVehicle
を継承した子クラスで、親クラスの機能をすべて引き継ぎながら、独自の機能(車のドア数やウィリーなど)を追加しています。
Rubyにおける継承の特徴
- 単一継承: Rubyでは、一つのクラスは一つの親クラスしか持てません(単一継承)。
-
Object クラス: 明示的に継承を指定しないクラスは、自動的に
Object
クラスを継承します。 -
継承チェーン: 継承は連鎖的に行われます。例えば、
C < B < A
という関係の場合、CはBを通じてAの機能も継承します。
継承階層の確認にはancestors
メソッドが便利です:
puts Car.ancestors.inspect
# => [Car, Vehicle, Object, Kernel, BasicObject]
4.2 super の使い方
super
キーワードは、子クラスから親クラスのメソッドを呼び出すために使用します。これは、親クラスの機能を活用しながら、子クラスで拡張する場合に特に便利です。
主な使用パターンは以下の通りです:
- 引数をそのまま渡す
super
- 特定の引数を指定する
super(arg1, arg2, ...)
- 引数なしで呼び出す
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
メソッドをDog
とCat
クラスでそれぞれオーバーライドしています。各クラスのオブジェクトに対して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
クラスを継承し、以下のカスタマイズを行っています:
-
拡張:
initialize
メソッドにinterest_rate
パラメータを追加 -
追加: 金利を追加する
add_interest
メソッドを新規追加 -
オーバーライド:
withdraw
メソッドを再定義して、最低残高のチェックを追加
withdraw
メソッドのオーバーライドでは、条件をチェックした後、条件を満たす場合はsuper
を呼び出して親クラスの実装を利用しています。
4.4 実践問題と解説(2問)
問題1: ECサイトの商品クラス階層
ECサイトの商品管理システムを作成します。以下の要件を満たすクラス階層を設計・実装してください:
- 基底クラスとして
Product
を作成(商品ID、名前、価格、在庫数を持つ) -
PhysicalProduct
クラスをProduct
から派生(重量、サイズを追加、送料を計算) -
DigitalProduct
クラスをProduct
から派生(ダウンロードURLとファイルサイズを追加) -
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) # 最低契約期間未満
解説:
この実装では、継承を使って様々な種類の商品をモデル化しています。
-
Product(基底クラス):
- すべての商品に共通する属性(ID、名前、価格、在庫数)を持つ
- 基本的な機能(在庫確認、購入、価格計算、情報表示)を提供する
-
PhysicalProduct(物理商品):
- 物理的な商品特有の属性(重量、サイズ)を追加
- 送料計算機能を追加
- 価格計算をオーバーライドして送料を加算
- 情報表示をオーバーライドして追加情報を表示
-
DigitalProduct(デジタル商品):
- デジタル商品特有の属性(ダウンロードURL、ファイルサイズ)を追加
- 在庫の概念を書き換え(常に利用可能)
- 購入処理をオーバーライドしてダウンロード情報を提供
- 情報表示をオーバーライドして追加情報を表示
-
Subscription(サブスクリプション):
- サブスクリプション特有の属性(月額料金、最低契約期間)を追加
- 購入処理をオーバーライドして契約期間を扱う
- 価格計算をオーバーライドして月額料金を反映
- 情報表示をオーバーライドして追加情報を表示
この例では、以下のような継承の特性が示されています:
-
共通機能の継承:すべての子クラスが
Product
の基本機能を引き継いでいる - 属性の拡張:各子クラスが独自の属性を追加している
- メソッドのオーバーライド:各子クラスが親クラスのメソッドを必要に応じて上書きしている
-
ポリモーフィズム:同じメソッド名(
purchase
,calculate_price
,display_info
など)で異なる動作を実現している
さらに、super
を使って親クラスの機能を拡張する方法も示されています(例:PhysicalProduct.calculate_price
メソッド)。
問題2: 形状クラス階層の作成
様々な図形の面積と周囲の長さ(周長)を計算するクラス階層を作成してください。以下の要件を満たす必要があります:
- 基底クラスとして
Shape
を作成 -
Rectangle
(長方形)、Circle
(円)、Triangle
(三角形)の各クラスをShape
から派生 - 各クラスに適切なプロパティを持たせる
- 各クラスに面積計算メソッド
area
と周長計算メソッドperimeter
を実装 - 任意の図形の配列に対して、総面積と総周長を計算できるようにする
解答:
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
クラスも追加しています。
-
Shape(基底クラス):
- 抽象クラスとして機能し、直接インスタンス化されないよう設計
-
area
とperimeter
メソッドを定義し、サブクラスでオーバーライドするよう要求 - 共通の
to_s
メソッドを提供
-
Rectangle(長方形):
- 幅と高さを属性として持つ
- 面積と周長を計算するメソッドを実装
-
Square(正方形):
-
Rectangle
の特殊ケースとして実装(幅と高さが等しい) - コンストラクタでは一辺の長さだけを受け取り、親クラスのコンストラクタに同じ値を渡す
-
-
Circle(円):
- 半径を属性として持つ
- 円周率(
Math::PI
)を使って面積と周長を計算
-
Triangle(三角形):
- 3辺の長さを属性として持つ
- 三角形の成立条件をチェック
- ヘロンの公式を使って面積を計算
-
ShapeCalculator(ユーティリティクラス):
- 図形の配列を受け取り、総面積と総周長を計算するクラスメソッドを提供
この例では、継承とポリモーフィズムを活用して、異なる図形に対して同じインターフェース(area
とperimeter
メソッド)を提供しています。これにより、図形の種類を意識せずに面積と周長を計算できます。
また、この実装では以下のようなオブジェクト指向の概念が示されています:
-
抽象クラス:
Shape
クラスは抽象クラスとして機能し、直接インスタンス化できないようにしています。 -
メソッドの要求:基底クラスで
NotImplementedError
を発生させることで、サブクラスでメソッドを実装するよう促しています。 -
継承の階層:
Square
はRectangle
のサブクラスとして実装され、継承の階層構造を示しています。 -
入力値の検証:
Triangle
クラスでは、三角形の成立条件をチェックしています。 -
ポリモーフィズム:
ShapeCalculator
クラスのメソッドは、図形の具体的な種類を気にせず、共通インターフェースを使って計算を行っています。
終わりに
ここまでお読み頂きありがとうございました!!それでは5章の記事でまたお会いしましょう!
追記(2025年月4月28日)
本記事の5章目となるインターフェースと抽象クラスについての記事を執筆しました!
ぜひお読みくださいませ!!
Discussion