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

Class และ Module — แม่พิมพ์ของข้อมูล และกล่องเครื่องมือที่แชร์กัน

~24 นาที · ควรผ่าน บทเรียน 94 (nil) มาก่อน

บทก่อน ๆ เราจำลอง "บทความ" ด้วย Hash มาตลอด แต่ในโค้ด Rails จะเจอคำว่า Post ตัวใหญ่โผล่มาเฉย ๆ
บทนี้ตอบคำถามนั้นตรง ๆ: Post คืออะไร และมาจากไหน
อ่านจบแล้วคุณจะเขียน class Post ของตัวเองได้ · รู้ว่ามันคือ struct + method ของ Go ในอีกหน้าตาหนึ่ง · และเข้าใจว่าทำไม Post ใน Rails ถึงว่างเปล่าแต่ยังเรียก post.title ได้

🔁 ทวนก่อนเริ่ม — คำจากบทก่อนที่บทนี้ใช้

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

1 · ปัญหา: บทความหลายชิ้น หน้าตาต้องเหมือนกัน

ในบท 92 เราเก็บบทความหนึ่งชิ้นไว้ใน Hash ทำนองนี้ (จำลองบทความ ยังไม่ใช่ Rails):

post = { title: "ต้มยำกุ้ง", body: "วิธีทำ..." }

ใช้ได้ แต่ไม่มีใครบังคับว่าบทความทุกชิ้นต้องมีช่อง title · ถ้าเพื่อนพิมพ์ titel: ผิด ก็ได้บทความหน้าตาเพี้ยนมาหนึ่งชิ้น · และงานที่บทความ "ทำได้" (เช่น ทำคำทักทาย) ก็ต้องเขียนกระจายไว้ที่อื่น

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

🍮 เทียบให้เห็นภาพ

class เหมือนแม่พิมพ์ขนมไข่ — แม่พิมพ์มีอันเดียว กำหนดรูปร่างไว้แล้ว
object คือขนมแต่ละชิ้นที่หยอดแป้งออกมาจากแม่พิมพ์ · ทุกชิ้นรูปร่างเหมือนกัน แต่แต่ละชิ้นมีของของตัวเอง ชิ้นหนึ่งโรยงา อีกชิ้นโรยลูกเกด
Post คือแม่พิมพ์ · บทความ "ต้มยำกุ้ง" กับบทความ "ผัดกะเพรา" คือขนมสองชิ้น

ที่ไม่เหมือนกัน: ขนมอบเสร็จแล้วแก้ไม่ได้ แต่ object แก้ข้อมูลข้างในทีหลังได้เรื่อย ๆ · และแม่พิมพ์ขนมทำได้แค่กำหนดรูปร่าง ส่วน class ยังเขียนไว้ด้วยว่า object "ทำอะไรได้บ้าง" (method)

ถ้าเทียบกับ Go — type Post struct { ... } ก็คือแม่พิมพ์เหมือนกัน และ Post{Title: "Hi"} คือขนมหนึ่งชิ้น · แนวคิดเดียวกันเป๊ะ ต่างแค่วิธีเขียน

🏷️ ศัพท์ที่เอาไปคุยต่อได้
class
แบบที่บอกว่าข้อมูลชนิดหนึ่งมีอะไรและทำอะไรได้ · ชื่อขึ้นต้นด้วยตัวใหญ่เสมอ เช่น Post · ถาม AI ได้ว่า "class Post ใน Ruby เทียบกับ struct ของ Go ตรงไหนบ้าง"
object / instance
ของหนึ่งชิ้นที่สร้างจาก class · สองคำนี้ใช้แทนกันได้ ("post เป็น instance ของ Post") · ถาม senior ได้ว่า "ตัวแปร post ตรงนี้เป็น instance ของ class ไหนครับ"

2 · เขียน class Post ตัวแรก — เทียบ Go ทีละบรรทัด

มาเขียนแม่พิมพ์บทความกัน · ซ้ายคือ Go ที่คุณเคยเขียน · ขวาคือ Ruby ที่ทำเรื่องเดียวกัน (ทั้งสองฝั่งรันจริงแล้ว):

Go

type Post struct {
	Title string
}

func NewPost(title string) Post {
	return Post{Title: title}
}

post := NewPost("Hi")

Ruby

# post.rb
class Post
  def initialize(title)
    @title = title
  end
end

post = Post.new("Hi")
p post

ทวนฝั่ง Go ก่อนครึ่งประโยค: Go ไม่มี method สร้าง object แบบพิเศษ (เขียน Post{Title: "Hi"} ตรง ๆ ก็ได้) · NewPost เป็นแค่ func ธรรมดาที่เราตั้งชื่อขึ้นต้นด้วย New ตามธรรมเนียม · ส่วน Ruby มีที่ทางให้ตายตัว คือ method ชื่อ initialize เท่านั้น

อ่านฝั่ง Ruby ทีละบรรทัด:

