reduce, each_with_object และการออกแบบ accumulator
โค้ด Ruby ใช้ร่วมจากโฟลเดอร์ en/ ของต้นฉบับ เพื่อให้สองภาษาผูกกับชุดทดสอบเดียวกัน
th/topic_15_reduce_accumulators
ภาพรวม
ทำไมหัวข้อนี้จึงสำคัญ
หัวข้อ 14 สอนเรื่องการแปลงข้อมูลทีละขั้น ส่วนหัวข้อ 15 คือก้าวถัดไป: ปัญหาที่คุณ ต้องสร้างผลลัพธ์เพียงหนึ่งชิ้นจาก input หลายตัว ตรงนี้เองที่ reduce และ each_with_object กลายเป็นเครื่องมือใช้งานจริง ไม่ใช่ไอเดียเชิงนามธรรม
สิ่งที่ผมอยากให้คุณทำได้เมื่อจบหัวข้อนี้
เมื่อจบหัวข้อนี้ คุณควรจะ:
- อธิบายได้ว่าเมื่อไร
reduceเหมาะกว่าmap - ใช้
each_with_objectเพื่อสร้าง hashes และโครงสร้างแบบจัดกลุ่มได้อย่างชัดเจน - เทียบการสะสมค่าที่ต้องคอยเปลี่ยนค่าด้วยมือ กับ Ruby แบบใช้ accumulator ได้
- เลือกรูปร่างของ accumulator ให้ตรงกับโจทย์รายงานหรือการจัดกลุ่มได้
- อธิบายได้ว่าทำไม pattern พวกนี้จึงพบได้บ่อยใน Ruby ที่ใช้จริง
จุดที่ผมใช้ดูความเข้าใจ
ผมอยากให้คุณอธิบาย accumulator ที่เลือกได้ และบอกได้ว่าทำไมมันจึงชัดกว่าลูปแบบที่มี state ภายนอกคอยเปลี่ยนค่าไปมา
โน้ตสั้น
ไม่ใช่ทุกปัญหาที่เกี่ยวกับ collection จะเป็น map บางปัญหาต้องการ:
- ยอดรวมหนึ่งค่า
- hash ที่จัดกลุ่มแล้วหนึ่งก้อน
- summary object เพียงชิ้นเดียว
- ผลลัพธ์รวมที่ค่อย ๆ ถูกสร้างขึ้นทีละขั้น
ตรงนี้เองที่การคิดแบบ accumulator สำคัญขึ้นมา
แบบ imperative มักหน้าตาอย่างนี้:
total = 0
rows.each do |row|
total += row[:amount]
endruby
ส่วน Ruby แบบ accumulator อาจหน้าตาอย่างนี้:
rows.reduce(0) { |sum, row| sum + row[:amount] }ruby
ถ้าเป็น hashes หรือโครงสร้างที่จัดกลุ่ม each_with_object มักอ่านดีกว่าอีก:
rows.each_with_object({}) do |row, grouped|
grouped[row[:currency]] ||= 0
grouped[row[:currency]] += row[:amount]
endruby
จุดที่ Ruby ทำได้ดีในหัวข้อนี้:
- accumulator มองเห็นได้ตั้งแต่ใน method call
- โค้ดตอบคำถามว่า "เรากำลังสร้างอะไร" ได้ตรง ๆ
- ผลลัพธ์แบบจัดกลุ่มอ่านง่ายขึ้นมาก
จุดที่ต้องระวังในหัวข้อนี้:
reduceจะกลายเป็นภาษาลับ ถ้ารูปร่างของ accumulator ไม่ชัด- ไม่ใช่ทุก accumulator ควรถูกซ่อนใน one-liner ที่ดูฉลาด
- helper ที่ตั้งชื่อดี ๆ ก็ยังอาจเป็นคำตอบที่ชัดที่สุด
คำถามชวนคิด:
- ทำไม
each_with_objectจึงมักอ่านง่ายกว่าreduceเวลาสร้าง hash
ตัวอย่างแบบลงมือดู
Example 1: ยอดรวมของ invoices
แบบเก่า:
total = 0
invoices.each do |invoice|
total += invoice[:amount]
endruby
แบบ accumulator:
invoices.reduce(0) { |sum, invoice| sum + invoice[:amount] }ruby
เหตุผลที่เวอร์ชันที่สองช่วยได้:
- ค่าเริ่มต้นถูกบอกไว้ชัด
- ชนิดของผลลัพธ์เห็นได้ตั้งแต่ต้น
- ตัวแปรในลูปไม่หลุดหน้าที่ของมันออกไป
Example 2: จัดกลุ่มชื่อสินค้าตามหมวด
แบบเก่า:
grouped = {}
items.each do |item|
grouped[item[:category]] ||= []
grouped[item[:category]] << item[:name]
endruby
แบบ Ruby:
items.each_with_object({}) do |item, grouped|
grouped[item[:category]] ||= []
grouped[item[:category]] << item[:name]
endruby
hash ตัวนี้ก็ยังถูกเปลี่ยนค่าอยู่ก็จริง แต่การเปลี่ยนแปลงนั้นถูกกักไว้กับ accumulator และ object ตัวนี้ก็ถูกประกาศตรงจุดที่การวนซ้ำเริ่มต้นพอดี
Example 3: summary dashboard
งานจริงจำนวนมากต้องการ summary object ที่มีหลาย fields:
- ยอดรวม
- จำนวนรายการ
- ค่าเฉลี่ย
- รายการที่ถูก flag ไว้
นี่จึงเป็นแบบฝึกหัดขั้นสูงที่ดี เพราะมันทำให้เห็นว่าการออกแบบ accumulator เป็นความ กังวลจริงในงาน production ไม่ใช่แค่แบบฝึกหัดเขียนโค้ด
โพยสั้น
รวมยอดด้วย reduce
rows.reduce(0) { |sum, row| sum + row[:amount] }ruby
สร้าง hash ด้วย each_with_object
rows.each_with_object({}) do |row, grouped|
grouped[row[:category]] ||= []
grouped[row[:category]] << row[:name]
endruby
เลือกเครื่องมือให้เหมาะ
map: จำนวน output เท่าเดิมกับ inputreduce: ยุบ input หลายตัวให้กลายเป็นผลลัพธ์เดียวeach_with_object: สร้าง result object ที่แก้ไขได้แบบชัดเจนและอยู่เฉพาะจุด
แนวทางเทียบการใช้
- ตัวแปรภายนอกที่คอยเปลี่ยนค่าไปมา: ใช้ได้ แต่ accumulator หลุดออกไปอยู่นอกลูป
reduce: เหมาะกับยอดรวมและค่าที่ถูกคำนวณออกมาเพียงค่าเดียวeach_with_object: เหมาะกับ hashes, arrays และโครงสร้างแบบจัดกลุ่ม
คู่มือการเรียน
จุดประสงค์ของหัวข้อนี้
หัวข้อนี้อยากให้คุณแก้ปัญหาแบบ "input หลายตัว แต่ต้องการผลลัพธ์เดียว" ให้ชัดเจน ผมอยาก ให้ผู้เรียนเห็นว่า Ruby แบบใช้ accumulator เป็นเทคนิคใช้งานจริงในชีวิตประจำวัน ไม่ใช่ แบบฝึกเชิงวิชาการ
ลำดับที่ผมแนะนำ
- อ่าน
overview.md - อ่าน
shortnote.md - อ่าน
worked_examples.md - เปิด
cheatsheet.mdไว้ตอนอ่านexample.rb - ทำแบบฝึกหัดพื้นฐาน
- ทำแบบฝึกหัดขั้นสูง
แผนแบบ sprint
Sprint 1: ยอดรวมหนึ่งค่า ด้วย reduce
- เริ่มจาก accumulator ตัวตั้งต้นที่ชัดเจน
- ทำให้ชนิดของ accumulator มองเห็นได้ตั้งแต่ต้น
Sprint 2: ข้อมูลแบบจัดกลุ่มด้วย each_with_object
- ใช้ hash accumulator เมื่อผลลัพธ์ต้องเป็นกลุ่ม
- เก็บ accumulator ให้อยู่เฉพาะจุดภายในการวนซ้ำ
Sprint 3: สรุปผลหลายค่าในรอบเดียว
- ออกแบบ accumulator hash ให้ตรงกับโจทย์รายงาน
- อัปเดตหลาย fields ภายในหนึ่งรอบการเดินข้อมูล
แนวทางต่อยอด
- จัดกลุ่มซ้อนสองชั้นด้วยสอง key
- แปลง grouped hashes กลับเป็น sorted arrays
- ใช้
filter_mapร่วมกับการสะสมค่า - แยก reducer lambdas ออกมาเพื่อใช้ซ้ำ
คำถามชวนคิด
- ทำไม
reduceถึงเหมาะกับยอดรวมมากกว่าmap - ทำไม
each_with_objectมักชัดกว่าสำหรับ grouped hashes - accumulator แบบไหนถึงถือว่าออกแบบมาดีสำหรับโจทย์รายงาน
Source Files and Tests
โค้ด Ruby ใช้ร่วมจากโฟลเดอร์ en/ ของต้นฉบับ เพื่อให้สองภาษาผูกกับชุดทดสอบเดียวกัน
# EXAMPLE CODE
# Topic: topic_15_reduce_accumulators
#
# Purpose:
# - This file demonstrates `reduce` for a simple total.
# - It should pass tests from the beginning.
# - Read it before solving grouped and multi-field accumulator exercises.
class RevenueCalculator
def total_amount(rows)
rows.reduce(0) { |sum, row| sum + row[:amount] }
end
end
Ruby course source
# STUDENT TASK (BASIC)
# Topic: topic_15_reduce_accumulators
#
# What to do:
# - Group item names by category.
# - Use `each_with_object` so the grouped hash is introduced right where it is built.
# - Return the grouped hash instead of printing directly.
#
# Expected outcome:
# - You can solve a grouped-data problem with an explicit accumulator object.
class CatalogGrouper
def names_by_category(items)
items.each_with_object({}) do |item, grouped|
grouped[item[:category]] ||= []
grouped[item[:category]] << item[:name]
end
end
end
Ruby course source
# STUDENT TASK (ADVANCED)
# Topic: topic_15_reduce_accumulators
#
# Academic purpose:
# - Practice designing an accumulator with more than one field.
# - Show that a single pass can build a useful reporting summary clearly.
#
# Real-world use case:
# - Dashboards and import summaries often need several values at once:
# count, total, average, and a list of flagged rows.
# - This is a realistic place where accumulator design matters.
#
# Why Ruby is beautiful here:
# - A summary hash can be updated in one pass with clear field names.
# - The accumulator shape mirrors the business question.
# - The final result is easy to inspect in tests and logs.
#
# What to do:
# - Build a summary object using `each_with_object`.
# - Track count, total amount, average amount, and expensive item names.
# - Keep the expensive-item rule explicit in the code.
#
# Expected outcome:
# - Advanced tests pass and you can explain why the accumulator has its chosen shape.
class SalesSummary
def summarize(items, expensive_threshold: 100)
summary = items.each_with_object({
count: 0,
total_amount: 0,
average_amount: 0.0,
expensive_names: []
}) do |item, acc|
acc[:count] += 1
acc[:total_amount] += item[:amount]
acc[:expensive_names] << item[:name] if item[:amount] >= expensive_threshold
end
summary[:average_amount] =
summary[:count].zero? ? 0.0 : summary[:total_amount].to_f / summary[:count]
summary
end
end
Ruby course source
# ANSWER KEY (BASIC)
# Topic: topic_15_reduce_accumulators
#
# Solution idea:
# - Start with an empty hash accumulator.
# - Initialize each category bucket lazily.
# - Append names into the right bucket as the iteration proceeds.
class CatalogGrouper
def names_by_category(items)
items.each_with_object({}) do |item, grouped|
grouped[item[:category]] ||= []
grouped[item[:category]] << item[:name]
end
end
end
Ruby course source
# ANSWER KEY (ADVANCED)
# Topic: topic_15_reduce_accumulators
#
# Solution idea:
# - Use one summary hash as the accumulator.
# - Update all fields in a single pass.
# - Compute the average after the pass so the intermediate logic stays simple.
class SalesSummary
def summarize(items, expensive_threshold: 100)
summary = items.each_with_object({
count: 0,
total_amount: 0,
average_amount: 0.0,
expensive_names: []
}) do |item, acc|
acc[:count] += 1
acc[:total_amount] += item[:amount]
acc[:expensive_names] << item[:name] if item[:amount] >= expensive_threshold
end
summary[:average_amount] =
summary[:count].zero? ? 0.0 : summary[:total_amount].to_f / summary[:count]
summary
end
end
Ruby course source
# This spec is your learning companion for topic_15_reduce_accumulators.
#
# How to use this file:
# 1) Run tests and observe failures or successes.
# 2) Keep the EXAMPLE specs green from the beginning.
# 3) Implement the BASIC exercise as sprint 2: grouped accumulation.
# 4) Implement the ADVANCED exercise as sprint 3: multi-field summary accumulation.
#
# Expected final result:
# - All examples in this file pass.
# - You understand accumulator-driven Ruby as a practical problem-solving style.
require_relative "../example"
require_relative "../basic_exercise"
require_relative "../adv_exercise"
RSpec.describe "topic_15_reduce_accumulators" do
describe "EXAMPLE purpose: understand reduce as a way to collapse many rows into one total" do
it "sums revenue rows" do
calculator = RevenueCalculator.new
expect(calculator.total_amount([{ amount: 40 }, { amount: 60 }, { amount: 20 }])).to eq(120)
end
end
describe "BASIC EXERCISE purpose: group names with each_with_object instead of manual setup" do
it "returns names grouped by category" do
grouper = CatalogGrouper.new
items = [
{ category: "books", name: "Ruby Book" },
{ category: "books", name: "Refactoring" },
{ category: "home", name: "Desk Lamp" }
]
expect(grouper.names_by_category(items)).to eq(
{
"books" => ["Ruby Book", "Refactoring"],
"home" => ["Desk Lamp"]
}
)
end
end
describe "ADVANCED EXERCISE purpose: build a dashboard-style summary in one pass" do
it "tracks count, total, average, and expensive item names" do
summary = SalesSummary.new
items = [
{ name: "Ruby Book", amount: 80 },
{ name: "Refactoring", amount: 120 },
{ name: "Desk Lamp", amount: 150 }
]
expect(summary.summarize(items, expensive_threshold: 100)).to eq(
{
count: 3,
total_amount: 350,
average_amount: 116.66666666666667,
expensive_names: ["Refactoring", "Desk Lamp"]
}
)
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 แล้วจดว่าคำตอบต่างจากวิธีคิดแรกของคุณตรงไหน