บทเรียน 96 · Rails · Day 1

เมื่อโค้ดพัง — rescue แทน if err != nil และ duck typing

~20 นาที · ควรผ่าน บทเรียน 93 (method) และ บทเรียน 95 (class) มาก่อน · ถ้าจำ บทเรียน 05 (error ของ Go) ได้คร่าว ๆ จะง่ายขึ้นมาก

ตอนเขียน Go คุณพิมพ์ if err != nil จนชินมือ — แต่ใน Ruby บรรทัดนั้นไม่มีเลย
อ่านจบบทนี้คุณจะ: อ่านข้อความ error ของ Ruby ออกว่าพังที่ไหนเพราะอะไร · เขียน begin / rescue รับ error ได้ · โยน error เองด้วย raise · และเข้าใจ duck typing — เหตุผลที่ Ruby ไม่ต้องประกาศ interface แต่แลกมากับการพังตอนรัน

🔁 ทวนก่อนเริ่ม

เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · Go 1.26.5 (สไลด์ใช้ Ruby 4.0.6 · Rails 8.1.3 — เป็นรุ่นแพตช์ใกล้กัน ไม่กระทบเรื่องในบทนี้) · โค้ด Ruby ทุกก้อนรันด้วย ruby ชื่อไฟล์.rb ใน terminal · ผลรันทุกกล่องก็อปจากเครื่องจริง

1 · ทวน Go: error คือ "ค่า" ที่ส่งคืนมา

เริ่มจากของที่คุณรู้แล้ว ตัวอย่างทั้งบทจะใช้เรื่องเดียวกัน: แบ่งบิลค่าอาหาร — ยอด 900 บาท หารกันกี่คน
ใน Go ฟังก์ชันที่อาจพังจะคืนค่าสองตัว: ผลลัพธ์ กับ error

// main.go (Go)
package main

import (
	"errors"
	"fmt"
)

func splitBill(total, people int) (int, error) {
	if people == 0 {
		return 0, errors.New("จำนวนคนเป็น 0 ไม่ได้")
	}
	return total / people, nil
}

func main() {
	share, err := splitBill(900, 0)
	if err != nil {
		fmt.Println("แบ่งบิลไม่ได้:", err)
		return
	}
	fmt.Println("คนละ", share)
}
$ go run .
แบ่งบิลไม่ได้: จำนวนคนเป็น 0 ไม่ได้

สรุปง่าย ๆ: ใน Go error เดินทางมากับค่าที่ return เหมือนของชิ้นหนึ่ง · ใครได้รับก็ต้องหยิบขึ้นมาดูเอง แล้วตัดสินใจว่าจะจัดการเลย หรือ return err ส่งต่อให้ชั้นบน

2 · Ruby: error ถูก "โยน" ขึ้นไปจนกว่าจะมีคนรับ

Ruby ไม่คืน error เป็นค่า · พอเกิดเรื่องผิดปกติ Ruby จะหยุดบรรทัดนั้นทันที แล้วโยน error ออกไปหา "คนรับ" ที่อยู่ข้างบน
"ข้างบน" คือ method ที่เรียกเรามา แล้วก็ method ที่เรียก method นั้นมาอีกที ไล่ขึ้นไปทีละชั้น · ถ้าไม่มีใครรับเลยสักชั้น — โปรแกรมหยุดทั้งตัว

มาดูของจริง นี่คือการแบ่งบิลแบบ Ruby ที่ยังไม่มีใครรับ error:

# crash.rb — แบ่งบิลร้านอาหาร
def split_bill(total, people)
  total / people
end

puts "เริ่มคิดเงิน"
puts split_bill(900, 3)
puts split_bill(900, 0)
puts "คิดเงินเสร็จ"
$ ruby crash.rb
เริ่มคิดเงิน
300
crash.rb:3:in 'Integer#/': divided by 0 (ZeroDivisionError)
	from crash.rb:3:in 'Object#split_bill'
	from crash.rb:8:in '<main>'
$ echo $?
1

สังเกตว่า "คิดเงินเสร็จ" ไม่ถูกพิมพ์ · พอหารด้วย 0 Ruby โยน error ออกมา ไม่มีใครรับ โปรแกรมจึงหยุดตรงนั้น · echo $? ได้ 1 แปลว่า "จบแบบพัง" (0 = จบปกติ)

🔔 เทียบให้เห็นภาพ — กล่องจดหมาย กับ กริ่งไฟไหม้

Go เหมือนตึกที่ทุกห้องมีกล่องจดหมาย — ข่าวร้ายมาเป็นจดหมายใส่กล่อง คนในห้องต้องเปิดกล่องดูเองทุกครั้ง (if err != nil) ถ้าไม่เปิดดู งานก็เดินต่อไปเหมือนไม่มีอะไรเกิดขึ้น
Ruby เหมือนกริ่งไฟไหม้ — เกิดเรื่องที่ห้องไหน ทุกคนในห้องนั้นหยุดมือทันที เสียงกริ่งดังขึ้นไปทีละชั้น จนกว่าจะเจอชั้นที่มีเจ้าหน้าที่ประจำ (rescue) มารับเรื่อง · ถ้าดังไปถึงดาดฟ้าแล้วไม่มีใครรับ = อพยพทั้งตึก (โปรแกรมหยุด)

ที่ไม่เหมือนกัน: กริ่งจริงทุกคนได้ยินพร้อมกัน แต่ error ของ Ruby ขึ้นไปทีละชั้นตามลำดับที่ method เรียกกันมา · และเจ้าหน้าที่ใน Ruby รับเฉพาะเรื่องที่ตัวเองระบุไว้ (หัวข้อ 4) — ไม่ใช่รับได้ทุกเรื่อง