Rubyแปลว่าใน Go คือ
class Post … end เริ่มเขียนแม่พิมพ์ชื่อ Post · ทุกอย่างจนถึง end ตัวสุดท้ายเป็นของ class นี้ type Post struct
def initialize(title) method ชื่อพิเศษ รันอัตโนมัติทุกครั้งที่สร้างบทความใหม่ · รับชื่อเรื่องเข้ามาหนึ่งตัว func NewPost(title string)
@title = title เก็บชื่อเรื่องไว้ ติดกับบทความชิ้นนี้ · @ ข้างหน้าแปลว่า "ของ object ตัวนี้" ไม่ใช่ตัวแปรชั่วคราว Post{Title: title}
Post.new("Hi") สั่งแม่พิมพ์ให้หยอดขนมหนึ่งชิ้น · new จะไปเรียก initialize("Hi") ให้เอง เราไม่ได้เรียก initialize ตรง ๆ NewPost("Hi")
p post พิมพ์ object ออกมาดูแบบละเอียด (สำหรับ debug) fmt.Printf("%+v", post)

ผลรันจริง:

$ ruby post.rb
#<Post:0x0000000121f0beb0 @title="Hi">

อ่านว่า "นี่คือ object ของ class Post · ข้างในมี @title เป็น "Hi"" · ตัวเลข 0x0000… คือที่อยู่ใน memory ของคุณจะไม่ตรงกับนี้ และเปลี่ยนทุกครั้งที่รัน เป็นเรื่องปกติ

ทีนี้ลองหยอดขนมสองชิ้นจากแม่พิมพ์เดียว · แต่ละชิ้นมี @title เป็นของตัวเอง ไม่ปนกัน:

class Post ← แม่พิมพ์ (มีอันเดียว) │ ├─ Post.new("ต้มยำกุ้ง") ──▶ object a @title = "ต้มยำกุ้ง" │ └─ Post.new("ผัดกะเพรา") ──▶ object b @title = "ผัดกะเพรา"
🏷️ ศัพท์ที่เอาไปคุยต่อได้
initialize / constructor
method ที่ Post.new(...) เรียกให้อัตโนมัติตอนสร้าง object · ภาษาอื่นเรียกว่า constructor · ถาม senior ได้ว่า "ค่าเริ่มต้นของบทความควรตั้งใน initialize เลยไหมครับ"
instance variable (@title)
ตัวแปรที่ขึ้นต้นด้วย @ · ติดอยู่กับ object แต่ละตัว อยู่ได้ตลอดอายุของ object · พิมพ์บอก AI ได้ว่า "อธิบาย instance variable ใน Ruby เทียบกับ field ใน struct ของ Go"

3 · post.title — เอาข้อมูลออกมาดู

ใน Go ถ้าชื่อ field ขึ้นต้นตัวใหญ่ (Title) คนข้างนอกก็อ่าน post.Title ได้เลย · Ruby ไม่ได้ทำแบบนั้น — ลองทายดู:

# post2.rb — class เดิมจากหัวข้อ 2 ทุกตัวอักษร
class Post
  def initialize(title)
    @title = title
  end
end

post = Post.new("Hi")
puts post.title

ใน object มี @title = "Hi" อยู่แล้ว · บรรทัดสุดท้ายจะพิมพ์อะไร?

$ ruby post2.rb
post2.rb:9:in '<main>': undefined method 'title' for an instance of Post (NoMethodError)

puts post.title
         ^^^^^^

พัง · error แปลว่า "object ของ Post ไม่มี method ชื่อ title" · ใน Ruby post.title ไม่ใช่การหยิบ field แต่เป็นการเรียก method ชื่อ title · ข้อมูล @title อยู่ข้างใน object จริง แต่คนข้างนอกแตะตรง ๆ ไม่ได้ ต้องมี method ไว้ให้เรียก

ทางแก้ตรงไปตรงมา คือเขียน method ที่ส่ง @title ออกมาเอง:

class Post
  def initialize(title)
    @title = title
  end

  def title            # อ่านค่า — คืน @title ออกไป (return อัตโนมัติ)
    @title
  end

  def title=(value)   # แก้ค่า — ชื่อ method คือ "title=" มีเครื่องหมาย = ติดท้ายจริง ๆ
    @title = value
  end
end

พอมีสอง method นี้ post.title ก็อ่านได้ · ส่วน post.title = "ใหม่" ที่ดูเหมือนการเอาค่าไปใส่ จริง ๆ คือการเรียก method ชื่อ title= แล้วส่ง "ใหม่" เข้าไป · Ruby ยอมให้เว้นวรรคระหว่าง title กับ = ได้ เพื่อให้อ่านเหมือนการตั้งค่าตัวแปร

แต่ต้องเขียน 6 บรรทัดต่อข้อมูลหนึ่งช่อง ซึ่งน่าเบื่อมาก · Ruby เลยมีทางลัด บรรทัดเดียว:

# post3.rb
class Post
  attr_accessor :title     # สร้าง def title กับ def title= ให้เอง

  def initialize(title)
    @title = title
  end
end

post = Post.new("Hi")
puts post.title
post.title = "Hello Ruby"
puts post.title
$ ruby post3.rb
Hi
Hello Ruby

ถ้าอยากให้คนนอก อ่านได้อย่างเดียว ให้ใช้ attr_reader :title แทน (สร้างแค่ title) · แล้วถ้าใครสั่งแก้ค่า จะพังแบบนี้:

