แนวทางที่มั่นคงสำหรับหลักการ SOLID

1. บทนำ

ในบทช่วยสอนนี้เราจะพูดถึงหลักการ SOLID ของการออกแบบเชิงวัตถุ

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

2. เหตุผลสำหรับหลักการที่เป็นของแข็ง

หลักการ SOLID เป็นแนวคิดแรกโดย Robert C.Martin ในกระดาษปี 2000 หลักการออกแบบและรูปแบบการออกแบบ แนวคิดเหล่านี้ถูกสร้างขึ้นในภายหลังโดย Michael Feathers ผู้ซึ่งแนะนำตัวย่อ SOLID ให้เรา และในช่วง 20 ปีที่ผ่านมาหลักการทั้ง 5 นี้ได้ปฏิวัติโลกของการเขียนโปรแกรมเชิงวัตถุโดยเปลี่ยนวิธีการเขียนซอฟต์แวร์

SOLID คืออะไรและมันช่วยให้เราเขียนโค้ดได้ดีขึ้นได้อย่างไร? พูดง่ายๆคือหลักการออกแบบของ Martin's and Feathers กระตุ้นให้เราสร้างซอฟต์แวร์ที่บำรุงรักษาเข้าใจง่ายและยืดหยุ่นมากขึ้น ดังนั้นเมื่อแอปพลิเคชันของเรามีขนาดใหญ่ขึ้นเราจึงสามารถลดความซับซ้อนและช่วยตัวเองไม่ให้ปวดหัวได้อีกไกล!

5 แนวคิดต่อไปนี้ประกอบเป็นหลักการ SOLID ของเรา:

  1. S ingle ความรับผิดชอบ
  2. Oปากกา / ปิด
  3. การเปลี่ยนตัวL iskov
  4. ฉัน nterface แยก
  5. D ependency ผกผัน

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

3. ความรับผิดชอบเดียว

เริ่มต้นสิ่งต่างๆด้วยหลักการความรับผิดชอบเดียว ตามที่เราคาดหวังหลักการนี้ระบุว่าชั้นเรียนควรมีความรับผิดชอบเพียงอย่างเดียว นอกจากนี้ควรมีเหตุผลเดียวที่จะเปลี่ยนแปลง

หลักการนี้ช่วยให้เราสร้างซอฟต์แวร์ที่ดีขึ้นได้อย่างไร มาดูประโยชน์บางประการกัน:

  1. การทดสอบ - คลาสที่มีความรับผิดชอบเดียวจะมีกรณีทดสอบน้อยกว่ามาก
  2. การมีเพศสัมพันธ์ที่ต่ำกว่า - ฟังก์ชันการทำงานน้อยลงในคลาสเดียวจะมีการอ้างอิงน้อยลง
  3. องค์กร - ชั้นเรียนที่มีขนาดเล็กและมีการจัดระเบียบอย่างดีจะค้นหาได้ง่ายกว่าชั้นเรียนแบบเสาหิน

ยกตัวอย่างเช่นชั้นเรียนเป็นตัวแทนหนังสือธรรมดา:

public class Book { private String name; private String author; private String text; //constructor, getters and setters }

ในรหัสนี้เราเก็บชื่อผู้เขียนและข้อความที่เกี่ยวข้องกับตัวอย่างของหนึ่งหนังสือ

ตอนนี้เรามาเพิ่มสองวิธีในการสืบค้นข้อความ:

public class Book { private String name; private String author; private String text; //constructor, getters and setters // methods that directly relate to the book properties public String replaceWordInText(String word){ return text.replaceAll(word, text); } public boolean isWordInText(String word){ return text.contains(word); } }

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

ขอเตือนลมและเพิ่มวิธีการพิมพ์:

public class Book { //... void printTextToConsole(){ // our code for formatting and printing the text } }

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

public class BookPrinter { // methods for outputting text void printTextToConsole(String text){ //our code for formatting and printing the text } void printTextToAnotherMedium(String text){ // code for writing to any other location.. } }

น่ากลัว ไม่เพียง แต่เราได้พัฒนาคลาสที่ช่วยลดภาระหน้าที่การพิมพ์ของหนังสือแต่เรายังสามารถใช้ประโยชน์จากคลาสBookPrinterของเราเพื่อส่งข้อความของเราไปยังสื่ออื่น ๆ

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

4. เปิดสำหรับส่วนขยายปิดสำหรับการแก้ไข

ตอนนี้เวลาสำหรับ 'O' - ที่รู้จักมากขึ้นอย่างเป็นทางการในฐานะหลักการเปิดปิด พูดง่ายๆคือควรเปิดชั้นเรียนเพื่อขยายเวลา แต่ปิดเพื่อแก้ไข ในการทำเช่นนั้นเราจะหยุดตัวเองจากการแก้ไขโค้ดที่มีอยู่และทำให้เกิดข้อบกพร่องใหม่ ๆในแอปพลิเคชันที่มีความสุข

แน่นอนข้อยกเว้นประการหนึ่งของกฎคือเมื่อแก้ไขข้อบกพร่องในโค้ดที่มีอยู่

มาสำรวจแนวคิดเพิ่มเติมด้วยตัวอย่างโค้ดด่วน ในฐานะส่วนหนึ่งของโปรเจ็กต์ใหม่ลองจินตนาการว่าเราได้ใช้คลาสกีตาร์

มันเต็มเปี่ยมและยังมีปุ่มปรับระดับเสียง:

public class Guitar { private String make; private String model; private int volume; //Constructors, getters & setters }

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

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

ให้ยึดหลักการเปิดปิดและขยายคลาสกีตาร์ของเรา :

public class SuperCoolGuitarWithFlames extends Guitar { private String flameColor; //constructor, getters + setters }

การขยายคลาสGuitar ทำให้เรามั่นใจได้ว่าแอปพลิเคชันที่มีอยู่จะไม่ได้รับผลกระทบ

5. การเปลี่ยนตัว Liskov

Next up on our list is Liskov substitution, which is arguably the most complex of the 5 principles. Simply put, if class A is a subtype of class B, then we should be able to replace B with A without disrupting the behavior of our program.

Let's just jump straight to the code to help wrap our heads around this concept:

public interface Car { void turnOnEngine(); void accelerate(); }

Above, we define a simple Car interface with a couple of methods that all cars should be able to fulfill – turning on the engine, and accelerating forward.

Let's implement our interface and provide some code for the methods:

public class MotorCar implements Car { private Engine engine; //Constructors, getters + setters public void turnOnEngine() { //turn on the engine! engine.on(); } public void accelerate() { //move forward! engine.powerOn(1000); } }

As our code describes, we have an engine that we can turn on, and we can increase the power. But wait, its 2019, and Elon Musk has been a busy man.

We are now living in the era of electric cars:

public class ElectricCar implements Car { public void turnOnEngine() { throw new AssertionError("I don't have an engine!"); } public void accelerate() { //this acceleration is crazy! } }

By throwing a car without an engine into the mix, we are inherently changing the behavior of our program. This is a blatant violation of Liskov substitution and is a bit harder to fix than our previous 2 principles.

One possible solution would be to rework our model into interfaces that take into account the engine-less state of our Car.

6. Interface Segregation

The ‘I ‘ in SOLID stands for interface segregation, and it simply means that larger interfaces should be split into smaller ones. By doing so, we can ensure that implementing classes only need to be concerned about the methods that are of interest to them.

For this example, we're going to try our hands as zookeepers. And more specifically, we'll be working in the bear enclosure.

Let's start with an interface that outlines our roles as a bear keeper:

public interface BearKeeper { void washTheBear(); void feedTheBear(); void petTheBear(); }

As avid zookeepers, we're more than happy to wash and feed our beloved bears. However, we're all too aware of the dangers of petting them. Unfortunately, our interface is rather large, and we have no choice than to implement the code to pet the bear.

Let's fix this by splitting our large interface into 3 separate ones:

public interface BearCleaner { void washTheBear(); } public interface BearFeeder { void feedTheBear(); } public interface BearPetter { void petTheBear(); }

Now, thanks to interface segregation, we're free to implement only the methods that matter to us:

public class BearCarer implements BearCleaner, BearFeeder { public void washTheBear() { //I think we missed a spot... } public void feedTheBear() { //Tuna Tuesdays... } }

And finally, we can leave the dangerous stuff to the crazy people:

public class CrazyPerson implements BearPetter { public void petTheBear() { //Good luck with that! } }

Going further, we could even split our BookPrinter class from our example earlier to use interface segregation in the same way. By implementing a Printer interface with a single print method, we could instantiate separate ConsoleBookPrinter and OtherMediaBookPrinter classes.

7. Dependency Inversion

The principle of Dependency Inversion refers to the decoupling of software modules. This way, instead of high-level modules depending on low-level modules, both will depend on abstractions.

To demonstrate this, let's go old-school and bring to life a Windows 98 computer with code:

public class Windows98Machine {}

But what good is a computer without a monitor and keyboard? Let's add one of each to our constructor so that every Windows98Computer we instantiate comes pre-packed with a Monitor and a StandardKeyboard:

public class Windows98Machine { private final StandardKeyboard keyboard; private final Monitor monitor; public Windows98Machine() { monitor = new Monitor(); keyboard = new StandardKeyboard(); } }

This code will work, and we'll be able to use the StandardKeyboard and Monitor freely within our Windows98Computer class. Problem solved? Not quite. By declaring the StandardKeyboard and Monitor with the new keyword, we've tightly coupled these 3 classes together.

Not only does this make our Windows98Computer hard to test, but we've also lost the ability to switch out our StandardKeyboard class with a different one should the need arise. And we're stuck with our Monitor class, too.

Let's decouple our machine from the StandardKeyboard by adding a more general Keyboard interface and using this in our class:

public interface Keyboard { }
public class Windows98Machine{ private final Keyboard keyboard; private final Monitor monitor; public Windows98Machine(Keyboard keyboard, Monitor monitor) { this.keyboard = keyboard; this.monitor = monitor; } }

Here, we're using the dependency injection pattern here to facilitate adding the Keyboard dependency into the Windows98Machine class.

Let's also modify our StandardKeyboard class to implement the Keyboard interface so that it's suitable for injecting into the Windows98Machine class:

public class StandardKeyboard implements Keyboard { }

Now our classes are decoupled and communicate through the Keyboard abstraction. If we want, we can easily switch out the type of keyboard in our machine with a different implementation of the interface. We can follow the same principle for the Monitor class.

Excellent! We've decoupled the dependencies and are free to test our Windows98Machine with whichever testing framework we choose.

8. Conclusion

In this tutorial, we've taken a deep dive into the SOLID principles of object-oriented design.

เราเริ่มต้นด้วยประวัติ SOLID สั้น ๆ และเหตุผลที่หลักการเหล่านี้มีอยู่

จดหมายทีละตัวเราได้แจกแจงความหมายของหลักการแต่ละข้อด้วยตัวอย่างโค้ดด่วนที่ละเมิด จากนั้นเราจะเห็นวิธีแก้ไขโค้ดของเราและทำให้เป็นไปตามหลักการ SOLID

เช่นเคยรหัสสามารถใช้ได้บน GitHub