State, Validation, Modules และ Composition
โค้ด Ruby ใช้ร่วมจากโฟลเดอร์ en/ ของต้นฉบับ เพื่อให้สองภาษาผูกกับชุดทดสอบเดียวกัน
th/topic_06_classes_modules_composition
ภาพรวม
ทำไมหัวข้อนี้จึงสำคัญ
นี่คือจุดที่ผู้เรียนเริ่มออกแบบ object เล็ก ๆ ที่ทำงานร่วมกัน แทนที่จะเขียน functions แยก กันแบบโดด ๆ หัวข้อนี้จึงแนะนำทั้ง state ภายใน, validation, พฤติกรรมที่แชร์ผ่าน modules และการส่ง dependency เข้ามาผ่าน composition
สิ่งที่ผมอยากให้คุณทำได้เมื่อจบหัวข้อนี้
เมื่อจบหัวข้อนี้ คุณควรจะ:
- ใช้
attr_readerเพื่อเปิดเผย state อย่างตั้งใจ - คุ้มครอง invariants ของ object ด้วย validation methods แบบ private
- อธิบายบทบาทของ modules เมื่อใช้แชร์พฤติกรรม
- ใช้ composition และรับ object ร่วมงานจากภายนอก เพื่อให้ classes โฟกัสงานของตัวเอง
- เทียบ composition กับการออกแบบที่พึ่ง inheritance หนัก ๆ ได้
จุดที่ผมใช้ดูความเข้าใจ
ผมอยากให้คุณอธิบายได้ว่าทำไม object จึงเป็นเจ้าของ state ของมันเอง และทำไม dependency อย่าง calculator จึงถูกรับเข้ามาจากภายนอก แทนที่จะสร้างไว้ข้างใน
โน้ตสั้น
ระบบ object ของ Ruby รองรับ class ขนาดเล็กมากที่ยังทำงานจริงจังได้ ผมอยากให้ผู้เรียน เห็นว่า class ไม่ได้มีเหตุผลเพียงเพราะมันสั้นหรือเล็ก แต่มีเหตุผลเพราะมันมีหน้าที่ มี state ของตัวเอง และมีขอบเขตการร่วมงานที่ชัด
หัวข้อนี้สำคัญเพราะมันเริ่มฝึกนิสัยใหญ่ 2 อย่าง:
- เอา validation ไปไว้ใกล้ state ที่มันคุ้มครอง
- รับ object ร่วมงานเข้ามาจากภายนอก แทนการ hard-code ไว้ข้างใน
จุดที่ Ruby ทำได้ดีในหัวข้อนี้:
- state กับ behavior อยู่ด้วยกันได้ โดยไม่ต้องมีโค้ดประกอบมาก
- private helpers ช่วยสื่อ invariants ได้สะอาด
- composition ทำให้การทดสอบง่ายขึ้น และการออกแบบแยกส่วนมากขึ้น
จุดที่ต้องระวังในหัวข้อนี้:
- modules อาจซ่อนอะไรไว้มากเกินไป ถ้า mix in แบบไม่ระวัง
- class เล็ก ๆ จะมีประโยชน์ก็ต่อเมื่อหน้าที่ของมันชัดจริง
คำถามชวนคิด:
- ทำไม dependency injection ถึงยังมีประโยชน์ แม้ในแบบฝึกหัดเล็ก ๆ
ตัวอย่างแบบลงมือดู
Example 1: Wallet หรือยอดเงินในบัญชี
object ที่ถือยอดเงินเป็นตัวอย่างสอนที่ดี เพราะมันมี state จริง และมี invariants ที่ชัด
คำถามที่ผู้เรียนควรถาม:
- ใครเป็นเจ้าของยอดเงินนี้
- ค่าแบบไหนถือว่าไม่ถูกต้อง
- validation ควรอยู่ตรงไหน
Example 2: Checkout ที่รับ calculator เข้ามา
logic ของ checkout มักประกอบจาก pricing rules, discount engines, tax services หรือ promotional logic อยู่แล้ว จึงเป็นตัวอย่าง composition ที่เป็นธรรมชาติ
checkout = Checkout.new(calculator: calculator)
checkout.final_total(cart)ruby
เหตุผลที่ตัวอย่างนี้มีประโยชน์:
- object ของ checkout ไม่จำเป็นต้องรู้ว่าการคำนวณยอดทำอย่างไร
- tests สามารถแทน calculator ด้วย double ได้
- การออกแบบยังเปิดทางให้เพิ่ม pricing strategies ใหม่ในอนาคต
โพยสั้น
attr_reader กับ state
attr_reader :balanceruby
validation แบบ private
private
def validate_positive!(amount)
raise ArgumentError unless amount.positive?
endruby
การส่ง dependency ผ่าน constructor
def initialize(calculator:)
@calculator = calculator
endruby
สิ่งที่ควรจำเรื่องการออกแบบ
- ปล่อยให้ object คุ้มครอง invariants ของตัวเอง
- ส่ง object ร่วมงานเข้ามาจากภายนอก เมื่อพฤติกรรมอาจเปลี่ยนได้ หรือเวลาคุณอยากทดสอบ
แยกส่วนชัด ๆ
คู่มือการเรียน
จุดประสงค์ของหัวข้อนี้
หัวข้อนี้อยากให้คุณเห็นว่า object ใน Ruby จัดการ state ของตัวเองอย่างไร และร่วมงานกับ object อื่นอย่างไร เป้าหมายคือออกแบบ object ขนาดเล็กที่ทดสอบได้ ไม่ใช่เขียนโค้ดแบบ procedural ไว้ข้างใน class
ลำดับที่ผมแนะนำ
- อ่าน
overview.md - อ่าน
shortnote.md - อ่าน
worked_examples.mdก่อนเปิดดูโค้ด - เปิด
cheatsheet.mdไว้ตอนอ่านexample.rb - ทำแบบฝึกหัดเรื่อง wallet
- ทำแบบฝึกหัดเรื่อง checkout ที่รับ object ร่วมงานเข้ามาจากภายนอก
สิ่งที่ผมอยากให้คุณสังเกต
- การเปลี่ยน state ควรยังถูกควบคุมโดย object ที่เป็นเจ้าของมัน
- validation methods แบบ private ช่วยบอก invariants ได้ชัด
- composition ช่วยลด coupling และทำให้ทดสอบได้ง่ายขึ้น
คำถามที่อยากให้คุณพกไว้
- ทำไม
Walletควรเป็นคนตรวจค่าจำนวนเงินของตัวเอง - ทำไม
Checkoutถึงสะอาดกว่าถ้ามันรับcalculator:เข้ามา แทนที่จะสร้างเองข้างใน - เมื่อไร module เป็นเครื่องมือใช้ซ้ำที่เหมาะ และเมื่อไรมันเริ่มซ่อนข้อตกลงไว้มากเกินไป
Source Files and Tests
โค้ด Ruby ใช้ร่วมจากโฟลเดอร์ en/ ของต้นฉบับ เพื่อให้สองภาษาผูกกับชุดทดสอบเดียวกัน
# EXAMPLE CODE
# Topic: topic_06_classes_modules_composition
#
# Purpose:
# - This file demonstrates reference implementation for the concept.
# - It should pass tests from the beginning.
# - Read and understand it before solving exercises.
class BankAccount
attr_reader :balance
def initialize(balance: 0)
@balance = balance
end
def deposit(amount)
validate_amount!(amount)
@balance += amount
end
private
def validate_amount!(amount)
raise ArgumentError, "amount must be positive" unless amount.positive?
end
end
Ruby course source
# STUDENT TASK (BASIC)
# Topic: topic_06_classes_modules_composition
#
# What to do:
# - Implement or improve the class/methods in this file.
# - Read tests in tests/topic_06_classes_modules_composition_spec.rb under the "basic exercise" examples.
# - Make tests pass without breaking the example/advanced sections.
#
# Expected outcome:
# - You can run this topic tests and see all examples green after implementation.
class Wallet
attr_reader :balance
def initialize(balance: 0)
@balance = balance
end
def top_up(amount)
validate_positive!(amount)
@balance += amount
end
def spend(amount)
validate_positive!(amount)
raise ArgumentError, "insufficient balance" if amount > @balance
@balance -= amount
end
private
def validate_positive!(amount)
raise ArgumentError, "amount must be positive" unless amount.positive?
end
end
Ruby course source
# STUDENT TASK (ADVANCED)
# Topic: topic_06_classes_modules_composition
#
# Academic purpose:
# - Learn composition as a design choice, not just constructor syntax.
# - See how injected collaborators create simpler, more testable classes.
#
# Real-world use case:
# - Checkout systems rarely calculate totals by themselves.
# - Real applications may delegate to tax calculators, discount engines, or pricing services.
# - Injecting a calculator keeps checkout focused on orchestration rather than pricing policy.
#
# Why Ruby is beautiful here:
# - Keyword-based dependency injection is compact and explicit.
# - The collaborator contract can remain small: in this case, just `total(cart)`.
# - Tests can substitute a double easily, making the design discussion visible.
#
# What to do:
# - Complete the challenge behavior requested by the guide.
# - Focus on why composition improves the design, not only how dependency injection works.
# - Use tests in tests/topic_06_classes_modules_composition_spec.rb under the "advanced exercise" examples.
#
# Expected outcome:
# - Advanced tests pass and you can explain the value of injected collaborators.
class Checkout
def initialize(calculator:)
@calculator = calculator
end
def final_total(cart)
@calculator.total(cart)
end
end
Ruby course source
# ANSWER KEY (BASIC)
# Topic: topic_06_classes_modules_composition
#
# Solution idea:
# - The wallet owns the balance state.
# - Both top-up and spend operations reuse the same positivity validation.
# - Spending also checks that the wallet has enough balance before mutating state.
class Wallet
attr_reader :balance
def initialize(balance: 0)
@balance = balance
end
def top_up(amount)
validate_positive!(amount)
@balance += amount
end
def spend(amount)
validate_positive!(amount)
raise ArgumentError, "insufficient balance" if amount > @balance
@balance -= amount
end
private
def validate_positive!(amount)
raise ArgumentError, "amount must be positive" unless amount.positive?
end
end
Ruby course source
# ANSWER KEY (ADVANCED)
# Topic: topic_06_classes_modules_composition
#
# Solution idea:
# - `Checkout` should not calculate prices by itself.
# - Inject the calculator so checkout stays responsible only for orchestration.
# - The method simply delegates to the collaborator's contract.
class Checkout
def initialize(calculator:)
@calculator = calculator
end
def final_total(cart)
@calculator.total(cart)
end
end
Ruby course source
# This spec is your learning companion for topic_06_classes_modules_composition.
#
# How to use this file:
# 1) Run tests and observe failures/successes.
# 2) Keep EXAMPLE specs green from the beginning.
# 3) Implement BASIC exercise until BASIC specs pass.
# 4) Implement ADVANCED exercise and pass edge cases.
#
# Expected final result:
# - All examples in this file pass.
# - You understand both the concept and the implementation tradeoffs.
require_relative "../example"
require_relative "../basic_exercise"
require_relative "../adv_exercise"
RSpec.describe "topic_06_classes_modules_composition" do
describe "EXAMPLE purpose: understand the reference implementation" do
it "manages bank account deposits" do
a = BankAccount.new(balance: 10)
expect(a.deposit(5)).to eq(15)
expect { a.deposit(0) }.to raise_error(ArgumentError)
end
end
describe "BASIC EXERCISE purpose: implement the comparable task" do
it "supports wallet top up and spend validations" do
w = Wallet.new(balance: 20)
end
describe "ADVANCED EXERCISE purpose: solve challenge and edge cases" do
it "uses composition with injected calculator" do
calculator = instance_double("PriceCalculator", total: 120)
checkout = Checkout.new(calculator: calculator)
expect(checkout.final_total([:item])).to eq(120)
end
end
end
end
Ruby course source
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
exec "${ROOT_DIR}/run_tests.sh" "$(basename "${SCRIPT_DIR}")"
Ruby course source
Study Prompts
อ่าน test ก่อน แล้วบอกให้ได้ว่าพฤติกรรมใดเป็น example, basic exercise และ advanced exercise
ลองทำแบบฝึกหัดก่อนเปิด answer files แล้วจดว่าคำตอบต่างจากวิธีคิดแรกของคุณตรงไหน