🔰

Ruby/Railsで学ぶオブジェクト指向入門 カプセル化って何ぞや

に公開

はじめに

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

3. カプセル化とアクセス修飾子

3.1 カプセル化の概念

カプセル化(Encapsulation)は、オブジェクト指向プログラミングの重要な原則の一つです。カプセル化には主に以下の2つの側面があります:

  1. データと振る舞いをひとまとめにする:オブジェクトに関連するデータ(属性)とそれを操作するメソッド(振る舞い)を一つのクラスにまとめます。

  2. 実装の詳細を隠蔽する:オブジェクトの内部データを外部から直接アクセスできないようにし、代わりに安全に制御されたメソッドを通じてのみアクセスを許可します。

カプセル化の利点:

  • データの保護:オブジェクトの内部状態を不正な変更から守ります。
  • 実装の変更容易性:内部実装を変更しても、外部インターフェース(公開メソッド)が同じであれば、他のコードに影響を与えません。
  • 複雑さの管理:使用者がオブジェクトの内部の複雑な実装を理解する必要がなくなります。

例えば、銀行口座を表すクラスでは、残高(@balance)を直接変更できないようにし、入金(deposit)や出金(withdraw)といったメソッドを通じてのみ変更できるようにします:

class BankAccount
  def initialize(account_number, owner_name, initial_balance = 0)
    @account_number = account_number
    @owner_name = owner_name
    @balance = initial_balance
  end
  
  # 残高の読み取りは許可
  def balance
    @balance
  end
  
  # 直接残高を変更するメソッドは提供しない
  # 代わりに、deposit/withdrawメソッドを通じてのみ変更可能
  
  def deposit(amount)
    if amount > 0
      @balance += amount
      puts "#{amount}円を入金しました"
      true
    else
      puts "入金額は正の値である必要があります"
      false
    end
  end
  
  def withdraw(amount)
    if amount > 0 && amount <= @balance
      @balance -= amount
      puts "#{amount}円を出金しました"
      true
    else
      puts "出金できません(金額が不正か残高不足です)"
      false
    end
  end
end

account = BankAccount.new("12345", "山田太郎", 10000)
puts account.balance  # => 10000

account.deposit(5000)
puts account.balance  # => 15000

account.withdraw(3000)
puts account.balance  # => 12000

# balance=メソッドは定義されていないため、以下はエラーになる
# account.balance = 1000000  # => NoMethodError

このように、カプセル化によって、オブジェクトの使用者はクラスの実装詳細を知る必要がなく、公開されたメソッドを通じて安全にオブジェクトを操作できます。

3.2 private / protected / public の使い方

Rubyには、メソッドのアクセス制御のために3つのアクセス修飾子があります:

  1. public(公開):デフォルトのアクセスレベルです。クラスの外部からアクセス可能です。
  2. private(非公開):クラスの外部からアクセスできません。同じクラス内のメソッドからのみ呼び出せます。
  3. protected(保護):クラスの外部からはアクセスできませんが、同じクラスか、そのサブクラスのインスタンスからはアクセスできます。

以下の例で、それぞれの使い方を見てみましょう:

class Person
  def initialize(first_name, last_name, age)
    @first_name = first_name
    @last_name = last_name
    @age = age
  end
  
  # publicメソッド(デフォルト)
  def full_name
    "#{@first_name} #{@last_name}"
  end
  
  def greet
    "こんにちは、#{full_name}です!"
  end
  
  def adult?
    check_age  # privateメソッドを内部で使用
  end
  
  def older_than?(other_person)
    @age > other_person.get_age  # protectedメソッドを使用
  end
  
  # protectedメソッド
  protected
  
  def get_age
    @age
  end
  
  # privateメソッド
  private
  
  def check_age
    @age >= 20  # 日本の成人年齢
  end
end

# 使用例
person1 = Person.new("太郎", "山田", 25)
person2 = Person.new("花子", "鈴木", 18)

puts person1.full_name  # => 太郎 山田
puts person1.greet      # => こんにちは、太郎 山田です!
puts person1.adult?     # => true
puts person2.adult?     # => false

puts person1.older_than?(person2)  # => true
puts person2.older_than?(person1)  # => false

# エラーになる呼び出し
# puts person1.get_age    # => NoMethodError (protected method)
# puts person1.check_age  # => NoMethodError (private method)