🗣 ศัพท์ที่เพิ่งเรียนไป
exception
ชื่อเรียก error แบบที่ "ถูกโยน" ใน Ruby · มันเป็น object ตัวหนึ่ง มีชื่อชนิด (เช่น ZeroDivisionError) และข้อความ · ถาม senior ได้ว่า “method นี้ raise exception อะไรได้บ้าง” — เพราะ Ruby ไม่มีอะไรเขียนบอกไว้ที่หัว method เหมือน Go
raise (โยน) · propagate (ลอยขึ้น)
raise คือการโยน exception ออกมา · propagate คือการที่มันลอยขึ้นไปหา method ที่เรียกมาทีละชั้นเอง โดยเราไม่ต้องเขียน return err ส่งต่อ · พิมพ์บอก AI ได้ว่า “let this exception propagate up instead of handling it here”
unhandled exception
exception ที่ลอยขึ้นไปจนสุดแล้วไม่มีใครรับ → โปรแกรมหยุด พิมพ์ข้อความ error และจบด้วย exit code 1 · ถาม senior ได้ว่า “ตรงนี้ควรปล่อยให้พัง หรือควรรับไว้”

3 · อ่านข้อความ error ให้ออก

ข้อความ error ดูน่ากลัว แต่จริง ๆ มันบอกทุกอย่างที่ต้องรู้ · ลองแยกของ crash.rb ทีละท่อน:

ท่อนแปลว่า
crash.rb:3พังที่ไฟล์ crash.rb บรรทัด 3 (นับบรรทัด comment แรกด้วย) — เปิดไฟล์ไปดูบรรทัดนี้ก่อนเลย
in 'Integer#/'พังระหว่างทำ / (การหาร) ของตัวเลข · อ่านว่า "method / ของ class Integer" — เครื่องหมาย # ในข้อความ error แค่คั่นชื่อ class กับชื่อ method · ใน Ruby แม้แต่ / ก็เป็น method ตัวหนึ่ง
divided by 0ข้อความ — บอกเป็นภาษาคนว่าเกิดอะไร
(ZeroDivisionError)ชื่อชนิดของ error อยู่ในวงเล็บท้ายบรรทัดเสมอ · เอาไปใช้ตอน rescue ได้ (หัวข้อถัดไป)
บรรทัด from ...ทางที่ error ลอยขึ้นมา · อ่านบนลงล่าง = จากจุดพัง ย้อนไปหาคนที่เรียก · 'Object#split_bill' คือ method ของเรา (method ที่เขียนนอก class Ruby นับเป็นของ Object — จำแค่นี้พอ) · '<main>' คือ "ตัวไฟล์เอง" ชั้นนอกสุด (บรรทัด 8 คือ split_bill(900, 0))

เคล็ดลับ: ดูวงเล็บท้ายบรรทัดแรกก่อน (พังเพราะอะไร) แล้วดูเลขบรรทัด (พังที่ไหน) · แค่สองอย่างนี้ก็แก้ได้เกินครึ่งแล้ว

4 · begin / rescue — ขึงคนรับ error

ถ้าไม่อยากให้โปรแกรมหยุด เราต้อง "ขึงคนรับ" ไว้ตรงที่คิดว่าจะพัง
ใส่โค้ดเสี่ยงไว้ระหว่าง begin กับ rescue แล้วเขียนว่าจะทำอะไรถ้าพัง:

# rescue1.rb
def split_bill(total, people)
  total / people
end

begin
  puts split_bill(900, 0)
rescue
  puts "แบ่งบิลไม่ได้"
end
puts "คิดเงินเสร็จ"
$ ruby rescue1.rb
แบ่งบิลไม่ได้
คิดเงินเสร็จ

เทียบ Go: Go ไม่มีคู่คำนี้ — ทุกจุดที่อาจพังต้องเขียน if err != nil { ... } ต่อท้ายทีละจุด · ใน Ruby เขียน begin ... rescue ครอบทีเดียว คลุมได้หลายบรรทัด
ถ้าเคยเขียน TypeScript: begin ≈ try · rescue ≈ catch — คำต่างกันแต่หน้าที่เดียวกัน

รับเฉพาะชนิดที่อยากรับ

ในชีวิตจริง error มีหลายแบบ และแต่ละแบบควรจัดการต่างกัน · Ruby ให้เขียนชื่อชนิดไว้หลัง rescue ได้ แล้วเขียนกี่ช่องก็ได้
ตัวอย่างนี้จำนวนคนมาเป็นข้อความ (เหมือนที่ผู้ใช้พิมพ์มาในฟอร์ม) จึงพังได้สองแบบ:

# rescue2.rb — แยกรับทีละชนิด
def split_bill(total, people_text)
  people = Integer(people_text)   # แปลงข้อความเป็นตัวเลข
  total / people
end

["3", "0", "สาม"].each do |text|
  begin
    puts "คนละ #{split_bill(900, text)} บาท"
  rescue ZeroDivisionError
    puts "จำนวนคนเป็น 0 ไม่ได้"
  rescue ArgumentError => e
    puts "อ่านจำนวนคนไม่ออก: #{e.message}"
  ensure
    puts "  (ปิดบิล)"
  end
end
$ ruby rescue2.rb
คนละ 300 บาท
  (ปิดบิล)
จำนวนคนเป็น 0 ไม่ได้
  (ปิดบิล)
อ่านจำนวนคนไม่ออก: invalid value for Integer(): "สาม"
  (ปิดบิล)

แต่ละรอบไปตกคนละช่อง: "3" ไม่พัง · "0" ตกช่อง ZeroDivisionError · "สาม" ตกช่อง ArgumentError
Ruby ไล่ช่อง rescue จากบนลงล่าง แล้วเข้าช่องแรกที่ชนิดตรง — เข้าได้ช่องเดียว

แล้วถ้า error ที่เกิดไม่ตรงกับช่องไหนเลยล่ะ?

# only_one.rb — รับไว้แค่ชนิดเดียว
begin
  puts 900 / 0
rescue ArgumentError
  puts "รับ ArgumentError ไว้"
end
puts "ไปต่อ"
$ ruby only_one.rb
only_one.rb:3:in 'Integer#/': divided by 0 (ZeroDivisionError)
	from only_one.rb:3:in '<main>'

ช่อง rescue รอแค่ ArgumentError · ZeroDivisionError วิ่งผ่านหน้าไปเฉย ๆ แล้วไม่มีใครรับต่อ โปรแกรมเลยพัง · ไม่พิมพ์ทั้ง "รับ ArgumentError ไว้" และ "ไปต่อ"