# reader.rb
class Post
  attr_reader :title     # อ่านได้อย่างเดียว

  def initialize(title)
    @title = title
  end
end

post = Post.new("Hi")
puts post.title               # อ่าน — ทำได้
post.title = "Hello Ruby"     # แก้ — พังตรงนี้
$ ruby reader.rb
Hi
reader.rb:12:in '<main>': undefined method 'title=' for an instance of Post (NoMethodError)

บรรทัด Hi คือการอ่านที่ทำได้ · ส่วน error บอกว่าหา method ชื่อ 'title=' ไม่เจอ เพราะ attr_reader สร้างให้แค่ title ตัวเดียว

🍱 เทียบให้เห็นภาพ

object เหมือนกล่องข้าวที่ปิดฝาไว้ · @title คือกับข้าวที่อยู่ในกล่อง คนข้างนอกล้วงเองไม่ได้
attr_reader = เปลี่ยนเป็นฝาใส มองเห็นว่ามีอะไร แต่เปลี่ยนกับข้าวไม่ได้
attr_accessor = ฝาที่เปิดได้ ทั้งดูและเปลี่ยนกับข้าวได้

ที่ไม่เหมือนกัน: กล่องข้าวจริงปิดฝาแล้ว ใครแอบเปิดก็ได้ · ส่วนใน Ruby ไม่มี method ก็คือเรียกไม่ได้ ได้ NoMethodError ทันที · และฝาใสมีฝาเดียวต่อกล่อง แต่ attr_reader เลือกได้ทีละช่องข้อมูล (title ฝาใส · body ฝาเปิดได้)

🏷️ ศัพท์ที่เอาไปคุยต่อได้
getter / setter
method ที่อ่านค่า (getter เช่น title) และ method ที่ตั้งค่า (setter เช่น title=) · พิมพ์บอก AI ได้ว่า "ทำไม error บอกว่าไม่มี method title= ผมแค่จะตั้งค่าเอง"
attr_accessor / attr_reader / attr_writer
ทางลัดสร้าง getter+setter / getter อย่างเดียว / setter อย่างเดียว · ถาม senior ได้ว่า "title ควรเป็น attr_reader ไหมครับ ไม่อยากให้ข้างนอกแก้ได้"

4 · ให้บทความ "ทำอะไรได้" — method ใน class

นอกจากเก็บข้อมูล class ยังเขียนได้ว่า object ทำอะไรได้บ้าง · ตัวอย่างจากสไลด์หน้า 28 คือ method greeting ที่คืนคำทักทาย · ฝั่ง Go คือ method ที่มี receiver (p Post) (ทวน: (p Post) หน้าชื่อ func แปลว่า "func นี้เป็นของ Post · ข้างในเรียกบทความชิ้นที่ถูกเรียกว่า p"):

Go

func (p Post) Greeting() string {
	return "Hello, " + p.Title
}

Ruby (เขียนไว้ใน class Post)

def greeting
  "Hello, #{@title}"
end

เติมลงใน class Post แล้วเรียก puts Post.new("Hi").greeting ได้ Hello, Hi (ฝั่ง Go ก็ได้ Hello, Hi เหมือนกัน)

🏷️ ศัพท์ที่เอาไปคุยต่อได้
instance method
method ที่เขียนใน class แล้วเรียกผ่าน object แต่ละชิ้น เช่น post.greeting · ผลขึ้นกับข้อมูลของชิ้นนั้น · ถาม senior ได้ว่า "logic ทำคำทักทายนี้ควรเป็น instance method ของ Post ไหมครับ"

⚠️ พิมพ์ชื่อ @ตัวแปรผิด Ruby ไม่เตือน — ถ้าเผลอเขียน "Hello, #{@titel}" จะไม่มี error ไม่มี warning (แม้รันด้วย ruby -w ซึ่งสั่งให้ Ruby เตือนเรื่องน่าสงสัย ก็ยังเงียบ) เพราะ @ตัวแปรที่ยังไม่เคยตั้งค่าจะได้ nil เฉย ๆ:

$ ruby typo.rb      # greeting เขียน @titel แทน @title · บรรทัดท้าย p post.greeting
"Hello, "

Go จะฟ้องตั้งแต่ compile ว่าไม่มี field Titel · ใน Ruby ถ้าผลออกมาว่างแบบแปลก ๆ ให้สงสัยตัวสะกดของ @ตัวแปรก่อน

5 · สืบทอดด้วย < — class ลูกที่ต่อยอดจาก class แม่

แอปบล็อกของเราอยากมีบทความชนิดพิเศษ: สูตรอาหาร ที่มีทุกอย่างเหมือนบทความ (ชื่อเรื่อง · คำทักทาย) แต่มี "เวลาที่ใช้ทำ" เพิ่มมาหนึ่งช่อง · แทนที่จะก็อป Post มาทั้งก้อน Ruby ให้เขียนว่า "Recipe เป็นลูกของ Post" ด้วยเครื่องหมาย <

👩‍🍳 เทียบให้เห็นภาพ

เหมือนสูตรขนมที่ต่อยอดจากสูตรแม่ — สูตรแม่คือขนมไข่ธรรมดา · สูตรลูกเขียนว่า "ทำตามสูตรแม่ทุกขั้น แล้วเพิ่มไส้สังขยา" · ไม่ต้องคัดลอกขั้นตอนของแม่ลงมาใหม่ แค่เขียนส่วนที่เพิ่ม

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

