Note: This is part of a series of articles reviewing the five SOLID Principles of object-oriented programming.
The Single Responsibility Principle was first coined by Robert Martin in an article on the Principles of Object Oriented Design. To this day, it remains an important design principle because it encourages a lowly-coupled system with highly-cohesive classes. This type of system is much more maintainable because it is easier to modify.
Before we dive into the principle itself, we need to address two important concepts in object-oriented programming: coupling and cohesion.
Coupling
Coupling is used to describe the degree of dependency between individual classes. Classes are "highly coupled" if one class is directly affected by the behavior of another class in the system. If many classes are dependent on each other, a change in one may lead to a breaking ripple effect! This makes the system hard to change because many more parts need to be tested, fixed, and deployed.We should always strive for classes that aren't too dependent on each other. This leads to a "lowly coupled" system, which is much easier to maintain.
Cohesion
Cohesion is used to describe the degree of similarity between the internal elements of a single class. A class with a variety of methods spanning many unrelated behaviors is said to have "low cohesion". If a class is designed this way, it will have dependencies with several unrelated classes. This leads to a monolithic class with tangles over many different parts of the system!We should always strive for classes that encapsulate very closely-related behaviors. This leads to a set of "highly cohesive" classes, making it easier to decouple different parts of the system.
Cohesion and Decoupling through Single Responsibility
Robert Martin describes the Single Responsibility Principle as:"A class should have only one reason to change."
In other words, a class should be responsible for providing only one specific function or behavior in a given system. When a class only has one responsibility, its internal elements are sure to be closely related, making it very highly cohesive. A system with many small, focused classes will have less dependencies because no single class will have to interact with too many other classes. This helps keep the system lowly coupled.
Let's look at a simple example.
Breaking Down a Monolithic Class
Suppose we have a simple class for representing bodies of text. We'll call it Document:class Document attr_accessor :author, :text def initialize(author, text) @author = author @text = text end def save_to_file(filename) File.open(filename, 'w') do |file| file.write(full_text) end end def print_as_pdf pdf_creator = PDFCreator.new(full_text) pdf_creator.print end def print_as_html html_creator = HTMLCreator.new(full_text) html_creator.print end def send_to_email(email) email_sender = Mailer.new(email) email_sender.send(full_text) end def full_text "Author: #{@author}, Text: #{@text}" end endHere is a list of all the behaviors Document is responsible for:
- Saving the text to disk.
- Printing the text as a PDF or HTML page.
- Sending the text via email.
How about we break up each individual responsibility into its own class?
Small, Single-Responsibility Classes
Using our list of behaviors, we'll create new classes for each responsibility.Saving the text to disk.
We'll create a class whose sole purpose is to save text to the file system. We'll call it DocumentFile:
class DocumentFile def initialize(filename, document) @filename = filename @document = document end def save! File.open(filename, 'w') do |file| file.write(document.full_text) end end endNow, our Document can make use of DocumentFile to write its text contents to disk:
document = Document.new('Thomas Harris', 'The story of Silence of the Lambs...') file = DocumentFile.new('silence.txt', document) data_file.save!If we wanted to add the ability to load an existing Document from the file system, we have a dedicated class for doing file input/output. We don't have to clutter our Document class to add functionality.
Printing the text as a PDF or HTML page.
We'll extract any methods for printing different formats out into a class called DocumentPrinter. This will make use of two imaginary Gems named PDFCreator and HTMLCreator. Their implementations aren't important, but it helps describe the behavior of the methods:
class DocumentPrinter def initialize(document) @document = document end def print_as_pdf pdf_creator = PDFCreator.new(@document.full_text) pdf_creator.print end def print_as_html html_creator = HTMLCreator.new(@document.full_text) html_creator.print end endSimple enough. Now, we have a class dedicated to printing different formats of our Documents:
document = Document.new('Stephen King', 'The story of Birds...') document_printer = DocumentPrinter.new(document) document_printer.print_as_pdf # Results in PDF data being printed to screen. document_printer.print_as_html # Results in HTML being printed to screen.Exercise: This could be taken even further by breaking each method and creating two new classes: DocumentPDFPrinter and DocumentHTMLPrinter. Each could implement a Printer interface. This might be a good idea, since we may require printing of other formats in the future.
Sending the text via email.
Finally, we'll create a new class whose sole responsibility is to send the Document to someone via email. Again, this will make use of an imaginary Gem named Mailer. We'll call our new class DocumentSender:
class DocumentSender def initialize(document) @document = document end def send_to_email(email) email_sender = Mailer.new(email) email_sender.send(@document.full_text) end endNow, we have a class whose only responsibility is sending a Document over email.
document = Document.new('Stephen King', 'The story of Birds...') document_sender = DocumentSender.new(document) document_sender.send_to_email('test@test.com') # Sends text in document to test@test.com.
Conclusion
In the end, we have three individual classes, each with their own responsibilities. If we need to add another feature, we can create another class without affecting any of the existing classes. We can more easily modify existing functionality because we can pinpoint where changes need to be made based on each responsibility.This whole example can be taken even further by making our Sender and File more generic. In other words, decouple them from Document by taking any data and sending or saving it. I'll leave that as an exercise for the reader.
Happy coding!
No comments:
Post a Comment