/ BasicKnowledge

SOLID, The OOD Principle

พอดีผมได้มีโอกาสอ่านบล็อกของเพื่อนในตอนล่าสุด(เขียน iOS application ใช้ OOP ดีไหม แล้วใช้ทำไม (Part2)) ก็เลยนึกขึ้นมาได้ว่า การเขียนโปรแกรมเชิงวัตถุนั้นยังมีกฏหรือหลักธรรมเนียมปฏิบัติย่อยๆ ซึ่งคิดขึ้นโดยผู้เชี่ยวชาญในวงการจำนวนมาก และหนึ่งใน Principle ที่ผมคิดว่าดีที่สุดนั้นก็คือ SOLID


What is SOLID?

SOLID เป็นหลักปฏิบัติในการออกแบบและเขียนโปรแกรมเชิงวัตถุ ซึ่งกล่าวถึงหลักการออกแบบคลาสที่ดี แต่เดิมใช้ชื่อว่า The first five principles ผมไม่รู้ว่าใครเป็นผู้คิดค้น แต่ที่แน่ๆ Robert C. Martin หรือที่รู้จักกันในนามของ Uncle BOB ได้กล่าวถึงในหนังสือ Agile Software Development: Principles, Patterns and Practices และบนเว็บไซต์ของเขา

คำว่า SOLID นั้นเป็นการเอาพยัญชนะแรกของแต่ละ Principle มาเรียงต่อกัน ซึ่งได้แก่ Single responsibility, Open-closed, Liskov substitution, Interface segregation and Dependency inversion เรามาเริ่มเรียนรู้จากตัวอย่างกันเลยดีกว่า

ให้เขียนโปรแกรมเครื่องเล่นเพลงเพื่อให้ User ทั่วไปสามารถฟังเพลงจาก list ที่มีอยู่ในเครื่อง

*โค้ดในบทความนี้เป็นเพียง Pseudocode เท่านั้น โดยจะเขียนให้ใกล้เคียงภาษา Java และ C#

Single responsibility

Single responsibility หรือ SRP นั้นกำหนดว่าแต่ละคลาสต้องมีหน้าที่เพียงอย่างเดียวเท่านั้น กล่าวคือคลาสใดๆ ก็ตาม จะเขียนขึ้นเพื่อตอบสนองต่อ Requirement ของ Actor เพียง 1 คน และเหตุผลเดียวที่จะแก้คลาสนั้นได้ก็คือ Actor นั้น เปลี่ยน Requirement

จากโจทย์ให้มองว่ามี Actor เดียวคือผู้ใช้ทั่วไป (ให้มองกลุ่มของคนที่ใช้งานเหมือนๆ กันเป็น 1 Actor)สิ่งแรกที่เราต้องทำก็คือการออกแแบคลาสที่จะใช้

เมื่อพิจารณาแล้วพบว่า อย่างน้อยที่สุดจะต้องมี 2 คลาส คือ เพลง และตัวเล่นเพลง คลาสเครื่องเล่นเพลงนั้นจะตอบสนอง Actor ของเรา แต่ในทำนองเดียวกันคลาสนี้ัยังต้องเป็น Actor เองอีกด้วย เพราะหน้าที่ของมันคือ เล่นเพลง ดังนั้นเราจึงต้องสร้างคลาสเพลง เพื่อตอบสนอง Actor ตัวนี้

class Song
{
	string getName(){}
    string getArtist(){}
    string getAlbum(){}
	byte[] getContent(){}
}

clsss SongPlayer
{
	List<Song> getSongs(){}
    Song getCurrentSong(){}
    
    selectSong(int index){}
    nextSong(){}
    previousSong(){}
    play(){}
    pause(){}
}

จากโค้ดจะเห็นว่า SongPlayer นั้นออกแบบมาให้ตอบสนองความต้องการของ Actor ได้เป็นอย่างดี ทั้งสามารถ เลือกเพลง ไปเพลงถัดไป/ก่อนหน้า เล่นเพลงและหยุด ในขณะเดียวกันการที่จะทำให้เรื่องพวกนี้เกิดขึ้นได้ก็จำเป็นต้องมี เพลง เสียก่อน และความ Song ก็เกิดมาเพื่อตอบสนองความต้องการนี้ โดยมี Properties เป็น ชื่อเพลง ศิลปิน อัลบั้ม และส่วนของเนื้อเพลง