# inherit.rb — ข้างบนคือ class Post ที่มี attr_accessor :title, initialize และ greeting (หัวข้อ 3–4)
class Recipe < Post
  attr_accessor :minutes

  def initialize(title, minutes)
    super(title)          # ให้ initialize ของ Post ตั้ง @title ให้ก่อน
    @minutes = minutes    # แล้วค่อยเก็บส่วนที่ Recipe เพิ่มเอง
  end
end

r = Recipe.new("ต้มยำกุ้ง", 15)
puts r.greeting
puts r.title
puts r.minutes
p r.is_a?(Post)
$ ruby inherit.rb
Hello, ต้มยำกุ้ง
ต้มยำกุ้ง
15
true

Go ไม่มีการสืบทอดแบบนี้ (สไลด์หน้า 28 ก็เขียนไว้) · Go ใช้การฝัง struct ไว้ข้างใน (embedding) แทน ซึ่งจะเห็นในหัวข้อ 7

🏷️ ศัพท์ที่เอาไปคุยต่อได้
inheritance (การสืบทอด)
ให้ class หนึ่งได้ทุกอย่างของอีก class ไปใช้ · class แม่เรียก superclass · class ลูกเรียก subclass · ถาม senior ได้ว่า "Recipe ควรเป็น subclass ของ Post ไหมครับ หรือแยกเป็นคนละ class ดีกว่า"
super
เรียก method ชื่อเดียวกันของแม่ · ใช้บ่อยใน initialize ของลูก · พิมพ์บอก AI ได้ว่า "ใน initialize ของ class ลูก ควรเรียก super แบบไหน ถึงไม่ส่ง argument เกิน"

6 · สะพานไป Rails — Post ในโปรเจกต์จริงมาจากไหน

ถึงตรงนี้ตอบคำถามต้นบทได้แล้ว · ในโปรเจกต์ Rails จะมีไฟล์ app/models/post.rb ที่หน้าตาแบบนี้ (Rails สร้างไฟล์นี้ให้ด้วยคำสั่ง bin/rails generate — เจอในบท 99):

# app/models/post.rb — ทั้งไฟล์มีแค่นี้จริง ๆ
class Post < ApplicationRecord
end

อ่านออกแล้วใช่ไหม — Post คือ class ธรรมดา ที่เป็นลูกของ ApplicationRecord · แต่แปลกตรงที่ว่างเปล่า ไม่มี attr_accessor ไม่มี initialize · แล้ว post.title มาจากไหน?

คำตอบ: Rails เปิดดูตาราง posts ในฐานข้อมูลตอนรัน แล้วสร้าง method ตามชื่อคอลัมน์ให้เอง (title · title= · body · …)

สังเกตชื่อสองแบบ: Post (ตัวใหญ่ เอกพจน์) คือแม่พิมพ์ของบทความหนึ่งชิ้น · posts (ตัวเล็ก พหูพจน์) คือตารางที่เก็บบทความทุกชิ้น (ตาราง = ที่เก็บข้อมูลเป็นแถว ๆ ในฐานข้อมูล แบบที่เคยใช้กับ GORM) · Rails จับคู่สองชื่อนี้ให้เองตามกฎตั้งชื่อ — ละเอียดในบท 99

ผมสร้างตาราง posts ที่มีคอลัมน์ title กับ body ไว้ก่อน (ด้วย bin/rails generate model Post title:string body:text แล้ว bin/rails db:prepare — เรื่องของ Day 2 ตอนนี้แค่ดู) · แล้วรันสคริปต์นี้ในโปรเจกต์:

# try.rb — วางไว้ในโฟลเดอร์โปรเจกต์ wongnok_blog
p Post.column_names

post = Post.new(title: "Hi", body: "My first post")
puts post.title
puts post.body
p post.respond_to?(:title)
p post.respond_to?(:author)
$ bin/rails runner try.rb
["id", "title", "body", "created_at", "updated_at"]
Hi
My first post
true
false
🍽️ เทียบกับร้านอาหาร (ภาพเดียวกันทั้งคอร์ส)

model Post คือห้องเก็บวัตถุดิบ + คนดูแลสต็อก · ฐานข้อมูลทั้งก้อนคือตู้เก็บแฟ้ม · ตาราง posts คือลิ้นชักบทความ · บทความหนึ่งแถวคือแฟ้มหนึ่งแผ่น
คนดูแลสต็อกของ Rails เดินไปเปิดลิ้นชักดูเองว่าแฟ้มมีช่องอะไรบ้าง (title · body) แล้วก็พร้อมหยิบช่องนั้นให้ทันที
ส่วน Go ที่เคยเรียน = เปิดร้านเองตั้งแต่ศูนย์ ต้องจดรายการช่องของแฟ้มเองทุกช่องใน struct · Rails = ร้านแฟรนไชส์ที่ได้ครัวครบชุด ขอแค่ตั้งชื่อลิ้นชักตามที่แฟรนไชส์กำหนด (Post ↔ posts)

