การคิดเรื่อง Collections และ Enumerable
โค้ด Ruby ใช้ร่วมจากโฟลเดอร์ en/ ของต้นฉบับ เพื่อให้สองภาษาผูกกับชุดทดสอบเดียวกัน
th/topic_03_collections_enumerable
ภาพรวม
ทำไมหัวข้อนี้จึงสำคัญ
Enumerable คือหนึ่งในความสามารถของ Ruby ที่สำคัญที่สุดสำหรับการเขียนโปรแกรมใน ชีวิตประจำวัน มันชวนให้เราอธิบายการแปลงข้อมูลและการคัดเลือกข้อมูลตรง ๆ แทนที่จะ ต้องคอยจัดการ index หรือตัวสะสมค่าที่ต้องคอยเปลี่ยนค่าด้วยมือ
สิ่งที่ผมอยากให้คุณทำได้เมื่อจบหัวข้อนี้
เมื่อจบหัวข้อนี้ คุณควรจะ:
- เลือกใช้
map,select,sum,max_byและeach_with_objectได้เหมาะ - อธิบายความต่างระหว่างการแปลงข้อมูล การกรอง และการลดรูปข้อมูลได้
- เขียนโค้ดที่ทำงานกับ collection แบบบอกเจตนาได้ชัด
- มองออกว่าเมื่อไรการเขียน loop เองสื่อความหมายได้น้อยกว่า
Enumerable - อธิบายได้ว่าทำไมโค้ด Ruby ที่ทำงานกับข้อมูลจึงมักแน่นขึ้น แต่กลับชัดขึ้นกว่าลูปแบบ Java
จุดที่ผมใช้ดูความเข้าใจ
ผมอยากให้คุณอธิบายได้ว่าทำไมจึงเลือก method ในนั้น ไม่ใช่แค่ทำให้ชุดทดสอบผ่าน
โน้ตสั้น
หลายคนเริ่มรู้สึกว่า Ruby งามเป็นพิเศษตอนที่มันต้องทำงานกับ collections เพราะ Enumerable เปิดทางให้เราเรียกชื่อชนิดของงานได้ตรง ๆ เช่น:
- แปลงข้อมูล
- กรองข้อมูล
- ลดรูปข้อมูล
- จัดกลุ่ม
- ค้นหา
ในเชิงการสอน เรื่องนี้สำคัญมาก เพราะมันย้ายความสนใจจากคำถามว่า "จะวนลูปยังไง" ไป สู่คำถามว่า "ผมกำลังบอกการทำงานแบบไหนอยู่" นี่เป็นการเปลี่ยนวิธีคิดที่ชัดมากสำหรับคน ที่มาจาก Java
จุดที่ Ruby ทำได้ดีในหัวข้อนี้:
- method ที่ใช้กับ collection มักเกาะกับเจตนาได้ตรง
- โค้ดมักสั้นลงและอ่านง่ายขึ้นพร้อมกัน
- การ chain งานเล็ก ๆ หลายชิ้นเข้าด้วยกัน ช่วยให้ transformation ที่ซับซ้อนยังชัดได้
จุดที่ต้องระวังในหัวข้อนี้:
- chain ที่ยาวเกินไปจะเริ่มอ่านยาก ถ้าชื่อและไอเดียระหว่างทางไม่ชัด
- ไม่ใช่ทุกปัญหาควรถูกย่อจนเหลือ one-liner แบบฉลาดเกินเหตุ
คำถามชวนคิด:
- เวลา review pull request อะไรอ่านง่ายกว่ากัน ระหว่าง loop ที่คอยเปลี่ยนค่าไปเรื่อย ๆ
กับลำดับการทำงานของ Enumerable ที่ตั้งชื่อแต่ละขั้นไว้ชัดเจน
ตัวอย่างแบบลงมือดู
Example 1: รายงานยอดรวม
งานรายงานยอดใน orders หรือ invoices เป็นตัวอย่าง Ruby ที่ดีมาก เพราะเป็นงานที่พบ บ่อยและมีข้อมูลเยอะ คุณจึงเห็นได้ทันทีว่าทำไม method ที่ทำงานกับ collection ถึงรู้สึก เป็นธรรมชาติ
orders.sum { |order| order[:amount] }ruby
โค้ดนี้อ่านใกล้เคียงกับกฎทางธุรกิจมากกว่าการเขียนลูป
Example 2: รวมยอดตามสกุลเงิน
โค้ดสายการเงินหรือการค้าจะต้องรวมค่าตาม key ที่ใช้ร่วมกันอยู่บ่อย ๆ
invoices.each_with_object({}) do |invoice, totals|
key = invoice[:currency]
totals[key] ||= 0
totals[key] += invoice[:amount]
endruby
เหตุผลที่ตัวอย่างนี้มีประโยชน์:
- accumulator ถูกประกาศออกมาอย่างชัดเจน
- โค้ดไม่ต้องคอยจัดการ index
- โครงสร้างผลลัพธ์ตรงกับโจทย์รายงานโดยตรง
โพยสั้น
เครื่องมือใน Enumerable ที่ใช้บ่อย
orders.sum { |o| o[:amount] }
orders.select { |o| o[:amount] >= 100 }
orders.map { |o| o[:customer] }
items.max_by { |i| i[:score] }ruby
ค่อย ๆ สร้าง hash ขึ้นมา
invoices.each_with_object({}) do |invoice, totals|
currency = invoice[:currency]
totals[currency] ||= 0
totals[currency] += invoice[:amount]
endruby
แนวทางเลือกใช้
map: สร้าง collection ใหม่จากค่าที่แปลงแล้วselect: เก็บเฉพาะข้อมูลที่ตรงเงื่อนไขsum: รวมค่าตัวเลขmax_by: เลือกข้อมูลที่มีค่าที่คำนวณได้สูงสุดeach_with_object: ค่อย ๆ สร้างโครงสร้างผลลัพธ์อย่างชัดเจน
คู่มือการเรียน
จุดประสงค์ของหัวข้อนี้
หัวข้อนี้อยากให้คุณฝึกเขียนงานกับ collection แบบบอกเจตนาได้ตรง ๆ เพราะนี่คือจุดที่ หลายคนเริ่มรู้สึกถึงพลังของ Ruby จริง ๆ ไม่ใช่แค่สังเกตว่ามันมีไวยากรณ์ไม่เหมือนภาษาอื่น
ลำดับที่ผมแนะนำ
- อ่าน
overview.md - อ่าน
shortnote.mdก่อนเปิดดูโค้ด - อ่าน
worked_examples.mdแล้วดูว่าแต่ละ method กำลังตอบคำถามทางธุรกิจอะไร - เปิด
cheatsheet.mdไว้ตอนอ่านexample.rb - ทำแบบฝึกหัดพื้นฐาน
- ทำแบบฝึกหัดขั้นสูง แล้วอธิบายว่าทำไม
each_with_objectถึงเหมาะกับโจทย์นี้
สิ่งที่ผมอยากให้คุณสังเกต
- method ใน Enumerable มักบอกเจตนาได้ดีกว่าการเขียน loop เอง
- ชื่อของ method เป็นส่วนหนึ่งของภาษาที่เราใช้ในการออกแบบ
- ถ้ารูปร่างของ accumulator ดี งานต่อจากนั้นจะง่ายขึ้นมาก
คำถามที่อยากให้คุณพกไว้
- ทำไม
sumถึงชัดกว่าการประกาศตัวแปรสะสมแล้วค่อยบวกทีละรอบ - ทำไม
each_with_objectถึงเหมาะกับการรวมยอดตามกลุ่มมากกว่าmap - เมื่อไรคุณควรหยุด chain แล้วดึงบางส่วนออกมาเป็น method ที่มีชื่อ
Source Files and Tests
โค้ด Ruby ใช้ร่วมจากโฟลเดอร์ en/ ของต้นฉบับ เพื่อให้สองภาษาผูกกับชุดทดสอบเดียวกัน
# EXAMPLE CODE
# Topic: topic_03_collections_enumerable
#
# Purpose:
# - This file demonstrates reference implementation for the concept.
# - It should pass tests from the beginning.
# - Read and understand it before solving exercises.
class OrderReport
def total_amount(orders)
orders.sum { |o| o[:amount] }
end
def high_value_orders(orders, min: 100)
orders.select { |o| o[:amount] >= min }
end
def customer_names(orders)
orders.map { |o| o[:customer] }.uniq.sort
end
end
Ruby course source
# STUDENT TASK (BASIC)
# Topic: topic_03_collections_enumerable
#
# What to do:
# - Implement or improve the class/methods in this file.
# - Read tests in tests/topic_03_collections_enumerable_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 ScoreAnalyzer
def average_score(items)
return 0.0 if items.empty?
items.sum { |i| i[:score] }.to_f / items.size
end
def passing_names(items, threshold: 60)
items.select { |i| i[:score] >= threshold }.map { |i| i[:name] }
end
def top_scorer(items)
items.max_by { |i| i[:score] }&.dig(:name)
end
end
Ruby course source
# STUDENT TASK (ADVANCED)
# Topic: topic_03_collections_enumerable
#
# Academic purpose:
# - Practice turning a reporting requirement into a declarative collection transformation.
# - Understand why Ruby developers reach for Enumerable when shaping business data.
#
# Real-world use case:
# - Multi-currency invoice reporting is common in billing, analytics, and finance tools.
# - Teams often need "totals by group" answers for dashboards or exports.
# - This is a realistic place where Ruby's collection methods feel powerful rather than decorative.
#
# Why Ruby is beautiful here:
# - `each_with_object` makes the accumulator explicit without boilerplate loop setup.
# - The code mirrors the business question: "group totals by currency".
# - The method stays close to the domain language.
#
# What to do:
# - Complete the challenge behavior requested by the guide.
# - Focus on why the chosen Enumerable style matches the reporting requirement.
# - Use tests in tests/topic_03_collections_enumerable_spec.rb under the "advanced exercise" examples.
#
# Expected outcome:
# - Advanced tests pass and you can defend your use of Enumerable.
class InvoiceSummarizer
def group_total_by_currency(invoices)
invoices.each_with_object({}) do |invoice, totals|
key = invoice[:currency]
totals[key] ||= 0
totals[key] += invoice[:amount]
end
end
end
Ruby course source
# ANSWER KEY (BASIC)
# Topic: topic_03_collections_enumerable
#
# Solution idea:
# - Choose the Enumerable method that matches the job:
# average -> `sum`
# pass list -> `select` + `map`
# top result -> `max_by`
# - The code reads better when each method expresses one kind of collection work.
class ScoreAnalyzer
def average_score(items)
return 0.0 if items.empty?
items.sum { |item| item[:score] }.to_f / items.size
end
def passing_names(items, threshold: 60)
items.select { |item| item[:score] >= threshold }.map { |item| item[:name] }
end
def top_scorer(items)
items.max_by { |item| item[:score] }&.dig(:name)
end
end
Ruby course source
# ANSWER KEY (ADVANCED)
# Topic: topic_03_collections_enumerable
#
# Solution idea:
# - Build a hash accumulator explicitly with `each_with_object`.
# - Each invoice contributes its amount into the bucket for its currency.
# - This mirrors how grouped totals are computed in reporting code.
class InvoiceSummarizer
def group_total_by_currency(invoices)
invoices.each_with_object({}) do |invoice, totals|
currency = invoice[:currency]
totals[currency] ||= 0
totals[currency] += invoice[:amount]
end
end
end
Ruby course source
# This spec is your learning companion for topic_03_collections_enumerable.
#
# 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_03_collections_enumerable" do
describe "EXAMPLE purpose: understand the reference implementation" do
it "builds report totals and names" do
orders = [
{ customer: "Ann", amount: 120 },
{ customer: "Bob", amount: 80 },
{ customer: "Ann", amount: 30 }
]
r = OrderReport.new
expect(r.total_amount(orders)).to eq(230)
expect(r.high_value_orders(orders, min: 80).size).to eq(2)
expect(r.customer_names(orders)).to eq(%w[Ann Bob])
end
end
describe "BASIC EXERCISE purpose: implement the comparable task" do
it "analyzes score data" do
s = ScoreAnalyzer.new
scores = [{ name: "A", score: 80 }, { name: "B", score: 60 }, { name: "C", score: 40 }]
expect(s.average_score(scores)).to eq(60.0)
expect(s.passing_names(scores, threshold: 60)).to eq(%w[A B])
expect(s.top_scorer(scores)).to eq("A")
end
end
describe "ADVANCED EXERCISE purpose: solve challenge and edge cases" do
it "groups invoice totals by currency" do
i = InvoiceSummarizer.new
invoices = [{ currency: "USD", amount: 10 }, { currency: "THB", amount: 700 }, { currency: "USD", amount: 40 }]
expect(i.group_total_by_currency(invoices)).to eq({ "USD" => 50, "THB" => 700 })
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 แล้วจดว่าคำตอบต่างจากวิธีคิดแรกของคุณตรงไหน