Open/Closed Principle

Open/Closed Principle หรือ OCP กล่าวถึงการเปิดรับการเพิ่มความสามารถ แต่ไม่ให้แก้ไขของเดิม กล่าวคือข้อกำหกนดนี้พยายามบอกว่าไม่ว่าคุณจะต้องทำให้โค้ดของคุณต้องแก้น้อยที่สุดเมื่อมี Requirement ที่เปลี่ยนไป ฟังดูแล้วก็ยังงง ผมก็งง เอาเป็นว่าไปลองๆ ดูตัวอย่างกันเลยดีกว่า

Case ที่ง่ายที่สุดที่อาจจะทำให้เข้าใจตรงกันคือเรื่องราวของการคำนวณพื้นที่ในรูปร่างพื้นฐาน

class Rectangle
{
	double getHeight(){}
    double getWidth(){}
}

class Circle
{
	double getRadius(){}
}

class AreaCalculator
{
	double sumArea(object[] shapes)
	{
		double area = 0
		foreach (var shape in shapes)
		{
			if (shape is Rectangle)
			{
 				Rectangle rectangle = (Rectangle)shape
 				area += rectangle.getHeight() * rectangle.getWidth()
			}		
			else if (shape is Circle)
			{
 				Circle circle = (Circle)shape
 				area += circle.getRadius() * circle.getRadius() * Math.PI
			}
		}
		return area
	}
}

จากตัวอย่างนี้พบว่า ถ้าผมต้องการเพิ่มรูปร่างแบบอื่นๆ ผมก็ต้องมาแก้ sumArea() ใหม่ด้วย เพราะมันยังรองรับแค่ Rectangle กับ Circle เท่านั้น

abstract class Shape 
{
	double area()
}

class Rectangle : Shape
{
	double getHeight(){}
    double getWidth(){}
    
    double area()
    {
    	return getHeight() * getWidth();
    }
}

class Circle : Shape
{
	double getRadius(){}
    
    double area()
    {
    	return circle.getRadius() * circle.getRadius() * Math.PI;
    }
}

class AreaCalculator
{
	double sumArea(Shape[] shapes)
	{
		double area = 0
		foreach (var shape in shapes)
		{
			area += shape.area()
		}
		return area
	}
}

จากโค้ดที่แก้ใหม่นี้เป็นการย้าย Responsibirity ในการคำนวณพื้นที่ ไปไว้ในแต่ละคลาสของรูปร่างนั้นๆ แทน โดยมีการทำข้อตกลงว่า เมื่อใดก็ตามที่ต้องการสร้างรูปร่างชนิดใหม่ขึ้นมา จะต้องมี area() ให้เรียกใช้ได้เสมอ โดยใส่ไว้ใน Abstraction layer ที่ชื่อว่า Shape

จากตัวอย่างข้างต้น จะเห็นได้ว่าจริงๆ แล้วเป้าหมายของ OCP คือการให้คำมั่นสัญญาต่อ Business Model หรือ Requirement ว่าไม่ว่ามันจะถูกเปลี่ยนไปอย่างไร Business Model หรือ Requirement จะต้องถูกต้องเสมอ

เราย้อนกลับมาดูที่โจทย์ของเรากันบ้าง ลองจินตนาการดูครับว่าไฟล์เพลงที่คุณเคยฟัง หรือเคยได้ยินชื่อ มันมีกี่ประเภท สำหรับผมแล้วไม่ค่อยได้ฟังเพลง ก็จะรู้จักแค่พวก MP3 WMA อ่ะไรประมาณนั้น แต่ตอนนี้โค้ดของเรา ยังมีแค่ Song แบบเดียวเท่านั้น ลองมาดูแนวทางที่เป็นไปได้กันดีกว่าครับ