ที่ไม่เหมือนกัน: คนดูแลสต็อกจริงเห็นลิ้นชักแล้วจำได้ตลอด · ส่วน Rails ไม่ได้เขียนชื่อช่องลงไฟล์ post.rb ให้เราเห็นเลย · เปิดไฟล์ดูก็ว่างเปล่า ต้องไปดูที่ตาราง (หรือ Post.column_names) ถึงรู้ว่ามีช่องอะไร

เทียบกับ Go + GORM ที่เคยเรียนในบท 27 ให้เห็นชัด ๆ (ตัดมาเฉพาะส่วน struct):

Go + GORM — เขียนทุกช่องเอง

type Post struct {
	ID    uint
	Title string
	Body  string
}

ลืมเขียน Body = ใช้ post.Body ไม่ได้ แม้ตารางจะมีคอลัมน์นี้

Rails — ไม่เขียนสักช่อง

class Post < ApplicationRecord
end

ตารางมีคอลัมน์ไหน post.ชื่อคอลัมน์ ก็ใช้ได้เลย

แล้ว ApplicationRecord เป็นใคร? เป็น class ที่ rails new สร้างไว้ให้ และเป็นลูกของ ActiveRecord::Base ซึ่งเป็นตัวที่มีความสามารถคุยกับฐานข้อมูลทั้งหมด (:: แปลว่า "ที่อยู่ในกล่องชื่อ ActiveRecord"):

$ bin/rails runner 'p Post.superclass; p ApplicationRecord.superclass'
ApplicationRecord(abstract)
ActiveRecord::Base

.superclass คือถามว่า "แม่ของ class นี้คือใคร" · คำว่า (abstract) ท้าย ApplicationRecord แปลว่า class นี้มีไว้เป็นแม่ให้ model อื่นสืบทอดเท่านั้น ไม่ได้ผูกกับตารางของตัวเอง

นี่คือการสืบทอดจากหัวข้อ 5 ล้วน ๆ: Post → แม่คือ ApplicationRecord → แม่คือ ActiveRecord::Base · การบันทึกลงฐานข้อมูล การค้นหา (เช่น Post.where(...) = หาบทความตามเงื่อนไข) ทั้งหมดได้มาจากแม่ — Day 2 จะสอนเรื่องนี้เต็ม ๆ

🏷️ ศัพท์ที่เอาไปคุยต่อได้
model
class ที่แทนข้อมูลหนึ่งชนิดในฐานข้อมูล เช่น Post แทนตาราง posts · อยู่ใน app/models/ · ถาม senior ได้ว่า "model Post นี้ผูกกับตารางไหนครับ มีคอลัมน์อะไรบ้าง"
attribute (มาจาก column)
ช่องข้อมูลของ model ที่ Rails สร้าง method ให้จากชื่อคอลัมน์ในตาราง · พิมพ์บอก AI ได้ว่า "model ของผมว่างเปล่า แต่เรียก post.title ได้ อธิบายว่า Rails สร้าง attribute จาก column ยังไง"
ApplicationRecord
class แม่ของทุก model ในแอป · เป็นลูกของ ActiveRecord::Base อีกทอด · ถาม senior ได้ว่า "ของที่ทุก model ต้องใช้ ควรใส่ไว้ใน ApplicationRecord ไหมครับ"

7 · Module + include — กล่องเครื่องมือที่หลาย class ยืมไปใช้

สมมติอยากให้บทความมี slug — ชื่อเรื่องที่แปลงให้ใส่ใน URL ได้ เช่น "Hello Ruby World" → hello-ruby-world (เว็บจะได้มี URL แบบ /posts/hello-ruby-world ที่คนอ่านออก) · แล้วอยากให้ หมวดหมู่ (Category) ทำ slug ได้ด้วย

ใช้การสืบทอดไม่ได้ เพราะหมวดหมู่ไม่ได้ "เป็น" บทความ · Ruby เลยมี module: ก้อน method ที่สร้าง object เองไม่ได้ แต่ class ไหนก็ include ไปใช้ได้

🧰 เทียบให้เห็นภาพ

module เหมือนชุดเครื่องมือช่างกลางของหมู่บ้าน · ใครก็ยืมไปใช้ได้ บ้านไหนจะยืมก็ไม่ต้องเป็นญาติกัน · include Sluggable = "บ้านนี้ยืมชุดเครื่องมือทำ slug มาใช้"

ที่ไม่เหมือนกัน: เครื่องมือช่างจริงทำงานได้ด้วยตัวเอง · แต่ method ใน module ใช้ ข้อมูลของบ้านที่ยืมไป — slug ต้องการให้ class นั้นมี title · ถ้าไม่มี Ruby ก็ไม่ตรวจตอนยืม แต่จะพังตอนเรียกใช้ครั้งแรก · และยืมแล้วไม่ต้องคืน ทุกบ้านใช้ได้พร้อมกัน

นี่คือโค้ดจากสไลด์หน้า 29:

module Sluggable
  def slug
    title.downcase.tr(" ", "-")
  end
end

class Post
  include Sluggable
end

Post.new.slug      # ใช้ได้เลย

⚠️ พิมพ์ตามสไลด์แล้วพัง — Post ในสไลด์ไม่มี title ให้ slug เรียก:

