15

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]
end
shortnote.md
ruby

ส่วน Ruby แบบ accumulator อาจหน้าตาอย่างนี้:

rows.reduce(0) { |sum, row| sum + row[:amount] }
shortnote.md
ruby

ถ้าเป็น hashes หรือโครงสร้างที่จัดกลุ่ม each_with_object มักอ่านดีกว่าอีก:

rows.each_with_object({}) do |row, grouped|
  grouped[row[:currency]] ||= 0
  grouped[row[:currency]] += row[:amount]
end
shortnote.md
ruby

จุดที่ 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]
end
worked_examples.md
ruby

แบบ accumulator:

invoices.reduce(0) { |sum, invoice| sum + invoice[:amount] }
worked_examples.md
ruby

เหตุผลที่เวอร์ชันที่สองช่วยได้:

  • ค่าเริ่มต้นถูกบอกไว้ชัด
  • ชนิดของผลลัพธ์เห็นได้ตั้งแต่ต้น
  • ตัวแปรในลูปไม่หลุดหน้าที่ของมันออกไป

Example 2: จัดกลุ่มชื่อสินค้าตามหมวด

แบบเก่า:

grouped = {}
items.each do |item|
  grouped[item[:category]] ||= []
  grouped[item[:category]] << item[:name]
end
worked_examples.md
ruby

แบบ Ruby:

items.each_with_object({}) do |item, grouped|
  grouped[item[:category]] ||= []
  grouped[item[:category]] << item[:name]
end
worked_examples.md
ruby

hash ตัวนี้ก็ยังถูกเปลี่ยนค่าอยู่ก็จริง แต่การเปลี่ยนแปลงนั้นถูกกักไว้กับ accumulator และ object ตัวนี้ก็ถูกประกาศตรงจุดที่การวนซ้ำเริ่มต้นพอดี

Example 3: summary dashboard

งานจริงจำนวนมากต้องการ summary object ที่มีหลาย fields:

  • ยอดรวม
  • จำนวนรายการ
  • ค่าเฉลี่ย
  • รายการที่ถูก flag ไว้

นี่จึงเป็นแบบฝึกหัดขั้นสูงที่ดี เพราะมันทำให้เห็นว่าการออกแบบ accumulator เป็นความ กังวลจริงในงาน production ไม่ใช่แค่แบบฝึกหัดเขียนโค้ด

โพยสั้น

รวมยอดด้วย reduce

rows.reduce(0) { |sum, row| sum + row[:amount] }
cheatsheet.md
ruby

สร้าง hash ด้วย each_with_object

rows.each_with_object({}) do |row, grouped|
  grouped[row[:category]] ||= []
  grouped[row[:category]] << row[:name]
end
cheatsheet.md
ruby

เลือกเครื่องมือให้เหมาะ

  • map: จำนวน output เท่าเดิมกับ input
  • reduce: ยุบ input หลายตัวให้กลายเป็นผลลัพธ์เดียว
  • each_with_object: สร้าง result object ที่แก้ไขได้แบบชัดเจนและอยู่เฉพาะจุด

แนวทางเทียบการใช้

  • ตัวแปรภายนอกที่คอยเปลี่ยนค่าไปมา: ใช้ได้ แต่ accumulator หลุดออกไปอยู่นอกลูป
  • reduce: เหมาะกับยอดรวมและค่าที่ถูกคำนวณออกมาเพียงค่าเดียว
  • each_with_object: เหมาะกับ hashes, arrays และโครงสร้างแบบจัดกลุ่ม

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

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

หัวข้อนี้อยากให้คุณแก้ปัญหาแบบ "input หลายตัว แต่ต้องการผลลัพธ์เดียว" ให้ชัดเจน ผมอยาก ให้ผู้เรียนเห็นว่า Ruby แบบใช้ accumulator เป็นเทคนิคใช้งานจริงในชีวิตประจำวัน ไม่ใช่ แบบฝึกเชิงวิชาการ

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

  1. อ่าน overview.md
  2. อ่าน shortnote.md
  3. อ่าน worked_examples.md
  4. เปิด cheatsheet.md ไว้ตอนอ่าน example.rb
  5. ทำแบบฝึกหัดพื้นฐาน
  6. ทำแบบฝึกหัดขั้นสูง

แผนแบบ 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
en/topic_15_reduce_accumulators/example.rb
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
en/topic_15_reduce_accumulators/basic_exercise.rb
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
en/topic_15_reduce_accumulators/adv_exercise.rb
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
en/topic_15_reduce_accumulators/answer_basic_exercise.rb
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
en/topic_15_reduce_accumulators/answer_adv_exercise.rb
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
en/topic_15_reduce_accumulators/tests/topic_15_reduce_accumulators_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_15_reduce_accumulators/run_topic_tests.sh
Ruby course source

Study Prompts

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

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