ผลักภาระหน้าที่ในการเล่นเพลงให้คลาส Song ไปเลย

เป็นแนวคิดเดียวกับเรื่อง Shape เลยครับ แต่นั่นหมายความว่า คุณกำลังเอาส่วนการประมาวลผลลงไปในส่วนเก็บข้อมูล เท่ากับว่าเพลงทุกเพลงจะมีตัวเล่นของตัวเองหมด ขนาดก็จะใหญ่ขึ้น แต่ที่สำคัญเหนือสิ่งอื่นใดมันผิดหลัก SRP ครับ เพราะคลาสเพลงนั้นเราออกแบบมาให้ SongPlayer ใช้เพื่อดึงข้อมูลต่างๆ และเนื่อเพลงเท่านั้น

สร้างคลาสใหม่ขึ้นมาจัดการ Codec ต่างๆ ไปเลย

ดีกว่าแนวคิดเมื่อกี้ครับ เป็นการสร้างคนรับผิดชอบหน้าที่ใหม่ แต่เนื้อรวมยังต้องไปดูโปรแกรมหลักอยู่ดี

เอาเป็นว่าผมเลือกแนวทางที่สองไปก่อน ตอนนี้ เพื่อที่จะได้มีไฟล์เพลงหลายประเภทได้ เราจึงต้องสร้าง Abstraction layer ขึ้นมาคลุมไฟล์เพลงอีกที ขอทำการเปลี่ยน Song เป็น Abvstract Class นะครับ

abstract class Song
{
	string getName()
    string getArtist()
    string getAlbum()
	byte[] getContent()
}

เท่านี้เราก็ได้ต้นแบบของคลาสไฟล์เพลงแบบต่างๆ แล้วครับ มันจะเป็นตัวการันตีว่าไม่ว่าใช้ไฟล์เพลงไหน เครื่องเบ่นของเราจะคงทำงานได้เหมือนเดิม มาลองสร้างคลาสของไฟล์เพลงต่างๆ กันต่อครับ

class Mp3Song : Song
{
	string getName(){}
    string getArtist(){}
    string getAlbum(){}
	byte[] getContent(){}
}

class WmaSong : Song
{
	string getName(){}
    string getArtist(){}
    string getAlbum(){}
	byte[] getContent(){}
}

ต่อมาเรามาลองดูในส่วนของตัว Player กัน

clsss SongPlayer
{
	/*---- another code ----*/
    
    play()
    {
    	Song song = getCurrentSong()
        if(song is Mp3Song)
        {
        	//Decode and play here
        }
        else if(song is WmaSong)
        {
        	//Decode and play here
        }
    }
    
    /*---- another code ----*/
}

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

abstract class SongDecoder
{
	bool IsMatch(Song song)
	OutputStream decode(Song song)
}

class Mp3Decoder : SongDecoder
{
    bool IsMatch(Song song)
    {
    	return song is Mp3Song
    }
    
	OutputStream decode(Song song)
    {
    	//Decode algorithm here
    }
}

class WmaDecoder : SongDecoder
{
  	bool IsMatch(Song song)
    {
    	return song is WmaSong
    }
    
	OutputStream decode(Song song)
    {
    	//Decode algorithm here
    }
}

class SongDecodeHelper
{
	List<SongDecoder> songDecoders;
	
	SongDecodeHelper()
    {
        songDecoders = new List<SongDecoder>()
        songDecoders.Add(new Mp3Decoder())
        songDecoders.Add(new WmaDecoder())
    }
    
    Stream decode(Song song)
    {
        return songDecoders.First(s => s.IsMatch(song)).decode(song)
    }
}

จากโค้ดข้างต้นจะเห็นว่ามีการกำหนด Abstraction ของ Decoder ขึ้นมา ว่าการ Decode เพลงนั้นต้องทำอย่างไรบ้าง แล้วจึงค่อยๆ แยก Decoder ของแต่ละชนิด หลักจากนั้นเราสร้างผู้รับผิดชอบการแยกแยะเพลงขึ้นมา คือ SongDecodeHelper นั่นเอง เมื่อแก้โปรแกรมหลัก จะได้ว่า