$ ruby slide29.rb
slide29.rb:3:in 'Sluggable#slug': undefined local variable or method 'title' for an instance of Post (NameError)

    title.downcase.tr(" ", "-")
    ^^^^^
	from slide29.rb:11:in '<main>'

แก้โดยเติม attr_accessor :title กับ initialize ให้ Post (ตัวอย่างข้างล่าง)

ฉบับที่รันได้จริง — สอง class ที่ไม่เกี่ยวกัน ยืม Sluggable ตัวเดียวกัน:

# sluggable.rb
module Sluggable
  def slug
    title.downcase.tr(" ", "-")
  end
end

class Post
  include Sluggable
  attr_accessor :title

  def initialize(title)
    @title = title
  end
end

class Category
  include Sluggable
  attr_accessor :title

  def initialize(title)
    @title = title
  end
end

puts Post.new("Hello Ruby World").slug
puts Category.new("Thai Food").slug
$ ruby sluggable.rb
hello-ruby-world
thai-food

title ใน slug คือ title ของ object ที่เรียกมัน — ตอนเรียกจาก Post ก็เป็นชื่อบทความ · ตอนเรียกจาก Category ก็เป็นชื่อหมวด · และ module หยอดขนมเองไม่ได้ ถ้าสั่ง Sluggable.new จะได้ undefined method 'new' for module Sluggable (NoMethodError)

ฝั่ง Go ที่ใกล้ที่สุดคือ ฝัง struct (embedding) ซึ่งก็ได้ method มาใช้ฟรีเหมือนกัน (รันจริงได้ hello-go-world):

// main.go
package main

import (
	"fmt"
	"strings"
)

type Sluggable struct {
	Title string
}

func (s Sluggable) Slug() string {
	return strings.ReplaceAll(strings.ToLower(s.Title), " ", "-")
}

type Post struct {
	Sluggable // ฝัง struct ไว้ข้างใน → ได้ Slug() มาใช้ด้วย
}

func main() {
	post := Post{Sluggable{Title: "Hello Go World"}}
	fmt.Println(post.Slug())
}

ต่างกันตรงที่ Go ต้องย้าย Title ไปอยู่ใน Sluggable เพราะ Slug() มองเห็นแค่ข้อมูลของตัวมันเอง · ส่วน module ของ Ruby ใช้ title ของ class ที่ include มันได้เลย · และ ไม่เหมือน interface (บท 10) ที่บอกแค่ว่า "ต้องมี method นี้" แล้วให้เราเขียนเอง — module ให้โค้ดที่ทำงานได้มาด้วย

🏷️ ศัพท์ที่เอาไปคุยต่อได้
module
ก้อน method ที่สร้าง object เองไม่ได้ เอาไว้ให้หลาย class ใช้ร่วมกัน · ถาม senior ได้ว่า "method slug นี้ควรแยกเป็น module ไหมครับ มีหลาย class ใช้"
mixin / include
การเอา method ของ module ไปใส่ใน class · class นั้นได้ method ไปใช้ทันที · พิมพ์บอก AI ได้ว่า "แยก method slug ออกเป็น module แล้ว include ใน Post กับ Category ให้หน่อย"

8 · Concern — ที่เก็บ module ของ Rails (แค่ปู)

สไลด์หน้า 29 ปิดท้ายว่า Rails เรียก module แบบนี้ว่า concern และมีโฟลเดอร์ให้เก็บ · เปิดโปรเจกต์ที่เพิ่ง rails new ก็เห็นโฟลเดอร์รออยู่แล้ว (.keep คือไฟล์ว่างที่ใส่ไว้ให้ git เก็บโฟลเดอร์เปล่าได้):

$ find app/models/concerns app/controllers/concerns
app/models/concerns
app/models/concerns/.keep
app/controllers/concerns
app/controllers/concerns/.keep

ลองย้าย Sluggable ไปไว้ที่ app/models/concerns/sluggable.rb (เนื้อในเหมือนเดิมทุกตัวอักษร) แล้วเติม include Sluggable หนึ่งบรรทัดใน class Post < ApplicationRecord:

$ bin/rails runner 'puts Post.new(title: "Hello Rails World").slug'
hello-rails-world

ไม่ต้องเขียน title เอง เพราะ Rails สร้างจากคอลัมน์ให้แล้ว (หัวข้อ 6) · และไม่ต้อง import ไฟล์ sluggable.rb เพราะ Rails หาไฟล์ตามชื่อเอง (บท 99) · concern ของจริงมักมีบรรทัด extend ActiveSupport::Concern ด้วย — ไว้เจอทีหลัง ตอนนี้รู้แค่ว่า concern = module ที่วางไว้ในโฟลเดอร์ concerns

🍽️ เทียบกับร้านอาหาร

Rails เป็นร้านแฟรนไชส์ที่ทุกสาขาวางของตำแหน่งเดียวกัน · โฟลเดอร์ concerns คือชั้นวางเครื่องมือกลางของครัวที่แฟรนไชส์กำหนดไว้แล้ว · ห้องไหนในร้านอยากใช้เครื่องทำ slug (model ใช้ชั้นใน app/models/concerns · controller ใช้ชั้นใน app/controllers/concerns) ก็หยิบจากชั้นนี้ (include Sluggable) ไม่ต้องตามหาว่าใครเก็บไว้ที่ไหน

