SOLID란?
- 객체지향 디자인 원리들을 사용하면 유지보수가 쉽고, 유연하고, 확장이 쉬운 소프트웨어를 개발할 수 있다.
- 이 원리들은 디자인 패턴보다 범위는 작지만 표준화 작업에서 아키텍처 설계에 이르기까지 다양하게 적용되고 있다.
- SOLID에는 5가지 원칙이 존재한다.
- SRP (Single Responsiblity Principle) - 단일 책임 원칙
- OCP (Open Closed Principle) - 개방 폐쇄 원칙
- LSP (Listov Substiution Principle) - 리스코브 치환 원칙
- ISP (Interface Segreation Principle) - 인터페이스 분리 원칙
- DIP (Dependency Inverstion Principle) - 의존성 역전 원칙
- SOLID를 통해 디자인 패턴들을 만들었다.
- SOLID의 5가지 원칙은 서로 독립된 개념이 아닌 서로 개념적으로 연관되어 있다.
- 원칙끼리 서로가 서로를 이용하기도하며 포함하기도 한다.
SRP(Single Responsiblity Principle)
- 클래스(객체)는 단 하나의 책임만 가져야 한다는 원칙
- 책임이라는 의미는 하나의 기능 담당을 뜻한다.
- 하나의 클래스는 하나의 기능을 담당하여 하나의 책임을 수행하는데 집중되도록 클래스를 따로 설계하라는 원칙이다.
- 만약 하나의 클래스에 기능(책임)이 여러개인 경우 기능 변경(수정)이 일어났을 때 수정해야할 코드가 많아진다.
- A 코드를 고쳤을 때 B 코드, C 코드를 연쇄적으로 수정해야할 일이 생길 수 있다.
- 마치, 책임이 순환되는 형태가 될 수 있다.
- SRP 원칙을 통해 한 책임의 변경으로부터 다른 책임의 변경으로 연쇄 작용을 극복할 수 있다.
- 단일 책임 원칙은 소프트웨어의 유지 보수 성을 높이기 위한 설계 기법이다.
- 이때 책임의 범위는 정해져 있는 것이 아닌 어떤 프로그램을 개발하냐에 따라 달라질 수 있다.
class Journal {
string m_title;
vector<string> m_entries;
public:
explicit Journal(const string &title) : m_title{title} {}
void add_entries(const string &entry) {
static uint32_t count = 1;
m_entries.push_back(to_string(count++) + ": " + entry);
}
auto get_entries() const { return m_entries; }
void save(const string &filename) {
ofstream ofs(filename);
for (auto &s : m_entries) ofs << s << endl;
}
};
int main() {
Journal journal{"Dear XYZ"};
journal.add_entries("I ate a bug");
journal.add_entries("I cried today");
journal.save("diary.txt");
return EXIT_SUCCESS;
}
- 위 코드는 Journal이라는 클래스를 구현한 코드이다.
- Journal 클래스는 save() 함수의 기능을 변경할 때 SRP 원칙을 위배한다.
- 만약, File이나 Database에 데이터를 save하지 않을 때 Journal 클래스를 수정해야하는 문제가 생긴다.
- 이는 Journal 클래스의 title, entries를 담고 있는 책임을 넘어서게 된다.
- Journal 클래스에 저장 기능을 다른 객체로 옮겨 저장하는 책임을 없애야한다.
class Journal {
string m_title;
vector<string> m_entries;
public:
explicit Journal(const string &title) : m_title{title} {}
void add_entries(const string &entry) {
static uint32_t count = 1;
m_entries.push_back(to_string(count++) + ": " + entry);
}
auto get_entries() const { return m_entries; }
//void save(const string &filename)
//{
// ofstream ofs(filename);
// for (auto &s : m_entries) ofs << s << endl;
//}
};
struct SavingManager {
static void save(const Journal &j, const string &filename) {
ofstream ofs(filename);
for (auto &s : j.get_entries())
ofs << s << endl;
}
};
SavingManager::save(journal, "diary.txt");
- SavingManager 라는 구조체를 통해 File 및 Database에 저장하는 책임을 부여하여 다른 저장 매체를 이용할 때 Journal 클래스는 수정되지 않도록 구현 하였다.
OCP(Open Closed Principle)
- 클래스(객체)는 확장에는 열려있고, 수정에는 닫혀있어야한다는 원칙
- 기능 추가 요청이 들어왔을 때 클래스의 확장을 손쉽게 구현하면서, 확장에 따른 클래스의 변경은 최소화 하도록 소프트웨어를 구현하는 설계 기법이다.
- Open - 새로운 변경 사항이 발생하였을 때 유연하게 코드를 추가함으로써 큰 힘을 들이지 않고 소프트웨어의 기능을 확장하는 것
- Closed - 새로운 변경 사항이 발생하였을 때 객체를 직접적으로 수정하지 않는 것
- OCP는 추상화를 통한 관계 구축을 권장하는 원칙이다.
- 다형성과 확장을 가능하게 하는 객체지향의 장점을 극대화하는 기본적인 설계 원칙
enum class COLOR { RED, GREEN, BLUE };
enum class SIZE { SMALL, MEDIUM, LARGE };
struct Product {
string m_name;
COLOR m_color;
SIZE m_size;
};
using Items = vector<Product*>;
#define ALL(C) begin(C), end(C)
struct ProductFilter {
static Items by_color(Items items, const COLOR e_color) {
Items result;
for (auto &i : items)
if (i->m_color == e_color)
result.push_back(i);
return result;
}
static Items by_size(Items items, const SIZE e_size) {
Items result;
for (auto &i : items)
if (i->m_size == e_size)
result.push_back(i);
return result;
}
static Items by_size_and_color(Items items, const SIZE e_size, const COLOR e_color) {
Items result;
for (auto &i : items)
if (i->m_size == e_size && i->m_color == e_color)
result.push_back(i);
return result;
}
};
int main() {
const Items all{
new Product{"Apple", COLOR::GREEN, SIZE::SMALL},
new Product{"Tree", COLOR::GREEN, SIZE::LARGE},
new Product{"House", COLOR::BLUE, SIZE::LARGE},
};
for (auto &p : ProductFilter::by_color(all, COLOR::GREEN))
cout << p->m_name << " is green\n";
for (auto &p : ProductFilter::by_size_and_color(all, SIZE::LARGE, COLOR::GREEN))
cout << p->m_name << " is green & large\n";
return EXIT_SUCCESS;
}
// 출력
Apple is green
Tree is green
Tree is green & large
- 위는 Product의 속성을 Filter를 통해 분리하여 출력해본 코드이다.
- 만약 요구사항이 변경되면 Product와 Filter를 수정해야한다.
- 기존에는 2가지 속성(color, size)와 3가지 기능(color, size, color와 size의 조합)이 있다.
- 만약 1가지 속성이 추가된다면 Product에 1개의 속성을 추가하고 Filter에 8가지 기능을 구현해야한다.
- 이는 기존의 코드를 계속해서 수정해야하며 이로인해 코드의 다른 부분에 의도하지 않은 에러가 발생할 수 있다.
- 이러한 상황은 OCP를 위반하는 기존 코드를 수정하는 일을 하게 된다.
template <typename T>
struct Specification {
virtual ~Specification() = default;
virtual bool is_satisfied(T *item) const = 0;
};
struct ColorSpecification : Specification<Product> {
COLOR e_color;
ColorSpecification(COLOR e_color) : e_color(e_color) {}
bool is_satisfied(Product *item) const { return item->m_color == e_color; }
};
struct SizeSpecification : Specification<Product> {
SIZE e_size;
SizeSpecification(SIZE e_size) : e_size(e_size) {}
bool is_satisfied(Product *item) const { return item->m_size == e_size; }
};
template <typename T>
struct Filter {
virtual vector<T *> filter(vector<T *> items, const Specification<T> &spec) = 0;
};
struct BetterFilter : Filter<Product> {
vector<Product *> filter(vector<Product *> items, const Specification<Product> &spec) {
vector<Product *> result;
for (auto &p : items)
if (spec.is_satisfied(p))
result.push_back(p);
return result;
}
};
// ------------------------------------------------------------------------------------------------
BetterFilter bf;
for (auto &x : bf.filter(all, ColorSpecification(COLOR::GREEN)))
cout << x->m_name << " is green\n";
- 위는 추상화를 통해 확장성을 고려한 코드이다.
- BetterFilter의 filter() 함수를 수정하지 않아도 된다.
- Specification을 변경된 요구사항에 따라 변경하면 된다.
LSP(Listov Substiution Principle)
- 자식 타입은 언제 어디서나 부모 타입으로 교체될 수 있어야한다는 원칙
- LSP는 다형성 원리를 이용하기 위한 원칙 개념이다.
- 다형성 특징을 이용하기 위해 상위 클래스 타입으로 객체를 선언하여 하위 클래스의 인스턴스를 받으면, 업케스팅된 상태에서 부모의 메소드를 사용해도 동작이 의도한대로 흘러가야한다는 의미
- 기본적으로 LSP는 부모 메소드의 오버라이딩을 따져가며 해야한다.
- 부모 클래스의 동일한 선행 조건을 기대하고 사용하는 코드에서 예상하지 못한 오류를 이르킬 수 있다.
struct Rectangle {
Rectangle(const uint32_t width, const uint32_t height) : m_width{width}, m_height{height} {}
uint32_t get_width() const { return m_width; }
uint32_t get_height() const { return m_height; }
virtual void set_width(const uint32_t width) { this->m_width = width; }
virtual void set_height(const uint32_t height) { this->m_height = height; }
uint32_t area() const { return m_width * m_height; }
protected:
uint32_t m_width, m_height;
};
struct Square : Rectangle {
Square(uint32_t size) : Rectangle(size, size) {}
void set_width(const uint32_t width) override { this->m_width = m_height = width; }
void set_height(const uint32_t height) override { this->m_height = m_width = height; }
};
void process(Rectangle &r) {
uint32_t w = r.get_width();
r.set_height(10);
assert((w * 10) == r.area()); // Fails for Square <--------------------
}
int main() {
Rectangle r{5, 5};
process(r);
Square s{5};
process(s);
return EXIT_SUCCESS;
}
- 위는 Rectangle is a Square라는 수학적 관점에서 구현한 코드이다.
- Square은 어디에서나 Rectangle로 치환되어야한다.
- 하지만 Square의 process() 함수에서 LSP를 위반한 것을 볼 수 있다.
- Square은 Rectangle에 치환되지 않는 문제를 가지고 있다.
- Square는 width와 height가 항상 같은 값을 가지고 있어야하지만 Rectangle로 치환 시 같은 값을 가지게 되지 못한다.
- 쉽게 말하면 Square는 size정보만 가지고 있어도 area를 구할 수 있지만 Rectangle로 치환시 이를 만족하지 못함
struct Shape {
virtual uint32_t area() const = 0;
};
struct Rectangle : Shape {
Rectangle(const uint32_t width, const uint32_t height) : m_width{width}, m_height{height} {}
uint32_t get_width() const { return m_width; }
uint32_t get_height() const { return m_height; }
virtual void set_width(const uint32_t width) { this->m_width = width; }
virtual void set_height(const uint32_t height) { this->m_height = height; }
uint32_t area() const override { return m_width * m_height; }
private:
uint32_t m_width, m_height;
};
struct Square : Shape {
Square(uint32_t size) : m_size(size) {}
void set_size(const uint32_t size) { this->m_size = size; }
uint32_t area() const override { return m_size * m_size; }
private:
uint32_t m_size;
};
void process(Shape &s) {
// Use polymorphic behaviour only i.e. area()
}
- Rectangle과 Square의 부모를 Shape 클래스로 하여 LSP를 만족하도록 구현하였다.
- Rectangle와 Square의 area를 구하는 방식을 Shape의 area를 overriding 하도록 변경하여 언제 어디서나 Shape로 치환되도록 하였다.
- Shape 클래스를 구현하지 않고 process 자체에서 Rectangle인지 Square인지 구분하여 LSP를 만족하도록 할 수도 있다.
ISP(Interface Segregation Principle)
- 인터페이스를 각각 사용에 맞게 끔 잘게 분리해야한다는 설계 원칙이다.
- SRP가 클래스의 단일 책임을 강조한다면, ISP는 인터페이스의 단일 책임을 강조한다.
- SRP의 목표는 클래스 분리를 통해 이루어진다면, ISP의 목표는 인터페이스의 분리를 통해 이루어진다.
- 인터페이스를 사용하는 클라이언트를 기준으로 분리함으로써, 클라이언트의 목적과 용도에 적합한 인터페이스만을 제공하는 것이 목표이다.
- 단, 한번 인터페이스를 분리하여 구성해놓고 나중에 무언가 수정 사항이 생겨 또 인터페이스를 분리하는 행위를 해서는 않된다.
- 인터페이스는 한번 구성하면 왠만하면 수정해서는 안된다는 정책 개념
struct Document;
struct IMachine {
virtual void print(Document &doc) = 0;
virtual void fax(Document &doc) = 0;
virtual void scan(Document &doc) = 0;
};
struct MultiFunctionPrinter : IMachine { // OK
void print(Document &doc) override { }
void fax(Document &doc) override { }
void scan(Document &doc) override { }
};
struct Scanner : IMachine { // Not OK
void print(Document &doc) override { /* Blank */ }
void fax(Document &doc) override { /* Blank */ }
void scan(Document &doc) override {
// Do scanning ...
}
};
- MultiFunctionPrinter는 IMachine 인터페이스에 의해 구현해야할 print(), fax(), scan() 메소드를 구현하여도 아무 문제가 없다.
- 하지만 Scanner인 경우 IMachine 인터페이스에 의해 구현할 책임이 있는 print(), fax() 메소드를 구현할 필요가 없다.
- 이는 ISP를 위반한 사례이며, 인터페이스의 책임을 분리하여 Scanner에 제공해야한다는 것을 알 수 있다.
/* -------------------------------- Interfaces ----------------------------- */
struct IPrinter {
virtual void print(Document &doc) = 0;
};
struct IScanner {
virtual void scan(Document &doc) = 0;
};
/* ------------------------------------------------------------------------ */
struct Printer : IPrinter {
void print(Document &doc) override;
};
struct Scanner : IScanner {
void scan(Document &doc) override;
};
struct IMachine : IPrinter, IScanner { };
struct Machine : IMachine {
IPrinter& m_printer;
IScanner& m_scanner;
Machine(IPrinter &p, IScanner &s) : printer{p}, scanner{s} { }
void print(Document &doc) override { printer.print(doc); }
void scan(Document &doc) override { scanner.scan(doc); }
};
- IPrinter와 IScanner 인터페이스로 print와 scan의 책임을 분리하고 IMachine 인터페이스는 2 기능을 모두 사용할 경우 사용하도록 한다.
- Scanner와 Machine 모두 필요한 책임만을 가진 인터페이스를 구현하고 있다.
DIP(Dependency Inverstion Principle)
- 어떤 클래스를 참조하여 사용할 때, 그 클래스를 직접 참조하는 것이 아닌 그 대상의 상위 요소(추상 클래스 | 인터페이스)로 참조하라는 원칙
- 구현 클래스에 의존하지 말고, 인터페이스에 의존하라는 의미
- 의존 관계를 맺을 때 변화하기 쉬운 것 또는 자주 변화하는 것보다는 변화하기 어려운 것 거의 변화하지 않는 것에 의존하라는 것
- DIP의 지향점은 각 클래스간 결합도(coupling)를 낮추는 것이다.
enum class Relationship { parent, child, sibling };
struct Person {
string m_name;
};
struct Relationships { // Low-level <<<<<<<<<<<<-------------------------
vector<tuple<Person, Relationship, Person>> m_relations;
void add_parent_and_child(const Person &parent, const Person &child) {
m_relations.push_back({parent, Relationship::parent, child});
m_relations.push_back({child, Relationship::child, parent});
}
};
struct Research { // High-level <<<<<<<<<<<<------------------------
Research(const Relationships &relationships) {
for (auto &&[first, rel, second] : relationships.m_relations) {// Need C++17 here
if (first.m_name == "John" && rel == Relationship::parent)
cout << "John has a child called " << second.m_name << endl;
}
}
};
int main() {
Person parent{"John"};
Person child1{"Chris"};
Person child2{"Matt"};
Relationships relationships;
relationships.add_parent_and_child(parent, child1);
relationships.add_parent_and_child(parent, child2);
Research _(relationships);
return EXIT_SUCCESS;
}
- 위 코드는 나중에 Relationship이라는 struct의 멤버인 vector가 set으로 바뀌거나 다른 컨테이너를 사용해야하면 여러 부분이 수정되어야 한다.
- Relationship의 멤버 변수인 Relationship::m_relations의 이름만 바뀌여도 여러 부분이 바뀌어야한다.
- Low-level 모듈인 Relationship은 High-level 모듈인 Research와 직접적으로 의존하게 된다. 이는 DIP를 위반하는 것이다.
struct RelationshipBrowser {
virtual vector<Person> find_all_children_of(const string &name) = 0;
};
struct Relationships : RelationshipBrowser { // Low-level <<<<<<<<<<<<<<<------------------------
vector<tuple<Person, Relationship, Person>> m_relations;
void add_parent_and_child(const Person &parent, const Person &child) {
m_relations.push_back({parent, Relationship::parent, child});
m_relations.push_back({child, Relationship::child, parent});
}
vector<Person> find_all_children_of(const string &name) {
vector<Person> result;
for (auto &&[first, rel, second] : m_relations) {
if (first.name == name && rel == Relationship::parent) {
result.push_back(second);
}
}
return result;
}
};
struct Research { // High-level <<<<<<<<<<<<<<<----------------------
Research(RelationshipBrowser &browser) {
for (auto &child : browser.find_all_children_of("John")) {
cout << "John has a child called " << child.name << endl;
}
}
// Research(const Relationships& relationships)
// {
// auto& relations = relationships.relations;
// for (auto&& [first, rel, second] : relations)
// {
// if (first.name == "John" && rel == Relationship::parent)
// {
// cout << "John has a child called " << second.name << endl;
// }
// }
// }
};
- 추상화를 통해 상위, 하위 모듈이 인터페이스에 의존하도록 수정한 코드이다.
- RelationshipBrowser 인터페이스와 Relationship, Research는 의존 관계를 맺고 있다.
- Relationship::m_relations의 이름이 바뀌어도 Research는 아무 영향을 받지 않는다.
- RelationshipBrowser의 find_all_children_of() 함수에 의해 자식들을 찾고 있기 때문
- C++에서 DIP는 STL이나 다른 라이브러리를 통해 만족 시킬 수 있다.
- 만약, Vector가 아닌 다른 컨테이너를 사용해도 STL에서 제공하는 iterator를 공통으로 사용하기 때문에 관계를 맺은 다른 클래스는 영향을 받지 않는다.
- 이는 C++에서 제공하는 auto 키워드에 의해 이루어진다.
출처
https://www.nextree.co.kr/p6960/
https://www.vishalchovatiya.com/category/design-patterns/
'CS > Software Engineering' 카테고리의 다른 글
[Software Engineering] 객체지향 프로그래밍 (OOP) (0) | 2024.01.23 |
---|