📋 เทียบให้เห็นภาพ — ป้ายแผนรับมือที่ติดผนังครัว

ช่อง rescue แต่ละช่องเหมือนป้ายแผนรับมือที่ติดผนังครัวไว้: "ถ้าแก๊สหมด → โทรร้านแก๊ส" · "ถ้าลูกค้าแพ้อาหาร → เรียกผู้จัดการ"
เกิดเรื่องขึ้น พนักงานอ่านป้ายจากบนลงล่าง เจอป้ายที่ตรงก็ทำตามป้ายนั้นแล้วจบ · เรื่องที่ไม่มีป้ายไหนเขียนไว้ ก็ต้องส่งต่อขึ้นไปให้ชั้นบน

ที่ไม่เหมือนกัน: พนักงานจริงใช้วิจารณญาณได้ เจอเรื่องคล้าย ๆ ก็ประยุกต์เอา · แต่ Ruby ดูชื่อชนิดอย่างเดียว ไม่ตรงคือไม่รับ · และ rescue เปล่า ๆ ที่ไม่เขียนชนิด ดูเหมือน "รับทุกเรื่อง" แต่จริง ๆ รับแค่ "เรื่องทั่วไป" (หัวข้อ 5 จะเล่า)

5 · raise — โยน error เอง และสร้างชนิดของตัวเอง

บางทีเราอยากเป็นคนโยนเอง เช่น ลูกค้าสั่งเมนูที่หมดแล้ว — โค้ดไม่ได้พังทางเทคนิค แต่ทำต่อไม่ได้ทางธุรกิจ
นึกถึงกริ่งไฟไหม้ในหัวข้อ 2: raise คือเราเดินไปกดกริ่งเองเมื่อเห็นปัญหา · ใช้ raise และสร้างชนิด error ของเราเองได้ในสองบรรทัด:

# raise.rb — โยน error เอง
class OutOfStockError < StandardError
end

def order(menu, stock)
  raise OutOfStockError, "#{menu} หมดแล้ว" if stock == 0
  "ได้ #{menu} 1 จาน"
end

puts order("ข้าวผัด", 5)
begin
  puts order("ต้มยำ", 0)
rescue OutOfStockError => e
  puts "ขอโทษครับ: #{e.message}"
end
$ ruby raise.rb
ได้ ข้าวผัด 1 จาน
ขอโทษครับ: ต้มยำ หมดแล้ว

เพราะ error ทุกชนิดคือ class ที่สืบทอดกันเป็นตระกูล (ทวน < จาก บท 95) · rescue ชนิดไหน ก็รับลูกหลานของชนิดนั้นด้วย — เช่น rescue StandardError รับ OutOfStockError ได้ เพราะมันเป็นลูกของ StandardError

เทียบ Go: เหมือน errors.New("...") หรือสร้าง struct ที่มี method Error() · ต่างกันที่ Go คืน error ออกไป ส่วน Ruby โยน มันออกไป

⚠️ error ของเราเองให้ < StandardError เสมอ

rescue แบบไม่เขียนชนิด (และ rescue => e) รับเฉพาะตระกูล StandardError · ส่วน Exception คือแม่ใหญ่สุดที่ครอบเรื่องระดับระบบด้วย (เช่นการกด Ctrl+C สั่งหยุดโปรแกรม) จึงตั้งใจไม่ให้ rescue ธรรมดารับ · ถ้าเผลอให้ error ของเราสืบทอดจาก Exception:

# wrong_parent.rb — สืบทอดผิดแม่
class PaymentError < Exception
end

begin
  raise PaymentError, "บัตรถูกปฏิเสธ"
rescue => e
  puts "รับไว้แล้ว"
end
$ ruby wrong_parent.rb
wrong_parent.rb:6:in '<main>': บัตรถูกปฏิเสธ (PaymentError)

ไม่ได้พิมพ์ "รับไว้แล้ว" — rescue => e ปล่อยผ่าน โปรแกรมพัง · แก้แค่เปลี่ยน Exception เป็น StandardError

ท่าย่อ: rescue ห้อยท้าย method (ไม่ต้องมี begin)

ถ้าอยากคุ้มครองทั้ง method ไม่ต้องเขียน begin — เอา rescue ไปห้อยก่อน end ของ method ได้เลย · โค้ด Rails ใช้ท่านี้บ่อยมาก จำหน้าตาไว้:

# method_rescue.rb — rescue ห้อยท้าย method (ไม่ต้องมี begin)
def parse_people(text)
  Integer(text)
rescue ArgumentError
  puts "อ่านจำนวนคนไม่ออก ขอนับเป็น 1 คน"
  1
end

p parse_people("4")
p parse_people("four")
$ ruby method_rescue.rb
4
อ่านจำนวนคนไม่ออก ขอนับเป็น 1 คน
1

เขตคุ้มครองคือ "ทั้งตัว method" · ถ้าพัง ค่าบรรทัดสุดท้ายของช่อง rescue (1) จะกลายเป็นค่าที่ method คืน · (p คือพิมพ์ค่าออกมาแบบเห็นชนิดชัด ๆ — ทวน บท 93)

🗣 ศัพท์ของการรับ error
begin / rescue / end
คู่คำที่ขึงเขตคุ้มครอง · ≈ try / catch ของ TypeScript · พิมพ์บอก AI ได้ว่า “wrap only the risky line in begin/rescue and rescue ZeroDivisionError only”
rescue ชนิด => e
รับเฉพาะ error ชนิดนั้น แล้วเก็บตัว error ไว้ในตัวแปร e · ใช้ e.message ดูข้อความ · e.class ดูชนิด · ถาม senior ได้ว่า “ตรงนี้ควร rescue ชนิดไหน ให้แคบที่สุดเท่าที่ได้”
StandardError · custom error
StandardError คือแม่ของ error ทั่วไป · custom error คือชนิดที่เราสร้างเองด้วย class XxxError < StandardError · พิมพ์บอก AI ได้ว่า “define a custom error class that inherits from StandardError”
method-level rescue
เขียน rescue ห้อยท้าย method โดยไม่มี begin (คู่มือ Ruby เรียกว่า begin-less exception handler) · ถาม senior ได้ว่า “ทีมนี้ชอบ rescue ท้าย method หรือ begin ครอบเฉพาะบรรทัด”