ที่ไม่เหมือนกัน: ชั้นวางจริงหยิบเครื่องมือไปแล้วคนอื่นต้องรอ · แต่ module ถูก include ได้พร้อมกันหลาย model ไม่มีใครต้องรอ · และของบนชั้นจริงหยิบได้ด้วยมือ ส่วน Rails หาไฟล์ให้จากชื่อ — ตั้งชื่อไฟล์ sluggable.rb ให้ตรงกับ Sluggable ถึงจะหาเจอ

🏷️ ศัพท์ที่เอาไปคุยต่อได้
concern
module ที่ Rails แนะนำให้วางใน app/models/concerns/ หรือ app/controllers/concerns/ เพื่อแชร์ method ระหว่าง model หรือ controller · ถาม senior ได้ว่า "method slug ที่ Post กับ Category ใช้ร่วมกัน ควรแยกเป็น concern ไหมครับ"

9 · ภารกิจ — บทความ สูตรอาหาร และ slug

🎯 ภารกิจ: เขียน blog.rb ไฟล์เดียว (~10 นาที)

  1. สร้างไฟล์ blog.rb ที่ไหนก็ได้ (ไม่ต้องใช้ Rails)
  2. เขียน module Sluggable ที่มี method slug แบบหัวข้อ 7
  3. เขียน class Post ที่ include Sluggable · มี attr_accessor :title · initialize(title) · และ greeting
  4. เขียน class Recipe < Post ที่รับ title กับ minutes (ใช้ super(title))
  5. ท้ายไฟล์สร้าง Recipe.new("Tom Yum Goong", 15) แล้ว puts ค่า greeting · slug · minutes
  6. รัน ruby blog.rb · ควรเห็นสามบรรทัด: Hello, Tom Yum Goong · tom-yum-goong · 15
# blog.rb
module Sluggable
  def slug
    title.downcase.tr(" ", "-")
  end
end

class Post
  include Sluggable
  attr_accessor :title

  def initialize(title)
    @title = title
  end

  def greeting
    "Hello, #{@title}"
  end
end

class Recipe < Post
  attr_accessor :minutes

  def initialize(title, minutes)
    super(title)
    @minutes = minutes
  end
end

recipe = Recipe.new("Tom Yum Goong", 15)
puts recipe.greeting
puts recipe.slug
puts recipe.minutes
$ ruby blog.rb
Hello, Tom Yum Goong
tom-yum-goong
15

สังเกตว่า Recipe ใช้ slug ได้ทั้งที่ไม่ได้ include เอง — เพราะแม่ (Post) include ไว้แล้ว ลูกก็ได้ไปด้วย

ถ้าเจอ error นี้ แปลว่า… (ทุกอันรันจริงจาก blog.rb ข้างบน แล้วแก้ให้ผิดทีละจุด · วิธีอ่าน: บรรทัดแรกบอกไฟล์:บรรทัดที่พัง และชื่อ error ในวงเล็บท้ายสุด · บรรทัด from ข้างล่างบอกว่าถูกเรียกมาจากไหน)

blog.rb:4:in 'Sluggable#slug': undefined local variable or method 'title' for an instance of Recipe (NameError)

    title.downcase.tr(" ", "-")
    ^^^^^
Did you mean?  @title
	from blog.rb:31:in '<main>'

→ ลืม attr_accessor :title ใน Post · slug หา method title ไม่เจอ (มีแค่ @title ข้างใน ซึ่ง Ruby ใบ้ให้ว่า "Did you mean? @title") · ก่อน error จะเห็น Hello, Tom Yum Goong พิมพ์ออกมาก่อนด้วย เพราะ greeting ใช้ @title ตรง ๆ ไม่ได้พึ่ง method title

blog.rb:9:in '<class:Post>': uninitialized constant Post::Slugable (NameError)

  include Slugable
          ^^^^^^^^
Did you mean?  Sluggable
	from blog.rb:8:in '<main>'

→ สะกดชื่อ module ผิด (ตก g ไปหนึ่งตัว) · uninitialized constant แปลว่า "ไม่รู้จักชื่อตัวใหญ่นี้"

blog.rb:12:in 'Post#initialize': wrong number of arguments (given 2, expected 1) (ArgumentError)

    caller: blog.rb:25
    callee: blog.rb:12
    |   def initialize(title)
            ^^^^^^^^^^
	from blog.rb:25:in 'Recipe#initialize'
	from blog.rb:30:in '<main>'

→ เขียน super เฉย ๆ ไม่มีวงเล็บ · Ruby ส่ง argument ทุกตัวของลูก (title และ minutes = 2 ตัว) ต่อไปให้แม่ ซึ่งรับได้ตัวเดียว · caller: blog.rb:25 / callee: blog.rb:12 บอกว่าบรรทัด 25 (ใน Recipe) เรียกไปหาบรรทัด 12 (ใน Post) · แก้เป็น super(title) · ถ้าเจอ (given 0, expected 1) แทน แปลว่าเรียก Post.new โดยไม่ได้ส่งชื่อเรื่องไป

10 · ทดสอบความเข้าใจ