アクセス修飾子の使い分け

  • public:クラスの外部インターフェースを構成するメソッドに使用します。一般的に、オブジェクトの主な機能を提供するメソッドはpublicにします。

  • private:クラスの内部実装の詳細を隠蔽するために使用します。これらのメソッドはクラス自身の内部処理のためのものであり、外部からは呼び出せません。

  • protected:同じクラスのインスタンス間での比較や協調動作が必要な場合に使用します。例えば、2つのオブジェクトを比較するメソッドなど。

Rubyのprivateメソッドの特徴

Rubyのprivateメソッドにはいくつかの特徴があります:

  1. 明示的なレシーバ(呼び出し元オブジェクト)を指定できません(Ruby 2.7以前)。
  2. 継承されます。
  3. selfをレシーバにすることはできません(Ruby 2.7以前)。

Ruby 2.7以降では、privateメソッドに対してselfをレシーバとして使用できるようになりました。

class MyClass
  def public_method
    private_method  # OK
    self.private_method  # Ruby 2.7以降はOK、それ以前はエラー
  end
  
  private
  
  def private_method
    puts "This is a private method"
  end
end

obj = MyClass.new
obj.public_method  # OK
# obj.private_method  # エラー

3.3 ゲッターとセッターの活用

オブジェクトの属性(インスタンス変数)にアクセスするために、ゲッターメソッド(読み取り用)とセッターメソッド(書き込み用)を使用します。Rubyでは、これらのメソッドを簡単に定義するための以下のマクロが用意されています:

  • attr_reader:読み取り専用のゲッターメソッドを定義します。
  • attr_writer:書き込み専用のセッターメソッドを定義します。
  • attr_accessor:読み書き両方のゲッターとセッターメソッドを定義します。
class Student
  # name属性の読み書きメソッドを生成
  attr_accessor :name
  
  # id属性の読み取り専用メソッドを生成
  attr_reader :id
  
  # grade属性の書き込み専用メソッドを生成
  attr_writer :grade
  
  def initialize(id, name)
    @id = id
    @name = name
    @grade = nil
  end
  
  # gradeの読み取り用のカスタムメソッド
  def grade
    if @grade.nil?
      "成績はまだ登録されていません"
    else
      "成績: #{@grade}"
    end
  end
end

student = Student.new(1, "田中")

puts student.name  # => 田中
puts student.id    # => 1
puts student.grade # => 成績はまだ登録されていません

student.name = "田中修正"
puts student.name  # => 田中修正

student.grade = "A"
puts student.grade # => 成績: A

# エラーになる操作
# student.id = 2  # => NoMethodError (呼び出せるセッターがない)

カスタムゲッターとセッター

単純にインスタンス変数の値を返す/設定するだけではなく、追加のロジックが必要な場合は、カスタムのゲッターやセッターメソッドを定義できます:

class Product
  attr_reader :name, :price
  
  def initialize(name, price)
    @name = name
    self.price = price  # セッターを使って初期化
  end
  
  # カスタムセッター
  def price=(new_price)
    if new_price >= 0
      @price = new_price
    else
      raise ArgumentError, "価格は0以上である必要があります"
    end
  end
  
  # カスタムゲッター
  def price_with_tax
    (@price * 1.1).round  # 10%の消費税を追加
  end
end

product = Product.new("本", 1000)
puts product.price          # => 1000
puts product.price_with_tax # => 1100

product.price = 1500
puts product.price          # => 1500
puts product.price_with_tax # => 1650

# エラーになる操作
# product.price = -500  # => ArgumentError: 価格は0以上である必要があります

メソッド名の命名規則

Rubyでは、セッターメソッドは属性名=という形式で定義します。そのため、attr_writer :nameは実際にはname=というメソッドを定義しています。

また、真偽値を返すメソッド(predicateメソッド)は慣習的に名前の末尾に?をつけます(例:empty?valid?など)。

class User
  attr_reader :name, :admin
  
  def initialize(name, admin = false)
    @name = name
    @admin = admin
  end
  
  # predicateメソッド
  def admin?
    @admin
  end
  
  def name_valid?
    !@name.nil? && @name.length >= 3
  end
end

user1 = User.new("山田太郎", true)
user2 = User.new("佐藤")

puts user1.admin?      # => true
puts user2.admin?      # => false
puts user1.name_valid? # => true
puts user2.name_valid? # => true

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

問題1: 体温管理クラスの作成

