13

Recursion และ Closures ผ่านปัญหา traversal ขนาดเล็ก

โค้ด Ruby ใช้ร่วมจากโฟลเดอร์ en/ ของต้นฉบับ เพื่อให้สองภาษาผูกกับชุดทดสอบเดียวกัน

th/topic_13_recursion_closures

ภาพรวม

ทำไมหัวข้อนี้จึงสำคัญ

ทั้ง recursion และ closures เป็นไอเดียที่ทรงพลัง แต่ผู้เรียนมักเจอมันในรูปของกลเม็ดทาง ไวยากรณ์ที่แยกจากกัน หัวข้อนี้จึงสอนมันผ่านปัญหาเล็ก ๆ ต่อเนื่องกัน: เดินผ่านข้อมูลซ้อนก่อน จากนั้นสร้าง closure ที่เก็บพฤติกรรมการค้นหาไว้ แล้วค่อยนำทั้งสองอย่างมารวมกันใน traversal service ตัวเดียว

สิ่งที่ผมอยากให้คุณทำได้เมื่อจบหัวข้อนี้

เมื่อจบหัวข้อนี้ คุณควรจะ:

  • อธิบายความต่างระหว่าง recursive step กับ base case ได้
  • ใช้ recursion เดินผ่าน nested arrays หรือ tree-like hashes ได้
  • อธิบายได้ว่า closure เก็บอะไรจาก scope รอบตัวมัน
  • สร้าง function factory ขนาดเล็กที่คืน lambda พร้อม state ที่มันจำไว้ได้
  • รวม recursive traversal กับ closure ที่ส่งเข้ามาจากภายนอก เพื่อให้ logic ยืดหยุ่นได้

จุดที่ผมใช้ดูความเข้าใจ

ผมอยากให้คุณอธิบายได้ว่าทำไม traversal แบบ recursive กับกฎการ match จึงถูกแยกออก จากกัน แทนที่จะฝังรวมไว้ใน method ยาวอันเดียว

โน้ตสั้น

Recursion มีประโยชน์เมื่อรูปร่างของข้อมูลซ้ำกันเอง:

  • nested array ข้างในยังมี arrays
  • node ของ tree ยังมี child nodes
  • แต่ละขั้นดูเหมือนปัญหาทั้งก้อนในขนาดที่เล็กลง

Closures มีประโยชน์เมื่อพฤติกรรมควรจำค่าบางอย่างจากจุดที่มันถูกสร้าง:

  • predicate ที่จำคำค้นไว้
  • ตัวนับที่จำยอดสะสมของตัวเอง
  • formatter ที่จำ prefix หรือกฎบางอย่างไว้

จุดที่ Ruby ทำได้ดีในหัวข้อนี้:

  • recursion ยังกระชับได้ ถ้า base case ชัด
  • lambdas และ blocks ทำให้พฤติกรรมที่จับ state ไว้ เขียนได้ง่าย
  • พอเอามารวมกัน ก็เกิด traversal code ที่งามได้มาก

จุดที่ต้องระวังในหัวข้อนี้:

  • recursion จะงงทันที ถ้า base case ไม่เด่นพอ
  • closures อาจซ่อน state สำคัญไว้ ถ้าสร้างขึ้นมาแบบไม่ตั้งใจ
  • ไม่ใช่ทุกปัญหาที่ซ้อนกันควรใช้ recursion ถ้า iteration ชัดกว่า

คำถามชวนคิด:

  • ในคำตอบขั้นสูง ส่วนไหนเป็นหน้าที่ของ traversal และส่วนไหนเป็นหน้าที่ของ closure ที่จำค่าไว้

ตัวอย่างแบบลงมือดู

Example 1: โครงสร้างตัวเลขที่ซ้อนกัน

การรวมผลรวมของตัวเลขที่ซ้อนกันเป็นแบบฝึก recursion แรกที่ดี เพราะรูปแบบที่ซ้ำกันมอง เห็นได้ชัด แต่ละ child เป็นได้สองอย่าง:

  • เป็นค่าที่ใช้งานได้ทันที หรือ
  • เป็นโครงสร้างแบบเดียวกันอีกชั้นหนึ่ง

ตรงนี้ช่วยให้ผู้เรียนเห็นง่ายว่า recursion กำลังทำงานอะไรอยู่

Example 2: Search predicates ในรูป closure

closure ดูสมจริงมากเมื่อผู้ใช้ส่งคำค้นเข้ามา แล้วโค้ดต้องการ matcher เล็ก ๆ ที่เรียกใช้ซ้ำได้:

matcher = TitleMatchers.containing("ruby")
matcher.call("Ruby Blocks")
# => true
worked_examples.md
ruby

แบบนี้ดีกว่าการ hard-code คำค้นไว้ใน traversal

Example 3: query แบบ recursive บน content tree

