ทำความเข้าใจ SOLID Principles ผ่านตัวอย่างจากการเขียน Game ด้วยภาษา Rust

Ruangyot Nanchiang
8 min readOct 20, 2023

--

สวัสดีชาว Developer ทุกท่านครับ เมื่อไม่นานมานี้ผมมีโอกาสได้ Review Code ครั้งแรก กับ Backend Lead ในบริษัทที่ผมทำงานอยู่ปัจจุบันครับ ซึ่งผมประทับใจกับการ Review Code กับ Backend Lead คนนี้มาก แบบมากเลยครับ

ก็คือในระหว่างที่ Review Code เขามักจะชอบยกเรื่อง SOLID Principles เข้ามาอธิบายครับว่า ทำไมพี่ถึงต้องให้น้องเขียนแบบนู้นแบบนี้ พร้อมทั้งยกตัวอย่างที่ไม่ดีให้เห็นด้วยว่า ถ้าเราฝืนเขียนแบบนี้ต่อไป ในอนาคตมันจะเป็นรูปแบบไหน พร้อมทั้งมีอธิบาย SOLID Principles แถมให้ด้วยตอนปิดท้าย

จากตอนแรกที่ผมไม่ค่อยได้เห็นความสำคัญของ SOLID Principles เท่า ถึงกับต้องกลับไปเปิดหนังสืออ่านอีกรอบเลยครับ ส่วนใครที่สนใจหนังสือเล่มที่ผมอ่านจะแปะไว้ให้ด้านล่างนะครับ สามารถหาซื้ออ่านกันได้เลย

ช่องทางการจำหน่ายหนังสือ https://refactoring.guru/design-patterns/book

ส่วนช่องทางการจำหน่าย Golang Microservices Building ก็อยู่ใน Facebook: Dancing with My Code เลยครับ 555

แล้วก็อีกอย่างนึงอย่างสุดท้ายก่อนเข้าเนื้อหา ผมจะขอใช้เป็นภาษา Rust นะครับในตัวอย่าง เนื่องจากช่วงนี้กำลังเบียว Rust ครับไม่มีเหตุผลอะไรเป็นพิเศษ

SOLID in Nutshell

มันก็คือหลักการในการที่เราจะต้องจำและลำลึกไว้เสมอในการที่จะพัฒนา Software ใดๆขึ้นมา เพื่อให้ Software ที่เราพัฒนาขึ้นมานั้นมันง่ายต่อการย้อนกลับมาดู Code ในภายหลัง, ยืดหยุ่น, และ แก้ไขเพิ่มเติมได้ง่ายในอนาคต โดยจะมีหลักการอยู่ 5 อย่างด้วยกัน เดี๋ยวผมจะพา Walkthrough ไปทีละข้อ พร้อมยกตัวอย่างจากการเขียนเกมด้วยภาษา Rust นะครับ

S — Single Responsibility Principle

A class should have just one reason to change.

ในบางที ตอนที่เราเริ่มลงมือเขียน Code แล้วพอเวลามันผ่านไปสักระยะ เคยกันไหมที่แบบว่า “เอ๋ เดี๋ยวนะ ตรงนี้เราเขียนไว้ทำไมวะ” แล้วหมิหนำซ้ำ ถ้าเราจำเป็นต้องไปแก้มันเมื่อไหร่ มันก็พร้อมจะระเบิดทุกเมื่อ เพราะไม่รู้ว่า แต่ละบรรทัดที่เคยเขียนไปนั้น มันส่งผลอะไรบ้างกันแน่

นั่นแหละครับ ทำไมหลักการ Single Responsibility ถึงสำคัญและควรคำนึงถึงตั้งแต่ตอนที่เราเริ่มลงมือเขียน Code ในช่วงแรกๆของการพัฒนา Software