健康管理アプリのために、体温を記録・管理するクラスTemperatureTrackerを作成してください。以下の要件を満たす必要があります:

  • 体温データを追加できる(摂氏温度で)
  • 体温データの一覧を取得できる
  • 最高体温、最低体温、平均体温を取得できる
  • 体温が正常範囲内(36.0℃~37.5℃)かどうかをチェックできる
  • 摂氏と華氏の変換ができる(内部では摂氏で保存)

解答

class TemperatureTracker
  # 読み取り専用のゲッター
  attr_reader :temperatures
  
  # 定数
  NORMAL_RANGE = 36.0..37.5  # 正常体温の範囲(摂氏)
  
  def initialize
    @temperatures = []
  end
  
  # 体温データを追加するメソッド
  def add_temperature(temp, unit = :celsius)
    # 華氏の場合は摂氏に変換
    celsius_temp = (unit == :fahrenheit) ? fahrenheit_to_celsius(temp) : temp
    
    if valid_temperature?(celsius_temp)
      @temperatures << celsius_temp
      puts "体温 #{celsius_temp.round(1)}℃ を記録しました"
      return true
    else
      puts "無効な体温値です(#{celsius_temp.round(1)}℃)"
      return false
    end
  end
  
  # 最高体温を取得するメソッド
  def max_temperature
    return nil if @temperatures.empty?
    @temperatures.max
  end
  
  # 最低体温を取得するメソッド
  def min_temperature
    return nil if @temperatures.empty?
    @temperatures.min
  end
  
  # 平均体温を取得するメソッド
  def average_temperature
    return nil if @temperatures.empty?
    @temperatures.sum / @temperatures.size
  end
  
  # 最新の体温が正常範囲内かチェックするメソッド
  def latest_temperature_normal?
    return false if @temperatures.empty?
    NORMAL_RANGE.include?(@temperatures.last)
  end
  
  # 摂氏温度を華氏に変換するメソッド
  def celsius_to_fahrenheit(celsius)
    (celsius * 9/5.0) + 32
  end
  
  # 統計情報を表示するメソッド
  def display_stats
    if @temperatures.empty?
      puts "記録された体温データがありません"
      return
    end
    
    puts "体温記録の統計:"
    puts "記録回数: #{@temperatures.size}回"
    puts "最高体温: #{max_temperature.round(1)}℃ (#{celsius_to_fahrenheit(max_temperature).round(1)}°F)"
    puts "最低体温: #{min_temperature.round(1)}℃ (#{celsius_to_fahrenheit(min_temperature).round(1)}°F)"
    puts "平均体温: #{average_temperature.round(1)}℃"
    puts "最新の体温: #{@temperatures.last.round(1)}℃ (#{celsius_to_fahrenheit(@temperatures.last).round(1)}°F)"
    puts "体温状態: #{latest_temperature_normal? ? '正常' : '異常'}"
  end
  
  private
  
  # 華氏温度を摂氏に変換するメソッド
  def fahrenheit_to_celsius(fahrenheit)
    (fahrenheit - 32) * 5/9.0
  end
  
  # 体温値が有効かチェックするメソッド
  def valid_temperature?(temp)
    temp.is_a?(Numeric) && temp >= 30 && temp <= 45
  end
end

# 使用例
tracker = TemperatureTracker.new

# 体温データの追加(摂氏)
tracker.add_temperature(36.7)
tracker.add_temperature(37.2)
tracker.add_temperature(36.5)

# 体温データの追加(華氏)
tracker.add_temperature(99.5, :fahrenheit)  # 約37.5℃

# 無効なデータ
tracker.add_temperature(42.0)  # 高すぎる
tracker.add_temperature(25.0)  # 低すぎる

# 統計情報の表示
tracker.display_stats

解説

このTemperatureTrackerクラスでは、カプセル化の原則に従って以下のように実装しています:

  1. データの保護:体温データは@temperatures配列に保存されますが、このデータは直接変更できません(attr_readerのみで、attr_writerはありません)。データの追加はadd_temperatureメソッドを通じてのみ行えます。

  2. メソッドのアクセス制御

    • publicメソッド:ユーザーが直接使用するメソッド(add_temperature, max_temperatureなど)。
    • privateメソッド:内部実装の詳細であるfahrenheit_to_celsiusvalid_temperature?メソッド。これらはクラスの外部からは呼び出せません。
  3. データの検証add_temperatureメソッドでは、追加される体温値が有効かどうかをvalid_temperature?メソッドでチェックし、無効な値は追加されないようにしています。

  4. 単位の変換:華氏温度が入力された場合は、内部で使用される摂氏温度に変換されます。これにより、内部データの一貫性が保たれます。