เมนูเอกสาร, comment threads และ category trees ล้วนใช้ pattern เดียวกัน:

  • แต่ละ node มี title
  • แต่ละ node อาจมี children
  • traversal ต้องตัดสินว่าจะเก็บอะไรกลับมา

เพราะอย่างนี้ recursion คู่กับ closure จึงเป็นคู่สอนที่ดีมาก

โพยสั้น

รูปร่างพื้นฐานของ recursion

def sum(node)
  return node if node.is_a?(Integer)

  node.sum { |child| sum(child) }
end
cheatsheet.md
ruby

เดิน tree แบบ recursive

def collect(node, paths = [], prefix = [])
  current = prefix + [node[:title]]
  paths << current.join(" > ") if node[:children].empty?
  node[:children].each { |child| collect(child, paths, current) }
  paths
end
cheatsheet.md
ruby

factory ที่สร้าง closure

def self.containing(term)
  ->(title) { title.downcase.include?(term.downcase) }
end
cheatsheet.md
ruby

วิธีคิดแบบ sprint

  • Sprint 1: base case กับ recursive case
  • Sprint 2: เดิน tree แบบ recursive
  • Sprint 3: สร้าง closure สำหรับ predicate ที่ใช้ซ้ำ
  • Sprint 4: เอา closure มารวมกับ recursive traversal

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

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

หัวข้อนี้อยากให้คุณเรียน recursion และ closures ผ่าน sprint สั้น ๆ ก่อนอื่นต้องจับทรงของ recursion จากตัวอย่างเล็กให้ได้ จากนั้นค่อยสร้าง closure ที่จำพฤติกรรมการค้นหาไว้ แล้ว ค่อยนำสองอย่างมารวมกันใน object เดียว

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

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

แผนแบบ sprint

Sprint 1: recursion ขั้นพื้นฐาน

  • มองให้ออกว่า base case คืออะไร
  • มองให้ออกว่า recursive case คืออะไร
  • เชื่อให้ได้ว่า call ที่เล็กลงจะจัดการปัญหาย่อยของมันเอง

Sprint 2: เดิน tree แบบ recursive

  • เดินผ่าน tree ที่ซ้อนกัน
  • สะสมผลลัพธ์ไว้นอก recursive call
  • ทำให้ path ปัจจุบันมองเห็นอยู่เสมอ

Sprint 3: Closure factory

  • สร้าง matcher ที่จำค่าจากจุดที่มันถูกสร้าง
  • เรียก matcher ตัวนั้นในภายหลังจากอีก object หนึ่ง

Sprint 4: รวม recursion กับ closure

  • ทำให้ traversal ยังเป็นของทั่วไป
  • inject กฎการ match เข้ามาในรูป closure
  • คืนผลลัพธ์ที่เก็บได้ แทนการ puts ออกมาตรง ๆ

แนวทางต่อยอด

  • นับจำนวน comments แบบ recursive
  • หาขนาดรวมของ file tree แบบ recursive
  • formatter แบบ closure ที่จำ indentation ไว้
  • tree traversal ที่ทั้งกรองและแปลงข้อมูล

คำถามชวนคิด

  • ทำไม recursion ถึงปลอดภัยไม่ได้ ถ้ายังไม่มี base case
  • closure ในแบบฝึกหัดขั้นสูงจำค่าอะไรไว้
  • ทำไมการ inject matcher เข้ามา ถึงสะอาดกว่าการ hard-code กฎการค้นหาไว้ใน traversal

Source Files and Tests

โค้ด Ruby ใช้ร่วมจากโฟลเดอร์ en/ ของต้นฉบับ เพื่อให้สองภาษาผูกกับชุดทดสอบเดียวกัน

# EXAMPLE CODE
# Topic: topic_13_recursion_closures
#
# Purpose:
# - This file demonstrates recursion over a nested array/integer structure.
# - It should pass tests from the beginning.
# - Read it before solving the tree traversal and closure exercises.

class NestedNumberSummer
  def sum(node)
    return node if node.is_a?(Integer)

    node.sum { |child| sum(child) }
  end
end
en/topic_13_recursion_closures/example.rb
Ruby course source
# STUDENT TASK (BASIC)
# Topic: topic_13_recursion_closures
#
# What to do:
# - Traverse a nested category tree recursively.
# - Return the full leaf paths instead of printing directly.
# - Keep the current path explicit so the recursion is easy to follow.
#
# Expected outcome:
# - You can solve a small tree problem with a clear base case and recursive case.

class CategoryPaths
  def paths(tree, prefix = [])
    current_path = prefix + [tree[:title]]
    return [current_path.join(" > ")] if tree[:children].empty?

    tree[:children].flat_map { |child| paths(child, current_path) }
  end