โดยประโยคที่ว่า “A class should have just one reason to change.” มันหมายความว่า มันมีเหตุผลเดียวที่เราจะกลับมาแก้ไขหรือเปลี่ยนแปลง Class ใดๆ หรือจะบอกเป็นอีกนัยหนึ่งว่า Class ใดๆมันควรที่จะรับผิดชอบหน้าที่เดียวก็พอ อย่าหาไปใส่หน้าที่ที่มันแตกต่างลงไปใน Class เดียวกัน เพราะเวลาที่เราจะแก้ไขหรือเปลี่ยนแปลง Class ตัวนั้น เราก็ต้องไปแก้หลายจุดใน Class ตัวเดียว แล้วก็อาจเป็นเหตุผลที่ว่าแก้บัคตรงนี้แล้วมันดันไประเบิดที่อื่นต่อเรื่อยๆไม่รู้จักจบสิ้น

Example

จาก UML Diagram ด้านบน สมมุติว่าเรามี Class ของ Character แล้วใน Character นั้นมีหน้าที่อยู่ 2 อย่างได้แก่

  1. Load หรือ Render Graphic ของ Character
  2. มีการรองรับการเคลื่อนที่ของ Character

ถ้าอย่างตามตัวอย่างด้านบนเนี่ย ถ้าวันดีคืนดี มีฝั่ง Business เดินมาบอกว่า “น้อง คือพี่อยากให้ตัวละครในเกมเรามันมีปีก แล้วบินได้ด้วยอะ น้องช่วยแก้ให้พี่หน่อยนะ”

ทีนี้ถ้าเกิดเราจะไปแก้ move() หรือไม่ก็เพิ่ม fly() มันก็อาจจะไปพังจุดอื่นใน Class ด้วยเช่นกัน อาจจะลามปามถึงขั้นไปแก้ loadCharacter() เลยก็เป็นได้ นั่นก็หมายความว่า Class ของเราเนี่ย เวลาที่เรากลับมาแก้หรือเปลี่ยนแปลง มันไม่ได้เปลี่ยนแปลงแค่จุดเดียวเสมอไปนั่นเอง

วิธีการแก้ปัญหาตรงนี้มันง่ายมากๆ อย่างที่เคยบอกไปครับ เราก็แค่ไปสร้าง Class ใหม่ให้รองรับ move() โดยอาจจะชื่อว่า MoveCharacter แล้วก็ให้มัน Dependency กับ Character แบบนี้

ตัวอย่าง Code ในภาษา Rust

Bad

struct Texture;

struct Character {
texture: Texture,
speed: f64,
}

impl Character {
fn loadCharacter(&self) {
println!("Loading character...")
}

fn moveCharacter(&self, speed: f64) {
println!("Walking at {} m/s", speed);
}
}

fn main() {
// ...
}

Good

struct Texture;

struct Character {
texture: Texture,
}

struct MoveCharacter;

impl Character {
fn load_character(&self) {
println!("Loading character...")
}
}

impl MoveCharacter {
fn move_character(&self, c: &Character, speed: f64) {
println!("Walking at {:?} m/s", speed);
}
}

fn main() {
// ...
}

เห็นไหมมม ว่าเพียงเราทำแบบนี้ เวลาที่เราจะต้องมาแก้ไขหรือเพิ่มเติมอะไรกับ Character Class ก็ง่ายแล้ว เหตุผลเดียวที่เราจะมาแก้ Character Class ก็มีเพียงแค่ตัว Method loadCharacter() มันมีปัญหา หรือต้องการปรับปรุงอะไรเพิ่มเติมแค่นั้น แต่ถ้าจะแก้ไขการเคลื่อนที่ของตัวละครเพิ่ม ก็ไปแก้ที่ MoveCharacter แทน แถมไม่ส่งผลอะไรต่อ Character Concrete Class หลักของเราด้วย

O — Open/Closed Principle

Classes should be open for extension but closed for modification.

ใน Concept หลักของ Open/Closed มันก็คือการป้องกันไม่ให้ Code เราพังเมื่อเพิ่ม Feature อะไรใหม่ๆเข้ามา นั่นเอง

โดยในความหมายของ “Classes should be open for extension but closed for modification.” ถ้าหากว่าเราสามารถที่จะทำการสร้าง Class ใหม่แล้วก็ Extends ของเก่าไป แล้วหลังจากนั้นจะเพิ่ม Method อะไรใหม่ๆก็ตามใจชอบได้เลย แต่อย่าไปแก้ Class เดิมก็พอ

Example