このクラスは単純な体温記録機能に限定されており、そのための適切なメソッドのみを公開しています。内部実装の詳細(単位変換や値の検証)は隠蔽されており、必要に応じて変更できますが、クラスの使用者に影響を与えることはありません。

問題2: ユーザー認証システムの作成

ウェブアプリケーションのための簡易的なユーザー認証システムを作成してください。以下の要件を満たす必要があります:

  • ユーザー情報(ユーザー名、メールアドレス、パスワード)を管理する
  • パスワードは暗号化して保存する
  • ユーザー登録、ログイン機能を提供する
  • ユーザー情報の更新機能を提供する
  • パスワードは外部から直接アクセスできないようにする

解答

require 'digest'  # パスワードのハッシュ化に使用

class User
  # 読み取り専用の属性
  attr_reader :username, :email
  
  def initialize(username, email, password)
    @username = username
    self.email = email  # バリデーションのためにセッターを使用
    set_password(password)  # privateメソッドを使用
    @logged_in = false
  end
  
  # メールアドレスのセッター(バリデーション付き)
  def email=(new_email)
    if valid_email?(new_email)
      @email = new_email
    else
      raise ArgumentError, "無効なメールアドレスです: #{new_email}"
    end
  end
  
  # パスワードの変更
  def change_password(old_password, new_password)
    if authenticate(old_password)
      set_password(new_password)
      puts "パスワードが変更されました"
      return true
    else
      puts "現在のパスワードが正しくありません"
      return false
    end
  end
  
  # ユーザーの認証(ログイン)
  def authenticate(password)
    hashed_input = hash_password(password, @salt)
    return hashed_input == @hashed_password
  end
  
  # ログイン状態を管理するメソッド
  def login(password)
    if authenticate(password)
      @logged_in = true
      puts "#{@username}さんがログインしました"
      return true
    else
      puts "ユーザー名またはパスワードが正しくありません"
      return false
    end
  end
  
  # ログアウトメソッド
  def logout
    if @logged_in
      @logged_in = false
      puts "#{@username}さんがログアウトしました"
    else
      puts "ログインしていません"
    end
  end
  
  # ログイン状態を確認するメソッド
  def logged_in?
    @logged_in
  end
  
  # ユーザー情報を表示するメソッド
  def display_info
    puts "ユーザー名: #{@username}"
    puts "メールアドレス: #{@email}"
    puts "ログイン状態: #{@logged_in ? 'ログイン中' : 'ログアウト'}"
  end
  
  private
  
  # パスワードをハッシュ化して保存するメソッド
  def set_password(password)
    if valid_password?(password)
      @salt = generate_salt  # ソルトを生成
      @hashed_password = hash_password(password, @salt)
    else
      raise ArgumentError, "パスワードは8文字以上必要です"
    end
  end
  
  # ソルトを生成するメソッド
  def generate_salt
    SecureRandom.hex(10)
  end
  
  # パスワードとソルトを組み合わせてハッシュ化するメソッド
  def hash_password(password, salt)
    Digest::SHA256.hexdigest(password + salt)
  end
  
  # メールアドレスの形式を検証するメソッド
  def valid_email?(email)
    # 簡易的なメール形式チェック(実際のアプリケーションではもっと厳密に)
    email.to_s.include?('@') && email.to_s.include?('.')
  end
  
  # パスワードの強度を検証するメソッド
  def valid_password?(password)
    password.to_s.length >= 8  # 8文字以上
  end
end

# ユーザー管理クラス
class UserManager
  def initialize
    @users = {}  # ユーザー名をキー、Userオブジェクトを値とするハッシュ
  end
  
  # ユーザー登録
  def register(username, email, password)
    if @users[username]
      puts "ユーザー名 #{username} は既に使用されています"
      return false
    end
    
    begin
      @users[username] = User.new(username, email, password)
      puts "ユーザー #{username} が登録されました"
      return true
    rescue ArgumentError => e
      puts "ユーザー登録エラー: #{e.message}"
      return false
    end
  end
  
  # ユーザーを取得
  def get_user(username)
    @users[username]
  end
  
  # ユーザーのログイン
  def login(username, password)
    user = get_user(username)
    if user
      return user.login(password)
    else
      puts "ユーザー #{username} が見つかりません"
      return false
    end
  end
  
  # 登録ユーザー一覧(デバッグ用)
  def list_users
    puts "===== 登録ユーザー一覧 ====="
    if @users.empty?
      puts "登録ユーザーはいません"
    else
      @users.each_key do |username|
        puts "- #{username}"
      end
    end
  end