end
en/topic_13_recursion_closures/basic_exercise.rb
Ruby course source
# STUDENT TASK (ADVANCED)
# Topic: topic_13_recursion_closures
#
# Academic purpose:
# - Combine recursive traversal with a closure that captures query behavior.
# - Show that recursion can stay generic while the matching rule remains flexible.
#
# Real-world use case:
# - Content trees, nested menus, and documentation sidebars often need searches such as
#   "find all titles containing this term."
# - A matcher closure lets the search term live outside the traversal logic.
#
# Why Ruby is beautiful here:
# - Lambdas make small captured behaviors easy to create.
# - The traversal stays focused on the tree shape.
# - The matching rule can change without rewriting the recursion.
#
# What to do:
# - Build one closure factory that remembers a search term.
# - Build one recursive query object that uses the closure to decide what to collect.
# - Return matching node titles as ordinary arrays.
#
# Expected outcome:
# - Advanced tests pass and you can explain what the closure captures.

module TitleMatchers
  def self.containing(term)
    ->(title) { title.downcase.include?(term.downcase) }
  end
end

class TreeQuery
  def initialize(matcher:)
    @matcher = matcher
  end

  def matching_titles(tree)
    results = []
    visit(tree, results)
    results
  end

  private

  def visit(node, results)
    results << node[:title] if @matcher.call(node[:title])
    node[:children].each { |child| visit(child, results) }
  end
end
en/topic_13_recursion_closures/adv_exercise.rb
Ruby course source
# ANSWER KEY (BASIC)
# Topic: topic_13_recursion_closures
#
# Solution idea:
# - Build the current path before descending.
# - If the node has no children, the recursion stops and returns one completed path.
# - Otherwise, recurse into each child and flatten the collected paths.

class CategoryPaths
  def paths(tree, prefix = [])
    current_path = prefix + [tree[:title]]
    return [current_path.join(" > ")] if tree[:children].empty?

    tree[:children].flat_map { |child| paths(child, current_path) }
  end
end
en/topic_13_recursion_closures/answer_basic_exercise.rb
Ruby course source
# ANSWER KEY (ADVANCED)
# Topic: topic_13_recursion_closures
#
# Solution idea:
# - The closure remembers the `term` value from where it was created.
# - `TreeQuery` only knows how to walk the tree.
# - The matcher decides which titles are worth collecting.

module TitleMatchers
  def self.containing(term)
    ->(title) { title.downcase.include?(term.downcase) }
  end
end

class TreeQuery
  def initialize(matcher:)
    @matcher = matcher
  end

  def matching_titles(tree)
    results = []
    visit(tree, results)
    results
  end

  private

  def visit(node, results)
    results << node[:title] if @matcher.call(node[:title])
    node[:children].each { |child| visit(child, results) }
  end
end
en/topic_13_recursion_closures/answer_adv_exercise.rb
Ruby course source
# This spec is your learning companion for topic_13_recursion_closures.
#
# 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: recursive path collection.
# 4) Implement the ADVANCED exercise as sprint 3 and 4: closure factory plus recursive query.
#
# Expected final result:
# - All examples in this file pass.
# - You understand both recursion and closure capture in a practical setting.

require_relative "../example"
require_relative "../basic_exercise"
require_relative "../adv_exercise"

RSpec.describe "topic_13_recursion_closures" do
  describe "EXAMPLE purpose: understand recursion through a repeated data shape" do
    it "sums nested integers recursively" do
      summer = NestedNumberSummer.new

      expect(summer.sum([1, [2, 3], [4, [5]]])).to eq(15)
      expect(summer.sum([7, [1, [2]]])).to eq(10)
    end
  end

  describe "BASIC EXERCISE purpose: traverse a tree recursively as a small sprint" do
    it "returns leaf paths from a nested category tree" do
      tree = {
        title: "Docs",
        children: [
          {
            title: "Ruby",
            children: [
              { title: "Blocks", children: [] },
              { title: "Enumerable", children: [] }
            ]
          },
          {
            title: "SQL",
            children: [
              { title: "SQLite", children: [] }
            ]
          }
        ]
      }

      paths = CategoryPaths.new.paths(tree)

      expect(paths).to eq(
        [
          "Docs > Ruby > Blocks",
          "Docs > Ruby > Enumerable",
          "Docs > SQL > SQLite"
        ]
      )
    end
  end

  describe "ADVANCED EXERCISE purpose: combine closure capture with recursive traversal" do
    it "uses a matcher closure to collect matching titles" do
      tree = {
        title: "Docs",
        children: [
          {
            title: "Ruby Basics",
            children: [
              { title: "Ruby Blocks", children: [] },
              { title: "Enumerable", children: [] }
            ]
          },
          {
            title: "SQL",
            children: [
              { title: "SQLite with Ruby", children: [] }
            ]
          }
        ]
      }

      matcher = TitleMatchers.containing("ruby")
      query = TreeQuery.new(matcher: matcher)

      expect(query.matching_titles(tree)).to eq(
        ["Ruby Basics", "Ruby Blocks", "SQLite with Ruby"]
      )
    end
  end
end
en/topic_13_recursion_closures/tests/topic_13_recursion_closures_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_13_recursion_closures/run_topic_tests.sh
Ruby course source

Study Prompts

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

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