สมมุติเรามี Character Class อยู่ แล้วใน Character Class ของเราสามารถที่จะเรียกใช้ Skills ต่างๆได้มากมาย อย่างเช่น healing() fireball() และอื่นๆอีกมากมายดังตัวอย่างด้านล่าง

เห็นแบบนี้ก็ดูเรียบง่ายดีนี่ ถ้าเกิดว่าเราสร้าง Class ตาม Diagram ด้านบนเนี่ย เป็นปัญหาแน่ๆ เพราะถ้าสมมุติเกมเรามันมี Skill ใหม่เพิ่มเข้ามาเรื่อยๆ ก็ต้องเข้ามาแก้ Character Class อยู่เรื่อยไป แล้วอาจจะเป็นปัญหาให้ Character Class มันใหญ่จนเกินไป แล้วอาจจะบำรุงรักษายากได้ในอนาคต

ลองนึกภาพดูถ้าเกิดมันมีหลาย Skills เข้า Class เราก็คงจะเละและดูไม่จืด ใครเห็นก็คงต้องส่ายหัวใช่ไหมครับ ถ้าหากว่าเราลองนำหลักการ Open/Closed มาใช้ล่ะ??? มันก็จะเป็นประมาณภาพด้านล่างนั่นเอง

ตัวอย่าง Code ในภาษา Rust

Bad

struct Character {
name: String,
level: u32,
}

impl Character {
fn execute_skill(&self, skill_type: &str) {
match skill_type {
"healing" => Self::healing(),
"fireballing" => Self::fireball(),
"illusioning" => Self::illusion(),
_ => println!("{} skill not found", skill_type),
}
}

fn healing() {
println!("Healing ...")
}

fn fireball() {
println!("Fireballing ...")
}

fn illusion() {
println!("Illusioning ...")
}
}

fn main() {
// ...
let player1 = Character {
name: String::from("Player 1"),
level: 1,
};

for skill in ["healing", "fireballing", "illusioning"] {
player1.execute_skill(skill);
}
}

Output

Healing!
Fireball!
Illusion!

Good

struct Character {
name: String,
level: u32,
skills: Vec<Box<dyn Skill>>,
}

impl Character {
fn execute_skill(&self) {
for skill in self.skills.iter() {
skill.execute();
}
}
}

trait Skill {
fn execute(&self);
}

struct Healing;

impl Skill for Healing {
fn execute(&self) {
println!("Healing!");
}
}

struct Fireball;

impl Skill for Fireball {
fn execute(&self) {
println!("Fireball!");
}
}

struct Illusion;

impl Skill for Illusion {
fn execute(&self) {
println!("Illusion!");
}
}

fn main() {
let character = Character {
name: "CharacterName".to_string(),
level: 3,
skills: vec![
Box::new(Healing),
Box::new(Fireball),
Box::new(Illusion)
],
};

character.execute_skill();
}

Output

Healing!
Fireball!
Illusion!

ปล. Code ชุดนี้ผมโชว์ตัวอย่างการใช้งานใน main ด้วยเนื่องจากมีความซับซ้อนอยู่พอสมควร เผื่อบางคนจะนึกภาพตามไม่ออกครับ แล้วด้วยความที่มันดึง Composite Pattern มาใช้ด้วย

เห็นไหมมม!!! ว่ามัน Clean กว่าเดิมเยอะเลยใช่ไหมล่ะ

L — Liskov Substitution Principle (LSP)

When extending a class, remember that you should be able to pass objects of the subclass in place of objects of the parent class without breaking the client code.

หลักการนี้เป็นหลักการที่ถูกคิดค้นโดย Barbara Liskov ในช่วงปี ค.ศ. 1987 อธิบายไว้ก็คือ เวลาที่เราทำการสร้าง Class ใหม่โดย Extends จาก Superclass แล้วตอนที่จะทำการ Override Methods ใดๆจาก Superclass ควรที่จะทำให้มันเป็นแนวโน้มเดียวกันมากกว่าการที่จะไปเปลี่ยนใหม่ทั้งหมด และที่สำคัญ Class ลูกที่ได้ทำการ Extends มาจาก Superclass นั้น ไม่มีสิทธิในการ Throw Exception อื่นๆนอกเหนือจากที่ Superclass ได้กำหนดเอาไว้แล้ว