end

# 使用例
user_manager = UserManager.new

# ユーザー登録
user_manager.register("tanaka", "tanaka@example.com", "password123")
user_manager.register("suzuki", "suzuki@example.com", "securepw456")

# 無効なデータでの登録
user_manager.register("yamada", "invalid-email", "pw")  # 無効なメールとパスワード
user_manager.register("tanaka", "another@example.com", "newpassword")  # 既存ユーザー名

# ユーザー一覧
user_manager.list_users

# ログイン
user_manager.login("tanaka", "password123")  # 成功
user_manager.login("tanaka", "wrongpassword")  # 失敗
user_manager.login("nonexistent", "anypassword")  # 存在しないユーザー

# ユーザー情報とパスワード変更
tanaka = user_manager.get_user("tanaka")
if tanaka
  tanaka.display_info
  tanaka.change_password("password123", "newpassword456")
  tanaka.logout
  tanaka.login("newpassword456")  # 新しいパスワードでログイン
end

解説

このユーザー認証システムでは、UserクラスとUserManagerクラスの2つのクラスを使用して、効果的にカプセル化を実現しています:

1. User クラス

  • データの保護:パスワードは直接アクセスできないように完全に隠蔽されています。パスワードはハッシュ化して@hashed_passwordに保存され、元のパスワードは保存されません。

  • アクセス制御

    • public メソッド:ユーザーが使用する必要があるメソッド(authenticate, login, logoutなど)
    • private メソッド:内部実装の詳細(set_password, hash_password, valid_email?など)
  • データの検証:メールアドレスとパスワードは設定前に検証され、無効な値は例外を発生させます。

2. UserManager クラス

  • ユーザーの登録、取得、ログインなどの機能を提供します。
  • 複数のユーザーを管理し、ユーザー名の重複チェックを行います。

カプセル化の利点

このシステムでは、以下のようにカプセル化の利点が活かされています:

  • セキュリティの向上:パスワードは外部から直接アクセスできず、ハッシュ化して保存されています。さらに、ソルトを使用して同じパスワードでも異なるハッシュになるようにしています。

  • データの整合性:メールアドレスやパスワードは設定前に検証されるため、無効なデータが保存されることはありません。

  • 実装の詳細の隠蔽:パスワードのハッシュ化やソルトの生成などの実装詳細は、privateメソッドとして隠蔽されています。

これにより、安全で使いやすいユーザー認証システムが実現されています。ユーザーはパスワードを直接参照することはできませんが、authenticateメソッドを通じて認証を行うことができます。また、パスワードの変更も、古いパスワードを正しく入力した場合にのみ許可されます。

主要なカプセル化ポイント

  1. パスワードの隠蔽: @hashed_password@saltはprivateで、外部からアクセスできません。

  2. 検証付きセッター: メールアドレスの変更時にvalid_email?メソッドで検証を行います。

  3. ユーザー名の一意性: UserManagerクラスがユーザー名の重複を防ぎます。

  4. 認証プロセス: パスワードの検証はauthenticateメソッドを通じてのみ行われ、内部実装の詳細は隠蔽されています。

実際のアプリケーションでの拡張ポイント

実際のRailsアプリケーションでこのようなユーザー認証システムを実装する場合、以下のような拡張を検討するとよいでしょう:

  1. パスワードのさらなる強化:

    • BCryptなどのより強力なハッシュアルゴリズムの使用
    • パスワード強度のより詳細なチェック(大文字、小文字、数字、記号の組み合わせなど)
  2. アカウント管理機能:

    • パスワードリセット機能
    • アカウントロック機能(複数回ログイン失敗時)
    • メール認証機能
  3. セッション管理:

    • トークンベースの認証
    • 「ログイン状態を保持する」機能
  4. 権限管理:

    • ロールベースのアクセス制御(管理者、一般ユーザーなど)
    • 特定の操作に対する権限チェック

実際のRailsアプリケーションでは、devisesorceryなどの認証ジェムを使用することで、これらの機能を簡単に実装できます。しかし、基本的な考え方はこの例で示したカプセル化の原則に基づいています。

終わりに

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

追記(2025年月4月17日)

本記事の4章目となる継承とクラスの拡張についての記事を執筆しました!ぜひお読みくださいませ!!
https://zenn.dev/osakayakyu/articles/1eeae55c3c5890

Discussion