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")
# => trueruby
แบบนี้ดีกว่าการ 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) }
endruby
เดิน 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
endruby
factory ที่สร้าง closure
def self.containing(term)
->(title) { title.downcase.include?(term.downcase) }
endruby
วิธีคิดแบบ 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 เดียว
ลำดับที่ผมแนะนำ
- อ่าน
overview.md - อ่าน
shortnote.md - อ่าน
worked_examples.md - เปิด
cheatsheet.mdไว้ตอนอ่านexample.rb - ทำแบบฝึกหัดพื้นฐาน
- ทำแบบฝึกหัดขั้นสูง
แผนแบบ 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
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
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
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
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
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
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 แล้วจดว่าคำตอบต่างจากวิธีคิดแรกของคุณตรงไหน