clsss SongPlayer
{
	/*---- another code ----*/
    
    SongDecodeHelper getSongDecodeHelper(){}
    
    play()
    {
    	Song song = getCurrentSong()
        Stream stream = getSongDecodeHelper().decode(song);
        //Play algorithm here
    }
    
    /*---- another code ----*/
}

เห็นไหมครับว่าเวลา Requirement เปลี่ยนเราจะแก้ง่ายขึ้นขนาดไหน ประโยชน์อีกอย่างคือการลดความผิดพลาดของการแก้โค้ดเดิมที่ OPC ได้บอกให้ระวัง

Liskov substitution principle

Liskov substitution principle หรือ LSP แค่ชื่อก็อ่านยากแล้วครับ แต่เนื้อในไม่ยากอย่างที่คิดไว้เลย มันกล่าวถึงว่า Object ทั้งหลายในโปรแกรมนี้ ต้องสามารถทดแทนได้ subtype ของ Object เหล่านั้นทันที โดยไม่ต้องมีการแก้ส่วนอื่นเพิ่มเติม

ตามกฏทั่วไปของการเขียนโปรแกรมเชิงวัตถุก็ได้มีการบอกเรื่องนี้ไว้ชัดเจนอยู่แล้ว Subclass ใดๆ ที่สืบทอดมาจาก Superclass ใดๆ ย่อมมีคุณสมบัติของ Superclass นั้นๆ ด้วย จะเห็นว่านี่มันก็คือหลักการเดิมที่เราได้เรียนรู้จากข้างบนว่าความต้องการของ Actor จะต้องถูกตอบสนองเสมอ และเมื่อ Requirement เปลี่ยน ส่วนการทำงานอื่นๆก็ต้องใช้ได้เหมือนเดิม

จากโจทย์ ถ้าเรามีการประกาศไว้ว่า

clsss SongPlayer
{
	List<Song> getSongs()
    {
    	return new List<SongDecoder>()
        {
        	new Mp3Song(){/*set properties*/},
            new Mp3Song(){/*set properties*/},
            new Mp3Song(){/*set properties*/},
            new Mp3Song(){/*set properties*/},
            new Mp3Song(){/*set properties*/},
            new Mp3Song(){/*set properties*/}
        }
	}
    /*---- another code ----*/
}

แบบนี้เวลา User ใช้ SongPlayer ก็สามารถสั่ง play ได้เลย แต่ถ้าเราจะแก้ข้างในเป็น

clsss SongPlayer
{
	List<Song> getSongs()
    {
    	return new List<SongDecoder>()
        {
        	new WmaSong(){/*set properties*/},
            new Mp3Song(){/*set properties*/},
            new WmaSong(){/*set properties*/},
            new Mp3Song(){/*set properties*/},
            new WmaSong(){/*set properties*/},
            new WmaSong(){/*set properties*/}
        }
	}
    /*---- another code ----*/
}

ไม่ว่าแก้ List เพลงเป็นอย่างไร User จะคงยังใช้ได้เหมือนเดิม เพราะ ทั้ง Mp3Song และ WmaSong นั้นเกิดมาจาก abstraction เดียวกัน ก็คือ Song ทำให้ในข้อนี้เราไม่จำเป็นต้องแก้ไขอะไรอื่น (เว้นแต่ว่าจะสร้าง Abstraction layer ของ SongDecodeHelper และ SongPlayer เพื่อให้เป็นไปตามข้อกำหนดอย่างสมบูรณ์)

กฏเหล็กของ LSP คือ ห้ามกระทำการใดๆ ที่เป็นการบิดเบือน หรือปิดกั้นคุณสมบัติเดิมของ Superclass เช่น ถ้าเราส้ราง Stack จาก List นั่นหมายความว่า Stack ที่เราสร้างย่อมต้องมีคุณสมบัติในการแทรกข้อมูล ลบข้อมูลที่ตำแหน่งต่างๆ เช่นเดียวกันกับ List และห้ามปิดกั้นคุณสมบัติเหล่านั้น

Interface segregation principle

