3

การคิดเรื่อง 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] }
worked_examples.md
ruby

โค้ดนี้อ่านใกล้เคียงกับกฎทางธุรกิจมากกว่าการเขียนลูป

Example 2: รวมยอดตามสกุลเงิน

โค้ดสายการเงินหรือการค้าจะต้องรวมค่าตาม key ที่ใช้ร่วมกันอยู่บ่อย ๆ

invoices.each_with_object({}) do |invoice, totals|
  key = invoice[:currency]
  totals[key] ||= 0
  totals[key] += invoice[:amount]
end
worked_examples.md
ruby

เหตุผลที่ตัวอย่างนี้มีประโยชน์:

  • 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] }
cheatsheet.md
ruby

ค่อย ๆ สร้าง hash ขึ้นมา

invoices.each_with_object({}) do |invoice, totals|
  currency = invoice[:currency]
  totals[currency] ||= 0
  totals[currency] += invoice[:amount]
end
cheatsheet.md
ruby

แนวทางเลือกใช้

  • map: สร้าง collection ใหม่จากค่าที่แปลงแล้ว
  • select: เก็บเฉพาะข้อมูลที่ตรงเงื่อนไข
  • sum: รวมค่าตัวเลข
  • max_by: เลือกข้อมูลที่มีค่าที่คำนวณได้สูงสุด
  • each_with_object: ค่อย ๆ สร้างโครงสร้างผลลัพธ์อย่างชัดเจน

คู่มือการเรียน

จุดประสงค์ของหัวข้อนี้

หัวข้อนี้อยากให้คุณฝึกเขียนงานกับ collection แบบบอกเจตนาได้ตรง ๆ เพราะนี่คือจุดที่ หลายคนเริ่มรู้สึกถึงพลังของ Ruby จริง ๆ ไม่ใช่แค่สังเกตว่ามันมีไวยากรณ์ไม่เหมือนภาษาอื่น

ลำดับที่ผมแนะนำ

  1. อ่าน overview.md
  2. อ่าน shortnote.md ก่อนเปิดดูโค้ด
  3. อ่าน worked_examples.md แล้วดูว่าแต่ละ method กำลังตอบคำถามทางธุรกิจอะไร
  4. เปิด cheatsheet.md ไว้ตอนอ่าน example.rb
  5. ทำแบบฝึกหัดพื้นฐาน
  6. ทำแบบฝึกหัดขั้นสูง แล้วอธิบายว่าทำไม 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
en/topic_03_collections_enumerable/example.rb
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
en/topic_03_collections_enumerable/basic_exercise.rb
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
en/topic_03_collections_enumerable/adv_exercise.rb
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
en/topic_03_collections_enumerable/answer_basic_exercise.rb
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
en/topic_03_collections_enumerable/answer_adv_exercise.rb
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
en/topic_03_collections_enumerable/tests/topic_03_collections_enumerable_spec.rb
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}")"
en/topic_03_collections_enumerable/run_topic_tests.sh
Ruby course source

Study Prompts

  1. อ่าน test ก่อน แล้วบอกให้ได้ว่าพฤติกรรมใดเป็น example, basic exercise และ advanced exercise

  2. ลองทำแบบฝึกหัดก่อนเปิด answer files แล้วจดว่าคำตอบต่างจากวิธีคิดแรกของคุณตรงไหน