หลักการนี้ฟังดูอาจจะดูแบบอุทานออกมาว่า WTF ได้ ว่าทำไมต้องทำแบบนี้ ทำไปทำไม ทำเพื่ออะไร แต่เชื่อเถอะครับ มันจะ Powerful มากๆ ถ้าวันนึงเราต้องไปสร้าง Framework หรือ Library เพราะว่าตอนที่ Client เขาเรียกใช้งาน Class จากเรา แล้วถ้า Class นั้นมันดันเป็น Class ที่เราได้ Extends มาจาก Superclass มันก็ควรที่จะทำอะไรได้เหมือนกันกับ Superclass ยังไงล่ะ

Example

ตัวอย่างเช่น สมมุติ เรามี Superclass Mage โดย Mage สามารถ castSpell() ได้ สามารถ healing() ได้ ต่อมาเราได้สร้าง Class ใหม่ขึ้นมา อาจจะเป็น Pyromancer Class แล้วได้ทำการ Override ให้ castSpell() จากเดิมที่เป็นการโจมตีเวทย์ปกติ เป็นการโจมตีด้วยเวทย์ไฟแทน แบบนี้ได้ เป็นไปตามหลักการของ LSP

แต่ถ้าแบบ เรา Override castSpell() ไป ให้เปลี่ยนเป็นกินบาบิคิวแทนการโจมตี แบบนี้จะไม่ได้ ผิดหลัก LSP ทันที หรือไม่ก็อยู่ๆบอกว่า Pyro ใช้ healing() ไม่ได้ก็ผิดหลัก LSP เช่นกัน เนื่องจากว่า Superclass สามารถ Heal ได้ แต่ Class ลูกๆกลับ Heal ไม่ได้เฉยงี้

ตัวอย่าง Code ในภาษา Rust

Bad

struct Mage;

impl Mage {
fn cast_spell(&self, magic: &str) {
match magic {
"" => println!("Magic attack!"),
_ => println!("Magic: {} attack!", magic)
}
}

fn healing(&self) {
println!("Healing!");
}
}

struct Pyromancer {
mage: Mage,
}

impl Pyromancer {
fn cast_spell(&self) {
self.mage.cast_spell("Fireball!");
}

fn healing(&self) {
panic!("I can't heal!")
}
}

fn main() {
// ...
}

Good

struct Mage;

impl Mage {
fn cast_spell(&self, magic: &str) {
match magic {
"" => println!("Magic attack!"),
_ => println!("Magic: {} attack!", magic)
}
}

fn healing(&self) {
println!("Healing!");
}
}

struct Pyromancer {
mage: Mage,
}

impl Pyromancer {
fn cast_spell(&self) {
self.mage.cast_spell("Fireball");
}

fn healing(&self) {
self.mage.healing();
}
}

fn main() {
// ...
}

I — Interface Segregation Principle

Clients shouldn’t be forced to depend on methods they do not use.

หลักการนี้ไม่มีอะไรเลย มันก็แค่บอกไว้ว่า Client ไม่ควรที่จะต้องมานั่งหลังแข็งเพื่อ Implement Methods ทั้งหมด เวลาที่ Class นั้นถูก Implement มาจาก Interface อีกที

ฟังแบบนี้อาจจะดูงงนิดนิดหน่อย ทั้งงั้นเราไปลองยกตัวอย่างกันเลยดีกว่า

Example

สมมุติว่าเรามี Interface นึง ที่เอาไว้กำหนดพฤติกรรมต่างๆของ Weapon โดย Weapon บางชนิดสามารถโจมตีระยะประชิดได้ บางชนิดโจมตีได้ทั้งระยะไกลและระยะประชิด หรือบางชนิดยิงเวทย์ได้อีกด้วย

วิธีการแก้ปัญหาก็คือ เราก็แค่กระจาย Interface แยกออกจากกันไปเลย อย่างเช่น จากเดิมที่เราเอาทุก Behavior ทั้งหมดไปรวมอยู่ใน Interface ของ Weapon ที่เดียว เราก็อาจจะแยกไปสร้าง Interface ใหม่เป็น SwordProperties และ BowProperties เพียงเท่านี้ก็จะแก้ปัญหานี้ได้แล้วอย่างง่ายดายนั่นเอง