6 · ensure — ทำเสมอ ไม่ว่าจะพังหรือไม่

บางงานต้องทำทุกครั้ง ไม่ว่าทางจะราบรื่นหรือพังกลางทาง เช่น ปิดเตา ปิดไฟล์ ปิดการเชื่อมต่อ · ช่อง ensure มีไว้สำหรับงานแบบนี้ — ถ้าเคยใช้ defer ของ Go (บทเรียน 08) มันคือความคิดเดียวกัน: "ออกทางไหนก็ต้องผ่านตรงนี้"

🔥 เทียบให้เห็นภาพ — ป้าย "ออกจากครัวต้องปิดแก๊ส"

ประตูครัวมีป้ายติดไว้: "ใครออกจากครัว ต้องปิดแก๊สก่อนเสมอ" · ทำอาหารเสร็จเรียบร้อยก็ต้องปิด · ทำพังกลางทางแล้ววิ่งไปตามผู้จัดการก็ต้องปิด — ออกทางไหนก็ต้องผ่านป้ายนี้
ensure คือป้ายนั้น · rescue คือ "ถ้ามีปัญหาให้ทำอะไร" ส่วน ensure คือ "ไม่ว่ายังไงก็ทำ"

ที่ไม่เหมือนกัน: คนจริงอาจลืมอ่านป้าย แต่ Ruby ไม่เคยลืม รันช่อง ensure ให้ทุกครั้ง · และ ensure ไม่ได้ "แก้" ปัญหา — ถ้าไม่มี rescue รับ error ก็ยังลอยต่อขึ้นไปหลังปิดแก๊สเสร็จ

🗣 ศัพท์ของ ensure
ensure · cleanup
ensure คือช่องที่รันเสมอ · งานในช่องนี้เรียกรวม ๆ ว่า cleanup (เก็บกวาด เช่นปิดไฟล์ ปิดการเชื่อมต่อ) ≈ defer ของ Go · ≈ finally ของ TypeScript · พิมพ์บอก AI ได้ว่า “put the cleanup in an ensure block so it runs even if this raises”
# predict.rb
def cook
  puts "1. ตั้งกระทะ"
  raise "แก๊สหมด"
  puts "2. ผัดข้าว"
end

begin
  cook
  puts "3. เสิร์ฟ"
rescue => e
  puts "4. แจ้งลูกค้า: #{e.message}"
ensure
  puts "5. ปิดเตา"
end

ข้อ 1 ถึง 5 — ข้อไหนถูกพิมพ์ออกมาบ้าง? (raise "ข้อความ" แบบไม่บอกชนิด = โยน error ชนิดทั่วไปชื่อ RuntimeError · rescue => e = รับ error ทั่วไปทุกชนิด แล้วเก็บไว้ใน e)

$ ruby predict.rb
1. ตั้งกระทะ
4. แจ้งลูกค้า: แก๊สหมด
5. ปิดเตา
  • ข้อ 2 หายไป — พอ raise แล้ว method cook หยุดทันที บรรทัดที่เหลือไม่ได้รัน
  • ข้อ 3 หายไป — error ลอยออกจาก cook มาถึงเขต begin แล้วกระโดดไปที่ rescue เลย ข้าม "เสิร์ฟ"
  • ข้อ 5 มาเสมอ — นี่คืองานของ ensure · ถ้าไม่พัง (ลองลบบรรทัด raise) ก็ยังได้ "5. ปิดเตา" อยู่ดี

7 · ข้อดีข้อเสีย: Go กับ Ruby

ไม่มีแบบไหนดีกว่ากันทุกเรื่อง · ลองดูว่าแต่ละแบบแลกอะไรกับอะไร
ก่อนอื่น ลองทำแบบ "ขี้เกียจเช็ค" ใน Go — ทิ้ง error ด้วย _:

// Go: ทิ้ง error ด้วย _ แล้วไม่เช็ค
share, _ := splitBill(900, 0)
fmt.Println("คนละ", share)
$ go build ./...    # ผ่าน ไม่มีคำเตือน
$ go run .
คนละ 0

โปรแกรม Go ไม่พัง แต่บอกว่าคนละ 0 บาท — ค่าผิดที่เดินต่อไปเงียบ ๆ · ถ้าเป็น Ruby เราได้เห็นแล้วว่ามันพังดัง ๆ แทน

เรื่องGo (error เป็นค่า)Ruby (โยน exception)
รู้ไหมว่าบรรทัดไหนพังได้✅ รู้ — ดูจาก error ที่หัวฟังก์ชัน❌ ไม่รู้ — ต้องอ่านเอกสารหรือลองรัน
ลืมจัดการแล้วเกิดอะไรเดินต่อด้วยค่าเพี้ยน ๆ (เช่น คนละ 0)โปรแกรมหยุด พร้อมบอกว่าพังที่ไหน
ความยาวโค้ดยาว — if err != nil ทุกชั้นสั้น — รับครั้งเดียวที่ชั้นที่จัดการได้จริง
ใครเตือนก่อนรันcompiler เตือนบางกรณีไม่มีใครเตือน — ต้องพึ่ง test

8 · ใน Rails: error ที่ไม่มีใครรับ = หน้า 500 (แต่ร้านไม่ปิด)

แอป Rails ก็คือโค้ด Ruby · ถ้าโค้ดใน controller (หัวหน้าเชฟที่รับใบสั่ง — เจอละเอียดใน บท 102) โยน error แล้วไม่มีใครรับ Rails จะเป็นคนรับชั้นบนสุดให้เอง แล้วตอบกลับไปเป็นหน้า error · server ไม่ดับ

ลองในแอป wongnok_blog (แอปบล็อกในสไลด์ — บทความหนึ่งชิ้นเรียกว่า Post) · ตอนนี้ยังไม่มี database (รอ Day 2) จึงจำลองบทความด้วย Hash แทน

👀 ส่วนนี้แค่ดูผล ยังไม่ต้องพิมพ์ตาม