Interface segregation principle หรือ ISP กล่าวว่า การสร้าง Interface ที่เฉพาะเจาะจงต่อผู้ใช้นั้นๆ ไปเลยหลายๆ อัน ดีกว่าการสร้างแบบใช้งานทั่วๆ ไปเพียงอันเดียว นั่นหมายความว่า ให้ทบทวนเรื่อง SRP อีกครั้งหนึ่ง ดูให้แน่ใจว่าในระบบคุณมี Actor ภายนอกกี่คน เป็นใครบ้าง และเขาต้องการอะไร

จากโจทย์เราพบว่า Actor จะกระทำกับ SongPlayer เท่านั้น ดังนั้นถ้าเราสร้างกลุ่ม Actor ใหม่โดยเอาความ SongPlayer เป็นพื้นฐาน เช่นแบ่งเป็นแบบ บน iPod Shuffle กับ บน PC จะพบว่ามีบางความสามารถที่ต่างกัน อาจจะได้ว่า

abstract class SongPlayer
{
	List<Song> getSongs()
    Song getCurrentSong()
    nextSong()
    previousSong()
    play()
    pause()
}

class IPodLikePlayer : SongPlayer
{
	List<Song> getSongs(){}
    Song getCurrentSong(){}
    nextSong(){}
    previousSong(){}
    play(){}
    pause(){}
    
    shuffle(){}
}

class PCSongPlayer : SongPlayer
{
	List<Song> getSongs(){}
    Song getCurrentSong(){}
    nextSong(){}
    previousSong(){}
    play(){}
    pause(){}
	
    selectSong(int index){}
}

จะเห็นว่าจากตัวอย่างนี้ เราใส่ shuffle() ให้ IPodLikePlayer แต่เราใส่ selectSong() ให้ PCSongPlayer นั่นหมายความว่า Actor ของ 2 Class นี้ มีกลุ่มหนึ่งที่จะไม่เลือกเพลงแต่ขอให้สลับเพลงแบบสุ่ม ส่วนอีกกลุ่มหนึ่งขอเลือกเพลงเองได้

จริงๆ แล้วในการออกแบบ Interface นั้น ให้คำนึงถึง Actor Requirement เป็นหลัก โดย Group Actor เป็นกลุ่มใหญ่ๆ ตามความต้องการ แล้วจึงค่อยบมาสร้างคลาสที่ตอบสนองความต้องการของแต่ละกลุ่มแยกกันไป

"people don't know what they want until you show it to them." ― Steve Jobs

ในทางกลับกันเราอาจจะระบุ Actor จาก Interface ที่เราเตรียมไว้ก็เป็นได้ เพราะหลายๆ ครั้ง ผลิตภัณฑ์ก็เกิดขึ้นก่อนที่ Actor จะรู้ความต้องการของตัวเองเสียอีก

Dependency inversion principle

Dependency inversion principle หรือ DIP เป็นข้อกำหนดเกี่ยวกับ Dependency ว่า Class ใดๆ ก็ตาม ควรจะมี Dependency เป็น Abstraction เท่านั้น ซึ่งข้อนี้เหมือนเป็นการสรุป 4 ข้อข้างต้น กล่าวโดยรวมคือกฎทุกข้อของ SOLID บอกให้เราสร้าง Abstraction Layer ให้ทุกอย่างเท่าที่ทำได้ เพื่อบังคับความถูกต้องของ Business Model เพราะ Abstraction Layer ไม่ผูกติดกับรายละเอียดการทำงานใดๆ เลย

DIP พยายามบอกเราว่า ให้ Inject ทุกอย่างเท่าที่เป็นไปได้ เลี่ยงการใช้งาน Concrete Class ซึ่งมีรูปแบบการทำงานชัดเจน

จากโจทย์ เราได้แก้ให้แทบทุกส่วนมี Abstraction Layer แล้ว แต่ก็ยังเหลือส่วนหลักๆ คือ SongDecodeHelper เรามาลองแก้กันดีกว่า

abstract class SongDecodeHelper
{
	SongDecodeHelper()
    addDecoder(SongDecoder songDecoder)
    Stream decode(Song song)
}

