Ruby แบบ Functional สำหรับปัญหาข้อมูลในชีวิตประจำวัน
โค้ด Ruby ใช้ร่วมจากโฟลเดอร์ en/ ของต้นฉบับ เพื่อให้สองภาษาผูกกับชุดทดสอบเดียวกัน
th/topic_14_functional_style_lambdas
ภาพรวม
ทำไมหัวข้อนี้จึงสำคัญ
ผู้เรียนมักมอง map, zip และ lambdas เป็นไวยากรณ์ที่ดูฉลาดเกินจำเป็น แต่นั่นเป็นมุม มองที่ทำให้พลาดของจริง เครื่องมือเหล่านี้มีประโยชน์มากในงานเขียนโค้ดที่แปลงข้อมูลอย่าง ชัดเจน ส่งพฤติกรรมในรูปค่าได้ และไม่ต้องตั้งตัวแปรที่คอยเปลี่ยนค่าไปมามากเกินไป
หัวข้อนี้จึงอยากให้เห็นคุณค่าจริงของ functional style ใน Ruby:
- รับข้อมูลเข้ามา
- แปลงมันทีละขั้นเล็ก
- ให้แต่ละขั้นซื่อสัตย์กับหน้าที่ของมัน
- ส่งกฎเป็นค่า เมื่อพฤติกรรมมีโอกาสเปลี่ยน
สิ่งที่ผมอยากให้คุณทำได้เมื่อจบหัวข้อนี้
เมื่อจบหัวข้อนี้ คุณควรจะ:
- อธิบายได้ว่าทำไม
mapมักชัดกว่าการสร้าง array ด้วยeach - ใช้
selectและzipเพื่อจัดรูปข้อมูลก่อนแปลง - ส่ง lambdas เข้าไปใน objects ในฐานะค่าของพฤติกรรม
- ประกอบ unary functions หลายตัวให้เป็นลำดับแปลงข้อมูลที่อ่านออก
- เทียบโค้ดแบบ imperative ที่ใช้ loops กับ Ruby แบบ functional แล้วอธิบาย trade-off ได้
จุดที่ผมใช้ดูความเข้าใจ
ผมอยากให้คุณอธิบายได้ว่าอะไรดีขึ้นเมื่อโค้ดย้ายจากลูปที่เขียนเอง ไปสู่การแปลงข้อมูลบน collection และเมื่อไรไม่ควรฝืนทำให้ทุกอย่างกลายเป็นลำดับขั้น
โน้ตสั้น
Ruby ไม่ได้กำลังบอกให้ผู้เรียนกลายเป็น pure functional programmer แต่มันชวนให้เห็น ว่าปัญหาธรรมดาหลายอย่างอ่านง่ายขึ้น เมื่อเขียนเป็นชุดของการแปลงข้อมูลทีละขั้น แทนที่จะ เป็นชุดของการเปลี่ยนค่าไปเรื่อย ๆ
แบบ imperative มักหน้าตาอย่างนี้:
rows = []
names.each_with_index do |name, index|
score = scores[index]
next if score < 60
rows << "#{name}: #{score}"
endruby
ส่วน Ruby แบบ functional มักหน้าตาอย่างนี้:
names.zip(scores)
.select { |_name, score| score >= 60 }
.map { |name, score| "#{name}: #{score}" }ruby
เวอร์ชันที่สองไม่ได้ดีกว่าเพราะมันสั้นกว่า แต่มันดีกว่าเพราะแต่ละขั้นตอบคำถามคนละข้อ:
- เราจะจับข้อมูลให้ตรงกันอย่างไร
- เราจะเก็บแถวไหนไว้
- เราจะแปลงมันอย่างไร
จุดที่ Ruby ทำได้ดีในหัวข้อนี้:
- โค้ดมักเดินตามรูปร่างของปัญหาข้อมูล
- lambdas ทำให้กฎเล็ก ๆ ถูกส่งต่อไปมาได้ง่าย
- composition ทำให้ลำดับของ functions หลายตัวอ่านเหมือนขั้นตอนการแปลงข้อมูล
จุดที่ต้องระวังในหัวข้อนี้:
- ลำดับขั้นเหล่านี้จะเริ่มทึบ ถ้าแต่ละขั้นไม่สื่อความหมายจริง
- การ abstract lambdas เล็กเกินไป อาจทำให้ debug ยากขึ้น
- โค้ดแบบ imperative ก็ยังเหมาะอยู่ ถ้าปัญหาจริงคือ flow ที่มี state
คำถามชวนคิด:
- เวอร์ชันไหนที่ developer คนอื่น review ใน pull request ได้ง่ายกว่ากัน
ตัวอย่างแบบลงมือดู
Example 1: ลูปที่เขียนเอง เทียบกับการแปลงข้อมูลทีละขั้น
แบบเก่า:
rows = []
quantities.each_with_index do |qty, index|
price = prices[index]
rows << qty * price
endruby
Ruby แบบ functional:
quantities.zip(prices).map { |qty, price| qty * price }ruby
เหตุผลที่เวอร์ชันที่สองดีกว่าในกรณีนี้:
- การจับข้อมูลให้ตรงกันถูกบอกไว้ชัดผ่าน
zip - การแปลงข้อมูลถูกบอกไว้ชัดผ่าน
map - ไม่มี array ชั่วคราวที่คอยเปลี่ยนค่าอยู่นอกลูป
Example 2: กรองก่อนแล้วค่อยแปลง
แบบเก่า:
labels = []
scores.each do |score|
next if score < 60
labels << "PASS: #{score}"
endruby
Ruby แบบ functional:
scores.select { |score| score >= 60 }
.map { |score| "PASS: #{score}" }ruby
เหตุผลที่เรื่องนี้สำคัญ:
- การตัดสินใจว่า "เก็บอะไรไว้" ถูกแยกจากการตัดสินใจว่า "จะแปลงอย่างไร"
- แต่ละขั้นจึงถูกทดสอบหรือเปลี่ยนทีละส่วนได้
Example 3: function composition สำหรับกฎทางธุรกิจ
สมมติว่าคะแนนต้องถูก curve, จำกัดเพดาน แล้วค่อยเปลี่ยนเป็น label เราจะเขียนเป็น method ใหญ่ก็ได้ แต่การต่อ functions เป็นลำดับทำให้แต่ละขั้นยังมองเห็นอยู่:
transform = FunctionTools.compose(to_letter, clamp, curve)ruby
นี่เป็นการใช้ lambdas ในโลกจริงที่ดี เพราะแต่ละขั้นมีชื่อและมีความหมายจริง
โพยสั้น
แปลงข้อมูล
scores.map { |score| score + 5 }ruby
กรองข้อมูล
scores.select { |score| score >= 60 }ruby
จับคู่ collections ที่เรียงตำแหน่งตรงกัน
names.zip(scores)ruby
Lambda
classifier = ->(score) { score >= 60 }ruby
Composition
def self.compose(*functions)
->(input) { functions.reverse.reduce(input) { |value, fn| fn.call(value) } }
endruby
แนวทางเทียบการใช้
each: ทำงานเพื่อ side effectsmap: สร้าง collection ใหม่จากการแปลงข้อมูลselect: เก็บเฉพาะรายการที่ตรงเงื่อนไขzip: จับคู่ collections ที่สัมพันธ์กันด้วยตำแหน่ง
คู่มือการเรียน
จุดประสงค์ของหัวข้อนี้
หัวข้อนี้อยากให้คุณเห็นว่า Ruby แบบ functional ไม่ใช่ท่าโชว์ แต่มันเป็นวิธีเขียนโค้ด แปลงข้อมูลที่ตรวจทาน ทดสอบ และขยายต่อได้ง่ายกว่า
ลำดับที่ผมแนะนำ
- อ่าน
overview.md - อ่าน
shortnote.mdแล้วเทียบตัวอย่างแบบเก่ากับแบบ functional - อ่าน
worked_examples.md - เปิด
cheatsheet.mdไว้ตอนอ่านexample.rb - ทำแบบฝึกหัดพื้นฐาน
- ทำแบบฝึกหัดขั้นสูง
แผนแบบ sprint
Sprint 1: แปลงข้อมูลจาก collection เดียว
- ใช้
mapเมื่อผลลัพธ์คือรูปแบบใหม่ของข้อมูลเดิม - ถ้างานเป็นการแปลงล้วน ๆ อย่ารีบสร้าง array ด้วยมือ
Sprint 2: จับคู่และกรองข้อมูล
- ใช้
zipเมื่อ arrays สองชุดตรงกันตามตำแหน่ง - ใช้
selectก่อนmapเมื่อโจทย์มีขั้นของการกรองอยู่ด้วย
Sprint 3: ส่งพฤติกรรมในรูปค่า
- ให้ lambda เป็นคนถือกฎไว้
- ทำให้ object โฟกัสเรื่องโครงสร้าง แทนที่จะต้องรู้ทุกกฎย่อยที่เป็นไปได้
Sprint 4: ประกอบ functions เข้าด้วยกัน
- สร้างลำดับแปลงข้อมูลจาก unary functions ขนาดเล็ก
- ให้ชื่อแต่ละขั้นของการแปลงข้อมูล แทนที่จะยัดทุกอย่างลงใน method เดียว
สะพานไปสู่หัวข้อ 15
หัวข้อนี้เน้นการแปลง collections ส่วนหัวข้อ 15 จะต่อยอดไปที่ปัญหาแบบใช้ accumulator ด้วย reduce และ each_with_object
คำถามชวนคิด
- state แบบที่คอยเปลี่ยนค่าไปมาอะไรหายไป เมื่อโค้ดย้ายมาใช้
zipกับmap - ทำไม lambda ที่ inject เข้ามา ถึงดีกว่าการ hard-code กฎการจัดประเภท
- เมื่อไร method ที่ตั้งชื่อชัด ๆ ธรรมดา ๆ จะง่ายกว่าการต่อ functions เป็นลำดับ
Source Files and Tests
โค้ด Ruby ใช้ร่วมจากโฟลเดอร์ en/ ของต้นฉบับ เพื่อให้สองภาษาผูกกับชุดทดสอบเดียวกัน
# EXAMPLE CODE
# Topic: topic_14_functional_style_lambdas
#
# Purpose:
# - This file demonstrates a small functional-style transform using `zip` and `map`.
# - It should pass tests from the beginning.
# - Read it before solving the reporting and composition exercises.
class VectorMath
def pairwise_sum(left, right)
left.zip(right).map { |a, b| a + b }
end
end
Ruby course source
# STUDENT TASK (BASIC)
# Topic: topic_14_functional_style_lambdas
#
# What to do:
# - Pair names and scores with `zip`.
# - Use an injected lambda to decide whether each score passes.
# - Return structured rows instead of printing directly.
#
# Expected outcome:
# - You can solve a small reporting task with `zip`, `map`, and a lambda.
class StudentRoster
def initialize(classifier:)
@classifier = classifier
end
def rows(names, scores)
names.zip(scores).map do |name, score|
{
name: name,
score: score,
passed: @classifier.call(score)
}
end
end
end
Ruby course source
# STUDENT TASK (ADVANCED)
# Topic: topic_14_functional_style_lambdas
#
# Academic purpose:
# - Practice function composition as a practical Ruby technique.
# - Show how a sequence of lambdas can model a transformation pipeline.
#
# Real-world use case:
# - Reporting and normalization code often applies several steps in order:
# curve a score, clamp it, then convert it into a label.
# - Composition lets each step stay small while the pipeline remains reusable.
#
# Why Ruby is beautiful here:
# - Lambdas are lightweight enough to use as ordinary values.
# - Composition keeps each transformation step honest and testable.
# - The final pipeline reads like a sequence of meaning, not a long method body.
#
# What to do:
# - Build one composition helper.
# - Apply the composed transformation to a zipped list of names and scores.
# - Return user-facing labels as strings.
#
# Expected outcome:
# - Advanced tests pass and you can explain how the pipeline is assembled.
module FunctionTools
def self.compose(*functions)
->(input) { functions.reverse.reduce(input) { |value, fn| fn.call(value) } }
end
end
class GradePipeline
def initialize(transform:)
@transform = transform
end
def labels(names, scores)
names.zip(scores).map do |name, score|
"#{name}: #{@transform.call(score)}"
end
end
end
Ruby course source
# ANSWER KEY (BASIC)
# Topic: topic_14_functional_style_lambdas
#
# Solution idea:
# - Use `zip` to align names and scores by position.
# - Map over the paired values to build structured rows.
# - Let the lambda decide the pass/fail rule so the object stays generic.
class StudentRoster
def initialize(classifier:)
@classifier = classifier
end
def rows(names, scores)
names.zip(scores).map do |name, score|
{
name: name,
score: score,
passed: @classifier.call(score)
}
end
end
end
Ruby course source
# ANSWER KEY (ADVANCED)
# Topic: topic_14_functional_style_lambdas
#
# Solution idea:
# - Composition applies small functions from right to left.
# - The pipeline turns a raw numeric score into a final display label.
# - `GradePipeline` does not know the transformation details; it only applies the function.
module FunctionTools
def self.compose(*functions)
->(input) { functions.reverse.reduce(input) { |value, fn| fn.call(value) } }
end
end
class GradePipeline
def initialize(transform:)
@transform = transform
end
def labels(names, scores)
names.zip(scores).map do |name, score|
"#{name}: #{@transform.call(score)}"
end
end
end
Ruby course source
# This spec is your learning companion for topic_14_functional_style_lambdas.
#
# 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 and 3: pairing data and injecting a lambda.
# 4) Implement the ADVANCED exercise as sprint 4: composing functions into a pipeline.
#
# Expected final result:
# - All examples in this file pass.
# - You understand how functional-style Ruby helps solve transformation problems.
require_relative "../example"
require_relative "../basic_exercise"
require_relative "../adv_exercise"
RSpec.describe "topic_14_functional_style_lambdas" do
describe "EXAMPLE purpose: understand a small data transform with zip and map" do
it "sums aligned vectors pairwise" do
math = VectorMath.new
expect(math.pairwise_sum([1, 2, 3], [10, 20, 30])).to eq([11, 22, 33])
end
end
describe "BASIC EXERCISE purpose: solve a reporting task with zip and a lambda rule" do
it "builds roster rows with pass/fail status" do
classifier = ->(score) { score >= 60 }
roster = StudentRoster.new(classifier: classifier)
expect(roster.rows(%w[Ana Bob], [88, 52])).to eq(
[
{ name: "Ana", score: 88, passed: true },
{ name: "Bob", score: 52, passed: false }
]
)
end
end
describe "ADVANCED EXERCISE purpose: compose lambdas into a reusable transformation pipeline" do
it "curves, clamps, and converts scores into labels" do
curve = ->(score) { score + 5 }
clamp = ->(score) { [score, 100].min }
to_letter = lambda do |score|
case score
when 90..100 then "A"
when 80...90 then "B"
when 70...80 then "C"
else "D"
end
end
transform = FunctionTools.compose(to_letter, clamp, curve)
pipeline = GradePipeline.new(transform: transform)
expect(pipeline.labels(%w[Ana Bob], [88, 76])).to eq(
["Ana: A", "Bob: B"]
)
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 แล้วจดว่าคำตอบต่างจากวิธีคิดแรกของคุณตรงไหน