ตัวอย่าง Code ในภาษา Rust

Bad

trait Weapon {
fn range_attack(&self);
fn melee_attack(&self);
fn magic_attack(&self);
}

struct Sword;

impl Weapon for Sword {
fn range_attack(&self) {
// Sword cannot range attack
}

fn melee_attack(&self) {
println!("Sword melee attack");
}

fn magic_attack(&self) {
println!("Sword magic attack");
}
}

struct Bow;

impl Weapon for Bow {
fn range_attack(&self) {
println!("Bow range attack");
}

fn melee_attack(&self) {
println!("Bow melee attack");
}

fn magic_attack(&self) {
// Bow cannot magic attack
}
}

fn main() {
// ...
}

Good

trait SwordProperties {
fn melee_attack(&self);
fn magic_attack(&self);
}

struct SwordOfDarkness;

impl SwordProperties for SwordOfDarkness {
fn melee_attack(&self) {
println!("Melee attack!");
}

fn magic_attack(&self) {
println!("Darkness attack!");
}
}

trait BowProperties {
fn ranged_attack(&self);
fn melee_attack(&self);
}

struct DragonBow;

impl BowProperties for DragonBow {
fn ranged_attack(&self) {
println!("Ranged attack!");
}

fn melee_attack(&self) {
println!("Dragon bite!");
}
}

fn main() {
// ...
}

รู้ตัวอีกที Code เราก็ดู Clean ไปแล้วสินะ แต่จริงๆตรงนี้เสริมนิดนึงคือ เราสามารถใช้ Composite Pattern ได้นะ มันก็จะออกมาคล้ายๆแบบนี้

trait Attack {
fn attack(&self);
}

struct MeleeAttack;

impl Attack for MeleeAttack {
fn attack(&self) {
println!("Melee Attack!");
}
}

struct RangedAttack;

impl Attack for RangedAttack {
fn attack(&self) {
println!("Ranged Attack!");
}
}

struct MagicAttack;

impl Attack for MagicAttack {
fn attack(&self) {
println!("Magic Attack!");
}
}

struct SwordOfDarkness {
properties: Vec<Box<dyn Attack>>,
}
struct DragonBow {
properties: Vec<Box<dyn Attack>>,
}

fn main() {
let sword_of_darkness = SwordOfDarkness {
properties: vec![Box::new(MeleeAttack), Box::new(MagicAttack)],
};

for property in sword_of_darkness.properties.iter() {
property.attack();
}

let dragon_bow = DragonBow {
properties: vec![Box::new(RangedAttack), Box::new(MagicAttack)],
};

for property in dragon_bow.properties.iter() {
property.attack();
}
}

D - Dependency Inversion Principle

High-level classes shouldn’t depend on low-level classes.
Both should depend on abstractions. Abstractions
shouldn’t depend on details. Details should depend on
abstractions.

หลักการนี้ทุกคนน่าจะคุ้นเคยกันดี เพราะว่าใช้บ่อยในพวกการเขียน Code ในรูปแบบของ Clean Architecture ที่เป็นการผสมผสานระหว่าง Facade Pattern กับ Chain Pattern นั่นเอง

โดยหลักการนี้ได้กล่าวเอาไว้ว่า Class ใดๆที่มัน High Level กว่า มันไม่ควรขึ้นอยู่กับ Class ที่ Low Level กว่ามัน แล้วทั้งคู่ก็ควรขึ้นอยู่กับ Abstractions (หรือ Interface ก็ได้ แต่ห้ามใช้ Concrete Class เด็ดขาด) ด้วย แล้วแน่นอนว่า Abstractions มันก็ย่อมไม่มี Details อะไรอยู่แล้ว มีแต่ Details สิที่ต้องขึ้นอยู่กับ Abstractions