เมื่อเขียน Post.new("Hi") Ruby เรียก method ไหนของ Post ให้อัตโนมัติ?

ตัวเลือกที่ 2 ถูก · new สร้าง object เปล่าแล้วส่ง "Hi" ต่อให้ initialize · ถ้าเรียก Post.new โดยไม่ส่งอะไร error จะบอกว่าพังใน 'Post#initialize' (given 0, expected 1) ซึ่งยืนยันว่า new ไปเรียก initialize

ตัวเลือกที่ 1 ผิดเพราะ new มีให้ทุก class อยู่แล้ว เราเขียนแค่ initialize · ตัวเลือกที่ 3 ผิดเพราะ title ถูกเรียกเมื่อเราสั่ง post.title เท่านั้น · ตัวเลือกที่ 4 ผิดเพราะ Ruby ไม่ได้เรียก method ตามลำดับที่เขียน

จุดที่ต้องจำ: Post.new(...) = สร้าง object แล้วรัน initialize(...) · เหมือน NewPost(...) ของ Go

class Post มีแค่ attr_reader :title · ถ้าสั่ง post.title = "ใหม่" จะเกิดอะไรขึ้น?

ตัวเลือกที่ 3 ถูก · ผลรันจริงในหัวข้อ 3 คือ undefined method 'title=' for an instance of Post (NoMethodError) · post.title = "ใหม่" คือการเรียก method ชื่อ title= ซึ่ง attr_reader ไม่ได้สร้างให้ (สร้างแค่ title)

ตัวเลือกที่ 1 ผิดเพราะ reader = อ่านอย่างเดียว · ตัวเลือกที่ 2 ผิดเพราะ Ruby ไม่ทิ้งคำสั่งเงียบ ๆ แต่พังทันที · ตัวเลือกที่ 4 ผิดเพราะโปรแกรมหยุดที่ error ไม่ได้ไปแตะ @title เลย

จุดที่ต้องจำ: เห็น error ที่มีชื่อ method ลงท้ายด้วย = แปลว่าไม่มี setter → ใช้ attr_accessor แทน

ใน Rails ไฟล์ app/models/post.rb มีแค่ class Post < ApplicationRecord กับ end · ทำไม post.title ถึงใช้ได้?

ตัวเลือกที่ 1 ถูก · ในหัวข้อ 6 Post.column_names มี "title" และ respond_to?(:title) ได้ true · แต่ respond_to?(:author) ได้ false เพราะตารางไม่มีคอลัมน์ author — method ตามคอลัมน์จริง

ตัวเลือกที่ 2 ผิดเพราะไฟล์ post.rb ว่างจริง ๆ ไม่มีอะไรถูกเขียนเพิ่ม · ตัวเลือกที่ 3 ผิดเพราะ class Post ของเราเองในหัวข้อ 3 ยัง NoMethodError อยู่เลยถ้าไม่มี attr_accessor · ตัวเลือกที่ 4 ผิดเพราะ ApplicationRecord ไม่รู้ว่าแต่ละ model มีช่องอะไร ต้องไปดูที่ตารางของ model นั้น

จุดที่ต้องจำ: Go ต้องเขียน field ใน struct เอง · Rails อ่านช่องข้อมูลจากตารางในฐานข้อมูลให้

ข้อไหนอธิบาย module ได้ถูกต้อง?

ตัวเลือกที่ 4 ถูก · ในหัวข้อ 7 Post กับ Category include Sluggable ตัวเดียวกันได้ · ส่วน Sluggable.new พังด้วย undefined method 'new' for module Sluggable

ตัวเลือกที่ 1 ผิดตามผลรันข้างบน · ตัวเลือกที่ 2 ผิดเพราะโค้ดสไลด์หน้า 29 include ผ่าน แต่ไปพังตอนเรียก slug (NameError) — Ruby ไม่ตรวจล่วงหน้า · ตัวเลือกที่ 3 ผิดเพราะ < ใช้กับ class แม่ ส่วน module ใช้ include

จุดที่ต้องจำ: สืบทอด (<) = "เป็นชนิดหนึ่งของ" · include module = "ยืมความสามารถมาใช้"

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

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

หน้าสไลด์เขียนของจริงต้องสนใจไหม
29 Post.new.slug # ใช้ได้เลย พังด้วย NameError เพราะ Post ในสไลด์ไม่มี title · ต้องเติม attr_accessor :title + initialize (หัวข้อ 7) ใช่ ถ้าพิมพ์ตาม
28 @ตัวแปรที่ยังไม่เคยตั้ง อ่านแล้วได้ nil ไม่ error ถูก · แต่แปลว่าสะกดชื่อผิดก็ไม่มีใครเตือน (หัวข้อ 4) นิดหน่อย
29 module ใกล้เคียง embedding ของ Go ใกล้ตรงที่ได้โค้ดมาฟรี · แต่ method ของ Go เห็นแค่ข้อมูลของ struct ที่ฝัง ส่วน module เห็นข้อมูลของ class ที่ include ไม่ต้อง
ทั้งชุด Ruby 4.0.6 · Rails 8.1.3 บทนี้ตรวจด้วย Ruby 4.0.9 · Rails 8.1.4 ผลเหมือนกัน ไม่ต้อง

อ้างอิง