การทำให้โค้ดนี้รันได้ต้องรู้เรื่อง route (พนักงานต้อนรับที่ชี้ว่า URL ไหนไปหาใคร — บท 101) และ controller (บท 102) ก่อน · ตอนนี้ให้ดูแค่ว่า error ใน Rails หน้าตาเป็นยังไง

ไฟล์ app/controllers/posts_controller.rb:

class PostsController < ApplicationController
  # จำลองบทความด้วย Hash (ของจริงจะอยู่ใน database — Day 2)
  POSTS = { 1 => "Rails วันแรก" }

  def show
    title = POSTS[params[:id].to_i]   # หาไม่เจอ → nil
    render plain: title.upcase
  end
end

เปิด server (bin/rails server) แล้วยิง request สามครั้ง: บทความที่มี → บทความที่ไม่มี → บทความที่มีอีกที

$ curl http://localhost:3000/posts/1
RAILS วันแรก
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/posts/2
500
$ curl http://localhost:3000/posts/1
RAILS วันแรก
# log ของ server ตอน /posts/2 (บางส่วน)
Completed 500 Internal Server Error in 0ms (ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.0ms)

NoMethodError (undefined method 'upcase' for nil):

app/controllers/posts_controller.rb:7:in 'PostsController#show'
🍳 เทียบให้เห็นภาพ — จานเสียหนึ่งจาน ร้านไม่ต้องปิด

ลูกค้าสั่ง "บทความจานที่ 2" (request) · หัวหน้าเชฟ (controller) ไปเปิดตู้แล้วไม่เจอวัตถุดิบ แต่ดันลงมือทำต่อ — ทำไม่ได้ ครัวโวยวายขึ้นมา (error)
ผู้จัดการร้าน (Rails) เดินมารับเรื่อง ยกป้าย "ขออภัย ครัวมีปัญหา" ไปให้โต๊ะนั้น (หน้า 500) · โต๊ะอื่นที่สั่งจานที่ 1 ยังได้อาหารตามปกติ ร้านไม่ต้องปิด

ที่ไม่เหมือนกัน: ผู้จัดการจริงอาจเสนอเมนูอื่นแทนหรือขอโทษอย่างสุภาพ · แต่ Rails ทำแค่ส่งหน้า error มาตรฐาน — ถ้าอยากได้คำตอบที่ดีกว่า เช่น "ไม่พบบทความนี้" (404) เราต้องเขียนเอง · และ 500 ที่ได้ก็ไม่ได้บอกลูกค้าว่าพลาดตรงไหน (รายละเอียดอยู่ใน log ฝั่งร้านเท่านั้น)

ในโค้ด Rails จริง คุณจะเจอวิธี "ขึงคนรับให้ทุก action ทีเดียว" หน้าตาแบบนี้: rescue_from ActiveRecord::RecordNotFound, with: :render_404 — อ่านว่า "ถ้า action ไหนโยน error ชนิด หาข้อมูลไม่เจอ ให้ไปเรียก method render_404 แทน" · มันคือ rescue ที่ครอบทั้ง controller · ตอนนี้แค่จำหน้าตาไว้ ใช้จริงตอน Day 2

🗣 ศัพท์ฝั่ง Rails
500 Internal Server Error
รหัสคำตอบที่แปลว่า "ฝั่ง server พังเอง ไม่ใช่ความผิดของคนขอ" · ใน Rails มักมาจาก exception ที่ไม่มีใครรับ · ถาม senior ได้ว่า “หน้า 500 นี้ ดู log ตรงไหนถึงจะเจอบรรทัดที่พัง”
server log
ข้อความที่ bin/rails server พิมพ์ออกมาทุก request — บอกว่าใครขออะไร ได้รหัสอะไร และถ้าพังก็บอกชนิดกับบรรทัด · เวลาถาม AI ให้แปะ log ส่วนนี้ไปด้วยเสมอ ไม่ใช่แค่บอกว่า "ได้ 500"
rescue_from
คำสั่งของ controller ที่บอกว่า "error ชนิดนี้ ไม่ว่าเกิดใน action ไหน ให้ method นี้รับ" · พิมพ์บอก AI ได้ว่า “use rescue_from in ApplicationController to render a custom 404 page” (Day 2)

9 · Duck typing — ไม่ถามว่าเป็นใคร ถามแค่ว่า "ทำได้ไหม"

เปลี่ยนเรื่องนิดหนึ่ง แต่เกี่ยวกันมาก · ใน Go ถ้าอยากให้ฟังก์ชันรับ "ของอะไรก็ได้ที่มี Title()" คุณต้องประกาศ interface ไว้ก่อน แล้ว compiler จะตรวจให้ตอน build (ทวน บทเรียน 10):

// Go (บางส่วน — ไฟล์เต็มมี package, import และ type Menu ที่มี method Title())
type HasTitle interface {
	Title() string
}

func show(x HasTitle) {
	fmt.Println(x.Title())
}

// ... ใน main
show(Menu{})   // Menu มี Title() → ผ่าน
show(42)       // int ไม่มี Title() → build ไม่ผ่าน
$ go build ./...
# goduck
./main.go:19:7: cannot use 42 (constant of type int) as HasTitle value in argument to show: int does not implement HasTitle (missing method Title)