เขียนมาแบบนี้คงจะงง ถ้าจะอธิบายให้ย่อยง่ายลงไปอีกก็คือ ถ้าเรามีการส่งผ่าน Properties กันแบบเป็น Chain ต่อๆกัน (นึกถึงการแบ่ง Layer แบบ Clean Architecture) โดย Layer ที่มันอยู่ชั้นบนกว่า จะไม่รับรู้อะไรทั้งนั้นว่า Layer ที่ต่ำกว่ามันมีกระบวนการอะไรซ่อนอยู่ข้างในบ้าง รู้แค่ว่า ถ้าเรียกใช้งาน มันจะต้องเรียกใช้ได้เสมอ

ซึ่งหลักการนี้นอกจากมันจะช่วยให้ Code เราสามารถที่จะพัฒนาต่อยอดได้ง่ายแล้ว มันยังช่วยแก้ปัญหาเรื่อง Dependency Injection อีกด้วย ถ้าหากว่าต้องมีการสร้าง Mock Function เพื่อทำ Unit Testing เอาล่ะ เราไปลองดูตัวอย่างกันเลยดีกว่า เพื่อความเข้าใจที่มากขึ้น

Example

สมมุติว่าเรามี Character Class ของเรา สามารถเป็นได้หลายอาชีพ เช่น Mage, Warrior, Thief และอื่นๆอีกมากมาย แล้วแต่ละอาชีพก็จะมี Behavior ที่เหมือนๆกัน อย่างเช่น Attack, Move และอื่นๆ อะไรประมาณนี้ เราก็อาจจะได้ UML Diagram ออกมาประมาณนี้

แล้วทีนี้ ถ้าอาชีพในเกมของเรามันเพิ่มขึ้นเรื่อยๆล่ะ? จะยังเขียนแบบนี้กันต่อไปไหม ก็คงไม่ ไม่งั้นได้สร้าง Class ใหม่กันขรี้แตกแน่ๆ 555

จริงๆแล้วเนี่ย เราสามารถทำให้ Attack และ Move เป็น Abstractions หรือ Interface ได้นั่นเอง แล้วทีนี้ไม่ว่าจะอาชีพไหนๆ จะหยิบ Abstractions ตัวนี้ไป Override ก็ตามใจแล้วแต่จะทำได้เลย แล้วก็ให้ Abstractions ตัวนี้แหละไปเป็น Attribute ของ Character Class

โดยที่ว่า ไม่ว่า Character Class จะเรียกใช้ Method ไหนๆ ไม่ว่าจะ Attack หรือ Move ก็ให้มันขึ้นอยู่กับอาชีพที่เคยเลือกไว้ไปเลย โดยที่ Character Class จะไม่รู้เลยว่าไส้ในของ Attack หรือ Move นั้นทำงานยังไง เป็นของอาชีพไหน

ตัวอย่าง Code ในภาษา Rust

Bad

struct Mage;

impl Mage {
fn attack(&self) {
println!("Mage attack");
}

fn move_action(&self) {
println!("Mage move");
}
}

struct MageCharacter {
career: Mage,
}

impl MageCharacter {
fn attack(&self) {
self.career.attack();
}

fn move_action(&self) {
self.career.move_action();
}
}

fn main() {
// ...
}

Good

trait Action {
fn attack(&self);
fn move_action(&self);

}

struct Mage;

impl Action for Mage {
fn attack(&self) {
println!("Mage attack");
}

fn move_action(&self) {
println!("Mage move");
}
}

struct Warrior;

impl Action for Warrior {
fn attack(&self) {
println!("Warrior attack");
}

fn move_action(&self) {
println!("Warrior move");
}
}

struct Character {
action: Box<dyn Action>
}

impl Character {
fn attack(&self) {
self.action.attack();
}

fn move_action(&self) {
self.action.move_action();
}
}

fn main() {
// ...
}

เพียงเท่านี้ก็เรียบร้อย ง่ายดายอย่างมีความสุขครับ แถมทำ Unit Testing ง่ายอีกด้วย ลองไปปรับใช้กันดูนะครับ สำหรับบทความนี้ก็มีเพียงเท่านี้ เจอกันใหม่ในบทความหน้าครับ หลังจากนี้ไปจะมีแต่ Rust รัวๆแน่นอนครับ

--

--

Ruangyot Nanchiang

I just an indie sleepless backend developer with a polylang skill 💀💀💀.