class MySongDecodeHelper
{
	List<SongDecoder> songDecoders;
    
    MySongDecodeHelper()
    {
    	songDecoders =	new List<SongDecoder>();
    }
	
	MySongDecodeHelper(List<SongDecoder> songDecoders)
    {
    	this.songDecoders = songDecoders;
    }
    
    addDecoder(SongDecoder songDecoder)
    {
    	songDecoders.add(songDecoder)
    }
    
    Stream decode(Song song)
    {
        return songDecoders.First(s => s.IsMatch(song)).decode(song)
    }
    
}

จะเห็นว่าคราวนี้ผมผลัก SongDecodeHelper ลงไปเป็น abstract แล้วสร้าง MySongDecodeHelper ขึ้นมาใช้แทน โดยคลาสใหม่นี้ใช้วิธี Inject object ของ Abstract Type SongDecoder ลงไป แทนที่จะสร้าง Instance ในนั้นตรงๆ ซึ่งวิธี Inject นี้ทำให้เกิดความยืดหยุ่นกว่ามาก

สรุปโค้ดทั้งหมดคร่าวๆ

abstract class Song
{
	string getName()
    string getArtist()
    string getAlbum()
	byte[] getContent()
}

class Mp3Song : Song
{
	string getName(){}
    string getArtist(){}
    string getAlbum(){}
	byte[] getContent(){}
}

class WmaSong : Song
{
	string getName(){}
    string getArtist(){}
    string getAlbum(){}
	byte[] getContent(){}
}

abstract class SongDecoder
{
	bool IsMatch(Song song)
	OutputStream decode(Song song)
}

class Mp3Decoder : SongDecoder
{
	bool IsMatch(Song song)
    {
    	return song is Mp3Song
    }
    
	OutputStream decode(Song song)
    {
    	//Decode algorithm here
    }
}

class WmaDecoder : SongDecoder
{
	bool IsMatch(Song song)
    {
    	return song is WmaSong
    }
    
	OutputStream decode(Song song)
    {
    	//Decode algorithm here
    }
}

abstract class SongDecodeHelper
{
	SongDecodeHelper()
    addDecoder(SongDecoder songDecoder)
    Stream decode(Song song)
}

class MySongDecodeHelper
{
	List<SongDecoder> songDecoders;
    
    MySongDecodeHelper()
    {
    	songDecoders =	new List<SongDecoder>();
    }
	
	MySongDecodeHelper(List<SongDecoder> songDecoders)
    {
    	this.songDecoders = songDecoders;
    }
    
    addDecoder(SongDecoder songDecoder)
    {
    	songDecoders.add(songDecoder)
    }
    
    Stream decode(Song song)
    {
        return songDecoders.First(s => s.IsMatch(song)).decode(song)
    }
    
}

abstract class SongPlayer
{
	List<Song> getSongs()
    Song getCurrentSong()
    nextSong()
    previousSong()
    play()
    pause()
}

class IPodLikePlayer : SongPlayer
{
	IPodLikePlayer(List<Song> songs, SongDecodeHelper songDecodeHelper){}
    SongDecodeHelper getSongDecodeHelper();
	List<Song> getSongs(){}
    Song getCurrentSong(){}
    nextSong(){}
    previousSong(){}
    play(){}
    pause(){}
    shuffle(){}
}

class PCSongPlayer : SongPlayer
{
	PCSongPlayer(List<Song> songs, SongDecodeHelper songDecodeHelper){}
    SongDecodeHelper getSongDecodeHelper();
	List<Song> getSongs(){}
    Song getCurrentSong(){}
    nextSong(){}
    previousSong(){}
    play(){}
    pause(){}
    selectSong(int index){}
}

สรุป

SOLID เป็นข้อกำหนดในการออกแบบโปรแกรมเชิงวัตถุที่มุ่งเน้นไปในเรื่องของ Actor's requirement และความง่ายในการแก้ไข โดยมีการพูดถึงความสำคัญของ Abstraction Layer และการ Inject Object แทนการเรียกใช้ Concrete Class โดยตรง