(# goduck คือชื่อ package ของตัวอย่าง) · Go จับได้ก่อนรัน · โปรแกรมไม่มีโอกาสได้รันสักบรรทัด
Ruby ไม่มี interface ให้ประกาศเลย · method แค่เรียก x.title ไปเฉย ๆ — ใครส่งอะไรมา ถ้ามี title ก็ผ่าน ไม่สนว่าเป็น class อะไร:

# duck.rb — ใครมี .title ก็ใช้ได้
class Post
  def title
    "Rails วันแรก"
  end
end

class Menu
  def title
    "ข้าวผัดกระเพรา"
  end
end

def show(x)
  puts x.title
end

show(Post.new)
show(Menu.new)
show(42)
$ ruby duck.rb
Rails วันแรก
ข้าวผัดกระเพรา
duck.rb:15:in 'Object#show': undefined method 'title' for an instance of Integer (NoMethodError)

  puts x.title
        ^^^^^^
	from duck.rb:20:in '<main>'

สองบรรทัดแรกรันผ่านไปก่อน แล้วเพิ่งมาพังตอนถึง show(42) จริง ๆ · นี่คือความต่างที่สำคัญ: Go ตรวจตอน build · Ruby รู้ตัวตอนรันถึงบรรทัดนั้น

🦆 เทียบให้เห็นภาพ — ร้องก๊าบ ๆ เดินแบบเป็ด ก็ถือว่าเป็นเป็ด

ชื่อ duck typing มาจากสำนวน "ถ้ามันเดินเหมือนเป็ด ร้องก๊าบ ๆ เหมือนเป็ด ก็ถือว่ามันเป็นเป็ด"
Ruby ไม่ขอดูใบรับรองว่าคุณ "เป็นเป็ดจริงไหม" (เป็น class อะไร) · ขอแค่ตอนที่ต้องร้อง คุณร้องได้ (มี method title) ก็พอ · Go ต่างออกไป: ต้องมีรายชื่อ "สิ่งที่เป็ดต้องทำได้" (interface) แล้ว compiler ตรวจทุกตัวก่อนปล่อยลงบ่อ

ที่ไม่เหมือนกัน: คนดูเป็ดจริงตัดสินได้ตั้งแต่แรกเห็น · แต่ Ruby ไม่ดูก่อนเลย — ปล่อยทุกตัวลงบ่อ แล้วรู้ว่าตัวไหนไม่ใช่เป็ด ก็ตอนสั่งให้ร้องแล้วมันร้องไม่ได้ (พังตอนรัน) · ถ้าอยากรู้ก่อน ต้องถามเอง (respond_to? ข้างล่าง) หรือเขียน test

respond_to? — ถามก่อนว่า "ทำได้ไหม"

ทุก object ใน Ruby มี method respond_to? ไว้ถามล่วงหน้าว่า "ตอบ method ชื่อนี้ได้ไหม":

# respond.rb — ถามก่อนว่า "ทำได้ไหม"
class Menu
  def title
    "ข้าวผัดกระเพรา"
  end
end

p Menu.new.respond_to?(:title)
p 42.respond_to?(:title)

def safe_show(x)
  if x.respond_to?(:title)
    puts "หัวข้อ: #{x.title}"
  else
    puts "(#{x.class} ไม่มี title)"
  end
end

safe_show(Menu.new)
safe_show(42)
$ ruby respond.rb
true
false
หัวข้อ: ข้าวผัดกระเพรา
(Integer ไม่มี title)
🗣 ศัพท์ของ duck typing
duck typing
การตัดสินว่า object ใช้ได้ไหมจากสิ่งที่มันทำได้ (method ที่มี) ไม่ใช่จากชนิดของมัน · ถาม AI ได้ว่า “this method relies on duck typing — what objects can I pass to it?”
runtime · compile time
compile time = ตอน build ก่อนรัน (Go ตรวจ interface ตรงนี้) · runtime = ตอนโปรแกรมกำลังรันอยู่ (Ruby รู้ตรงนี้) · ถาม senior ได้ว่า “error นี้จับได้ตั้งแต่ compile time ไหม หรือต้องรอ runtime”
respond_to?
method ที่ทุก object มี ถามว่า "ตอบ method ชื่อนี้ได้ไหม" · พิมพ์บอก AI ได้ว่า “guard this call with respond_to?(:title) instead of checking the class”
NoMethodError
error ที่ได้เมื่อเรียก method ที่ object นั้นไม่มี — ตัวที่คุณจะเจอบ่อยที่สุดใน Ruby (หัวข้อถัดไป) · ถาม senior ได้ว่า “NoMethodError ตัวนี้ ควรกันที่ต้นทางหรือเช็คที่ปลายทาง”

10 · NoMethodError — error ที่จะเจอบ่อยที่สุด

error ที่มือใหม่ Ruby เจอบ่อยมาก คือเรียก method บน nil — แปลว่า "ตัวแปรนี้ไม่มีค่า ทั้งที่คุณคิดว่ามี" · มักเกิดจากหาของไม่เจอมาตั้งแต่ก่อนหน้า:

# nil_title.rb — ของที่หาไม่เจอ = nil
posts = { 1 => "Rails วันแรก" }   # จำลองบทความด้วย Hash
title = posts[2]                  # ไม่มีบทความเลข 2 → ได้ nil
puts title.upcase
$ ruby nil_title.rb
nil_title.rb:4:in '<main>': undefined method 'upcase' for nil (NoMethodError)

puts title.upcase
          ^^^^^^^
ท่อนแปลว่า
undefined method 'upcase'"ไม่รู้จัก method ชื่อ upcase" · ชื่อในเครื่องหมาย '...' คือ method ที่เราพยายามเรียก
for nilของที่อยู่หน้าจุดเป็น nil · ถ้าเป็นของชนิดอื่นจะเขียนว่า for an instance of Integer (เป็นตัวเลข) แบบใน duck.rb
^^^^^^^Ruby ขีดเส้นใต้ให้เลยว่าพังตรง .upcase

วิธีแก้: อย่าแก้ที่บรรทัดที่พัง · ถามว่า "ทำไม title ถึงเป็น nil" แล้วย้อนไปบรรทัดก่อนหน้า — ที่นี่คือ posts[2] หาไม่เจอ · วิธีรับมือ nil ให้สวย ๆ อยู่ใน บท 94

11 · กับดักของคนที่มาจาก Go

มือ Go ที่เพิ่งมา Ruby มักสะดุดเรื่องเดิม ๆ · เรื่อง "ไม่มีใครบังคับให้รับ error" คุณเห็นกับตาแล้วในหัวข้อ 2 และ 8 · เรื่อง "เขียน for loop แทน map/select" ทวนได้ใน บท 92 · ที่เหลือ 4 ข้อ รันให้ดูสั้น ๆ:

① เลข 0 นับว่า "จริง"

# zero.rb
count = 0
if count
  puts "0 ถือว่าจริง!"
end
p count.zero?
p count.positive?
$ ruby zero.rb
0 ถือว่าจริง!
true
false

ใน Ruby มีแค่ false กับ nil ที่นับว่าเท็จ · อยากเช็คว่าเป็นศูนย์ให้ถามตรง ๆ ด้วย .zero? หรือ .positive? (มากกว่า 0) · (ใน Go เขียน if count กับตัวเลขไม่ได้อยู่แล้ว · ข้อนี้คนจาก TypeScript โดนบ่อยกว่า)

② บรรทัดสุดท้ายคือค่าที่คืน — เผลอเติมบรรทัดแล้วค่าเปลี่ยน

# last_line.rb
def full_name(first, last)
  name = "#{first} #{last}"
  puts "สร้างชื่อเสร็จแล้ว"   # เผลอเติมบรรทัดนี้ต่อท้าย
end

p full_name("สมชาย", "ใจดี")
$ ruby last_line.rb
สร้างชื่อเสร็จแล้ว
nil

คนจาก Go รอ return name แต่ Ruby คืนค่าของบรรทัดสุดท้าย ซึ่งคือ puts → nil เสมอ · ไม่มี error ไม่มีคำเตือน — แล้ว nil ตัวนี้จะไปพังเป็น NoMethodError ที่อื่นทีหลัง

③ ไม่มี type บอก — ส่งผิดชนิดแล้วไม่พัง แต่ได้ผลแปลก

# no_types.rb
def total(price, qty)
  price * qty
end

p total(5, 3)
p total("5", 3)
$ ruby no_types.rb
15
"555"

ส่ง "5" (ข้อความ) แทน 5 (ตัวเลข) — ไม่พังด้วยซ้ำ เพราะ string ก็มี * (แปลว่า "ทำซ้ำ 3 ครั้ง") · duck typing ทำงาน "ถูก" แต่ผลผิด · เมื่อไม่มี type ให้อ่าน วิธีรู้ว่า method รับอะไรคืออ่าน test (โค้ดที่ทดสอบ method นั้น — Day 4) และลองของจริงใน console (ครัวทดลองหลังร้าน — บท 104)

④ ชื่อไฟล์ใน Rails — ใช้ snake_case

ใน Go เราตั้งชื่อไฟล์อะไรก็ได้ แล้ว import เอาเอง · แต่ Rails หาไฟล์จากชื่อ โดยไม่ต้อง import (เหมือนร้านแฟรนไชส์ที่ของทุกชิ้นต้องอยู่ช่องที่ตั้งชื่อไว้ — ละเอียดใน บท 99)
กฎคือ class BlogPost ต้องอยู่ในไฟล์ blog_post.rb — ตัวเล็กทั้งหมด คั่นคำด้วย _ เรียกว่า snake_case · ถ้าตั้งชื่อ blogpost.rb (ติดกันหมด) Rails จะฟ้อง uninitialized constant BlogPost = "หา BlogPost ไม่เจอ"

ภารกิจ

🎯 ภารกิจ: เครื่องแบ่งบิลที่ไม่ล้ม (~10 นาที)

เริ่มจากโค้ดนี้ ซึ่งพังทันทีที่เจอ "0":

# bill.rb — ขั้นที่ 1: ยังไม่มีใครรับ error
def split_bill(total, people_text)
  people = Integer(people_text)
  total / people
end

puts split_bill(900, "3")
puts split_bill(900, "0")
puts split_bill(900, "สาม")
puts "ร้านปิด"
  1. สร้างไฟล์ bill.rb วางโค้ดข้างบน แล้วรัน ruby bill.rb — ควรเห็น 300 แล้วพังด้วย (ZeroDivisionError)
  2. แก้ split_bill ให้คืนข้อความแทนตัวเลข: ถ้าสำเร็จคืน "คนละ 300 บาท" (คำใบ้: เปลี่ยนบรรทัดสุดท้ายเป็น string ที่มี #{total / people} อยู่ข้างใน)
  3. เพิ่ม rescue ห้อยท้าย method (หัวข้อ 5) สองช่อง: หารด้วยศูนย์ → คืน "จำนวนคนต้องมากกว่า 0" · แปลงตัวเลขไม่ได้ → คืน "อ่านจำนวนคนไม่ออก (ArgumentError)" โดยดึงชื่อชนิดมาจาก e.class
  4. ต่อท้ายไฟล์ด้วยสามบรรทัดนี้ เพื่อเช็คตัวเอง — ต้องได้ true ทั้งสามบรรทัด:
    p split_bill(900, "3") == "คนละ 300 บาท"
    p split_bill(900, "0") == "จำนวนคนต้องมากกว่า 0"
    p split_bill(900, "สาม") == "อ่านจำนวนคนไม่ออก (ArgumentError)"

ถ้าเจอ error นี้ แปลว่า…

# bill.rb — ขั้นที่ 2: มีคนรับแล้ว
def split_bill(total, people_text)
  people = Integer(people_text)
  "คนละ #{total / people} บาท"
rescue ZeroDivisionError
  "จำนวนคนต้องมากกว่า 0"
rescue ArgumentError => e
  "อ่านจำนวนคนไม่ออก (#{e.class})"
end

puts split_bill(900, "3")
puts split_bill(900, "0")
puts split_bill(900, "สาม")
puts "ร้านปิด"

# เช็คตัวเอง: ต้องได้ true ทั้งสามบรรทัด
p split_bill(900, "3") == "คนละ 300 บาท"
p split_bill(900, "0") == "จำนวนคนต้องมากกว่า 0"
p split_bill(900, "สาม") == "อ่านจำนวนคนไม่ออก (ArgumentError)"
$ ruby bill.rb
คนละ 300 บาท
จำนวนคนต้องมากกว่า 0
อ่านจำนวนคนไม่ออก (ArgumentError)
ร้านปิด
true
true
true
  • ไม่ต้องมี begin เพราะ rescue ห้อยท้าย method คุ้มครองทั้งตัว method อยู่แล้ว
  • ค่าที่ method คืน = บรรทัดสุดท้ายของช่องที่ได้ทำ (ช่องปกติ หรือช่อง rescue ช่องใดช่องหนึ่ง)
  • "ร้านปิด" ถูกพิมพ์แล้ว — ไม่มีอะไรหลุดออกมาจน program หยุด
  • ลองต่อ: ลบช่อง rescue ZeroDivisionError ออก แล้วดูว่า "0" ไปตกที่ไหน (คำใบ้: หัวข้อ 4)

แบบทดสอบ

โค้ดนี้พิมพ์อะไรออกมา? (; ใช้แทนการขึ้นบรรทัดใหม่)
begin; puts 900 / 0; rescue ArgumentError; puts "รับไว้"; end; puts "ไปต่อ"

ตัวเลือกที่ 3 ถูก — ตรงกับผลรันของ only_one.rb ในหัวข้อ 4 · ช่อง rescue รอแค่ ArgumentError แต่สิ่งที่เกิดคือ ZeroDivisionError จึงวิ่งผ่านไป แล้วไม่มีใครรับต่อ

ตัวเลือกที่ 1 ผิดเพราะ rescue ดูชื่อชนิด ไม่ใช่รับทุกเรื่อง · ตัวเลือกที่ 2 ผิดเพราะ Ruby ไม่เคยข้าม error เงียบ ๆ — ไม่มีคนรับคือพัง · ตัวเลือกที่ 4 ผิดเพราะ Ruby ไม่ตรวจล่วงหน้าว่าชนิดตรงไหม รันได้ปกติจนถึงตอนพัง

จุดที่ต้องจำ: rescue ระบุชนิดไหน ก็รับแค่ชนิดนั้น (และลูกหลานของมัน) — นอกนั้นลอยผ่านไป

โค้ดในช่อง ensure ถูกรันเมื่อไหร่?

ตัวเลือกที่ 1 ถูก — ใน predict.rb โค้ดพังกลางทาง ก็ยังได้ "5. ปิดเตา" · และใน rescue2.rb รอบ "3" ที่ไม่พังก็ได้ "(ปิดบิล)" เหมือนกัน

ตัวเลือกที่ 2 กับ 3 แต่ละข้อถูกแค่ครึ่งเดียว · ตัวเลือกที่ 4 ผิดเพราะ ensure ไม่สนว่ามีคนรับหรือไม่ — มันทำเสมอ (แม้ error จะลอยต่อขึ้นไป ensure ก็ทำก่อนออก)

จุดที่ต้องจำ: ensure ≈ defer ของ Go — "ออกทางไหนก็ต้องผ่านตรงนี้" · ใช้กับงานเก็บกวาด

มี def show(x); puts x.title; end · ส่งอะไรเข้าไปแล้วไม่พัง?

ตัวเลือกที่ 4 ถูก — ใน duck.rb ทั้ง Post และ Menu ใช้ได้ ทั้งที่ไม่เกี่ยวกันเลย · นี่คือ duck typing

ตัวเลือกที่ 1 ผิดเพราะ Ruby ไม่ดูชื่อ class · ตัวเลือกที่ 2 ผิดเพราะ Ruby ไม่มี interface ให้ประกาศ · ตัวเลือกที่ 3 ผิดครึ่งหนึ่ง — ไม่ตรวจก่อนรันก็จริง แต่พังตอนรัน ถ้าส่ง 42 เข้าไป (NoMethodError)

จุดที่ต้องจำ: Ruby ถามแค่ "ทำได้ไหม" และถามตอนรันถึงบรรทัดนั้นเท่านั้น

เห็น undefined method 'title' for nil (NoMethodError) — ควรคิดอะไรเป็นอย่างแรก?

ตัวเลือกที่ 2 ถูก — for nil บอกว่าของที่อยู่หน้าจุดคือ nil · ต้นเหตุมักอยู่บรรทัดก่อนหน้า เช่น posts[2] ที่หาไม่เจอใน nil_title.rb

ตัวเลือกที่ 1 ผิดเพราะปัญหาไม่ได้อยู่ที่ class — ถ้าตัวแปรมีของจริง title อาจทำงานได้ปกติ · ตัวเลือกที่ 3 ผิดเพราะไม่เกี่ยวกับรุ่น · ตัวเลือกที่ 4 แค่ซ่อนอาการ — nil ยังอยู่ และจะไปโผล่ที่อื่น

จุดที่ต้องจำ: อ่าน error ส่วน for ... ให้ออก แล้วแก้ที่ต้นเหตุ ไม่ใช่ที่บรรทัดที่พัง

ถ้าเปิดสไลด์ดูประกอบ

หน้าสไลด์เขียนของจริง (ลองแล้ว)ต้องสนใจไหม
30ใต้โค้ด Go: "ลืมไม่ได้ compiler เตือน" compiler เตือนเฉพาะตอนรับ err มาแล้วไม่ใช้ (declared and not used: err) · ถ้าทิ้งด้วย _ build ผ่าน และได้ คนละ 0 (หัวข้อ 7) นิดหน่อย — Go ก็ลืมได้ถ้าตั้งใจทิ้ง
30do_thing · handle(e) · cleanup เป็นชื่อสมมุติ รันตรง ๆ ไม่ได้ · บทนี้ใช้ตัวอย่างแบ่งบิลแทนไม่ต้อง
30rescue_from ActiveRecord::RecordNotFound, with: :render_404 ถึงไม่เขียน rescue_from Rails ก็ตอบ 404 ให้ error ชนิดนี้อยู่แล้ว (ลองแล้ว) · rescue_from ใช้เมื่ออยากเลือกหน้าตาคำตอบเอง ยังไม่ต้อง — Day 2
31render partial: "post", object: anything_that_has_title partial = ชิ้นส่วนของหน้าเว็บ (Day 3) · ใจความคือ view ใช้ได้กับทุก object ที่มี .title = duck typing ยังไม่ต้อง
32"blogPost.rb จะทำให้ Rails หาไฟล์ไม่เจอ" Rails 8.1.4 โหลด blogPost.rb ได้ · ที่หาไม่เจอคือ blogpost.rb · ข้อสรุป "ใช้ blog_post.rb" ยังถูก ใช้ snake_case ไว้ แล้วไม่ต้องจำข้อยกเว้น

ติดตรงไหน หรือรันแล้วได้ผลไม่เหมือนในบท — ถามกลับมาได้เลย พร้อมแปะข้อความ error ทั้งก้อนที่ได้

อ้างอิง