14

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

ส่วน Ruby แบบ functional มักหน้าตาอย่างนี้:

names.zip(scores)
     .select { |_name, score| score >= 60 }
     .map { |name, score| "#{name}: #{score}" }
shortnote.md
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
end
worked_examples.md
ruby

Ruby แบบ functional:

quantities.zip(prices).map { |qty, price| qty * price }
worked_examples.md
ruby

เหตุผลที่เวอร์ชันที่สองดีกว่าในกรณีนี้:

  • การจับข้อมูลให้ตรงกันถูกบอกไว้ชัดผ่าน zip
  • การแปลงข้อมูลถูกบอกไว้ชัดผ่าน map
  • ไม่มี array ชั่วคราวที่คอยเปลี่ยนค่าอยู่นอกลูป

Example 2: กรองก่อนแล้วค่อยแปลง

แบบเก่า:

labels = []
scores.each do |score|
  next if score < 60
  labels << "PASS: #{score}"
end
worked_examples.md
ruby

Ruby แบบ functional:

scores.select { |score| score >= 60 }
      .map { |score| "PASS: #{score}" }
worked_examples.md
ruby

เหตุผลที่เรื่องนี้สำคัญ:

  • การตัดสินใจว่า "เก็บอะไรไว้" ถูกแยกจากการตัดสินใจว่า "จะแปลงอย่างไร"
  • แต่ละขั้นจึงถูกทดสอบหรือเปลี่ยนทีละส่วนได้

Example 3: function composition สำหรับกฎทางธุรกิจ

สมมติว่าคะแนนต้องถูก curve, จำกัดเพดาน แล้วค่อยเปลี่ยนเป็น label เราจะเขียนเป็น method ใหญ่ก็ได้ แต่การต่อ functions เป็นลำดับทำให้แต่ละขั้นยังมองเห็นอยู่:

transform = FunctionTools.compose(to_letter, clamp, curve)
worked_examples.md
ruby

นี่เป็นการใช้ lambdas ในโลกจริงที่ดี เพราะแต่ละขั้นมีชื่อและมีความหมายจริง

โพยสั้น

แปลงข้อมูล

scores.map { |score| score + 5 }
cheatsheet.md
ruby

กรองข้อมูล

scores.select { |score| score >= 60 }
cheatsheet.md
ruby

จับคู่ collections ที่เรียงตำแหน่งตรงกัน

names.zip(scores)
cheatsheet.md
ruby

Lambda

classifier = ->(score) { score >= 60 }
cheatsheet.md
ruby

Composition

def self.compose(*functions)
  ->(input) { functions.reverse.reduce(input) { |value, fn| fn.call(value) } }
end
cheatsheet.md
ruby

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

  • each: ทำงานเพื่อ side effects
  • map: สร้าง collection ใหม่จากการแปลงข้อมูล
  • select: เก็บเฉพาะรายการที่ตรงเงื่อนไข
  • zip: จับคู่ collections ที่สัมพันธ์กันด้วยตำแหน่ง

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

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

หัวข้อนี้อยากให้คุณเห็นว่า Ruby แบบ functional ไม่ใช่ท่าโชว์ แต่มันเป็นวิธีเขียนโค้ด แปลงข้อมูลที่ตรวจทาน ทดสอบ และขยายต่อได้ง่ายกว่า

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

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

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

Study Prompts

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

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