[1] 객체지향적 설계

1. 응집도

응집도는 클래스 또는 모듈 내부의 구성 요소들이 얼마나 밀접하게 관련되어 있는지를 나타낸다. 응집도는 높을수록 좋다.

반대로 응집도가 낮은 경우란 서로 관련 없는 기능들이 하나의 클래스에 포함된 경우를 말한다. 관련 없는 기능들이 모여있으면, 나중에 유지 보수가 어려워진다.

한 클래스에는 밀접하게 관련 있는 기능들만 모아놓을 수 있도록 하자.

 

2. 결합도

결합도는 모듈 또는 클래스 간의 의존성을 나타낸다. 결합도는 낮을수록 좋다.

모듈 또는 클래스 간의 의존성이 강해지면 하나의 모듈이 변경될 때 다른 모듈도 영향을 받게 된다. 다시말해 유지보수가 어려워진다.

#include <iostream>
#include <string>
using namespace std;

class Engine {
public:
 string state;
 Engine() : state("off") {}
 void start() {
 state = "on";
 }
};

class Car {
public:
 Engine engine;
 
 void startCar() {
 if (engine.state == "off") {
 engine.start();
 cout << "Car started" << endl;
 }
 }
};

int main() {

 return 0;
}

Car 클래스가 Engine 클래스에 강하게 의존하고 있다. 이렇게 되면 main() 함수에서 ElectricEngine을 사용하고 싶을 때 Car 클래스를 넘어 Engine까지 전체 구조를 수정해야 한다.

 

결합도가 낮아지는 일을 방지하려면 결국 코드를 작성하기 전에 아키텍처를 그리고 가는게 좋아보인다. 예를 들어, 위의 Car과 Engine의 관계를 보면, 자동차는 Engine이 장착되어 있고, 자동차에 시동이 걸리면 엔진이 동작한다. 엔진의 종류가 Diesel과 Electric으로 나뉘기 때문에 자동차가 여러 종류의 엔진에 엮여야 한다.

 

따라서 결합도를 낮추기 위해서는 Car 클래스가 DieselEngine, ElectricEngine으로 직접 포함하게 할 것이 아니라, 인터페이스를 활용해서 새로운 엔진을 추가해도 자동차 코드를 수정할 필요가 없게 만드는 것이 좋아보인다.

#include <iostream>
#include <memory>
using namespace std;

//Engine 클래스
class Engine {
public:
 virtual void start() = 0;
 virtual ~Engine() = default;
};

//DieselEngine 클래스는 Engine 클래스에 붙이고
class DieselEngine : public Engine {
public:
 void start() {
 cout << "Diesel Engine started" << endl;
 }
};

//Electric 클래스는 Engine 클래스에 붙이고
class ElectricEngine : public Engine {
public:
 void start() {
 cout << "Electric Engine started silently" << endl;
 }
};

// Car 클래스는 Engine 인터페이스에만 의존하게 하기
class Car {
private:
 unique_ptr<Engine> engine;
public:
 Car(unique_ptr<Engine> eng) : engine(move(eng)) {}
 void startCar() {
 engine->start();
  cout << "Car started" << endl;
 }
};

int main() {
 // DieselEngine을 사용할 때
 auto dieselEngine = make_unique<DieselEngine>();
 Car dieselCar(move(dieselEngine));
 dieselCar.startCar();
 
 // ElectricEngine을 사용할 때
 auto electricEngine = make_unique<ElectricEngine>();
 Car electricCar(move(electricEngine));
 electricCar.startCar();
 return 0;
}

1) Car 클래스는 Diesel/Electric엔진을 참조하지 않고 추상 클래스인 Engine 인터페이스에만 의존하게 한다.

2) Car 클래스에서는 unique_ptr을 생성자 매개변수로 받아서 사용. Car클래스는 어떤 엔진과 동작하는지 알 필요가 없어졌다.

3) 다른 엔진 모델이 추가가 된다고 해도, Car 클래스의 코드는 수정할 필요가 없어졌다. 마찬가지로 Engine 추상 클래스의 start()함수만 유지되면 Car 클래스는 영향을 받지 않는다.

4) Engine 클래스의 경우 순수 가상 함수를 사용하면서 추상 클래스가 되었다. 부모 클래스로서 자식 클래스(Diesel, Electric, ...) 의 기능을 일관되게 호출할 목적이다. 

 

3. SOLID 원칙

진정한 객체지향 프로그래밍을 하기 위한 설계 원칙이 SOLID 원칙이다. 유지 보수성과 확장성을 위해서 코드를 작성할 때 체크할 수 있도록 한다.

  1. 단일 책임 원칙(Single Responsibility Principle, SRP) : 하나의 클래스는 하나의 역할을
  2. 개방-폐쇄 원칙(Open-Closed Princciple, OCP) : 기존 코드 수정 없이 기능을 확장할 수 있도록
  3. 리스코프 치환 원칙(Liskov Substitution Principle, LSP) : 자식은 부모를 완벽히 대체 가능하도록
  4. 인터페이스 분리 원칙(Interface Segregation Principle, ISP) : 필요한 인터페이스만 제공하도록
  5. 의존 역전 원칙(Dependency Inversion Principle, DIP) : 인터페이스나 추상 클래스에 의존하도록

[2] 디자인 패턴

1. 생성 패턴(싱글톤 패턴의 경우)

새로운 객체를 만들어내는 방법과 관련된 패턴. 싱글톤 패턴의 경우, 프로그램 전체에서 특정 클래스의 인스턴스를 단 하나만 생성하고 어디서든 동일한 인스턴스에 접근할 수 있도록 보장하는 패턴이다.

#include <iostream>
using namespace std;

class Airplane {

private:	// 1)
	static Airplane* instance;
	int positionX;
	int positionY;

	Airplane(): positionX(0), positionY(0) {
		cout << "Airplane Created at (" << positionX << ", " << positionY << ")" << endl;
	}

public:
	Airplane(const Airplane&) = delete;	// 3)
	Airplane& operator = (const Airplane&) = delete;

	static Airplane* getInstance() {	//2)
		if (instance == nullptr) {
			instance = new Airplane();
		}
		return instance;
	}

	void move(int deltaX, int deltaY) {
		positionX += deltaX;
		positionY += deltaY;
		cout << "Airplane moved to (" << positionX << ", " << positionY << ")" << endl;
	}

	void getPosition() const {
		cout << "Airplane Position: (" << positionX << ", " << positionY << ")" << endl;
	}
};

Airplane* Airplane::instance = nullptr;

int main() {

	Airplane* airplane = Airplane::getInstance();	// 4)
	airplane->move(10, 20);
	airplane->getPosition();

	Airplane* sameairplane = Airplane::getInstance();	// 4)
	sameairplane->move(-5, 10);
	sameairplane->getPosition();

	return 0;
}

1) Airplane() 생성자를 private 영역에 배치해서 외부에서 new 키워드를 사용해서 마음대로 비행기 객체를 생성하지 못하도록 막는다. 그 대신 Airplane* 타입의 정적 변수인 instance를 두어서 이 변수가 유일한 객체의 주소를 저장하도록 함.

2) 외부에서 이 클래스를 사용하려면 반드시 public영역에서 static으로 선언된 getInstance() 함수를 호출해야 함. 처음 호출될 때만 객체를 생성하고 그 이후부터는 이미 만들어진 객체의 주소만 반환하기 때문에 어떤 프로그램의 위치에서든 유일한 비행기 객체를 불러내도록 함. (Airplane::getInstance(); 방식으로 접근)

3) 싱글톤 패턴의 안전장치로써 코드 중간에 delete키워드가 붙은 복사 생성자와 대입 연산자를 사용. 실수로 객체를 복사하는 상황을 막는 장치.

4) 결국 airplane, sameairplane이라는 다른 이름의 포인터를 사용해도 실제로 같은 메모리 공간을 가리키게 됨. 두 변수가 하나의 비행기를 조종하게 하였음.

2. 구조 패턴(데코레이터 패턴의 경우)

여러 개의 객체들의 구조를 어떻게 구성할지에 관련된 패턴. 객체에 동적으로 새로운 책임(기능)을 추가할 수 있게 해주는 패턴이다.

기본 객체를 중심에 두고, 다른 기능을 가진 클래스로 감싸는 것이다.

추상 클래스 Pizza,

기본 객체인 BasicPizza,

데코레이터 PizzaDecorator,

구체적인 기능을 가진 클래스 Cheese, Pepperoni, Olive.

메인함수에서는 토핑을 더한 후의 피자 이름과 피자 가격이 출력되도록 한다.

#include <iostream>
#include <string>

using namespace std;

class Pizza {

public:
	virtual ~Pizza() {}
	virtual string getName() const = 0;
	virtual double getPrice() const = 0;
};


class BasicPizza : public Pizza {

public:
	string getName() const {
		return "Basic Pizza";
	}
	double getPrice() const {
		return 5.0;
	}
};

class PizzaDecorator : public Pizza {

protected:
	Pizza* pizza;
	
public:
	PizzaDecorator(Pizza* p) : pizza(p) {}

	virtual ~PizzaDecorator() {
		delete pizza;
	}
};

class CheeseDecorator : public PizzaDecorator {
public:
	CheeseDecorator(Pizza* p) : PizzaDecorator(p) {}
	string getName() const {
		return pizza->getName() + " + Cheese";
	}
	double getPrice() const {
		return pizza->getPrice() + 1.5;
	}
};

class PepperoniDecorator : public PizzaDecorator {
public:
	PepperoniDecorator(Pizza* p) : PizzaDecorator(p) {}
	string getName() const {
		return pizza->getName() + " + Pepperoni";
	}
	double getPrice() const {
		return pizza->getPrice() + 2.0;
	}
};

class OliveDecorator : public PizzaDecorator {
public:
	OliveDecorator(Pizza* p) : PizzaDecorator(p) {}
	string getName() const {
		return pizza->getName() + " + Olive";
	}
	double getPrice() const {
		return pizza->getPrice() + 0.7;
	}
};

int main() {

	Pizza* pizza = new BasicPizza();

	pizza = new CheeseDecorator(pizza);
	pizza = new PepperoniDecorator(pizza);
	pizza = new OliveDecorator(pizza);

	cout << "Pizza: " << pizza->getName() << endl;
	cout << "Price: $" << pizza->getPrice() << endl;

	delete pizza;

	return 0;
}

1) 추상 클래스 Pizza를 정의하고 모든 피자가 갖출 규칙을 정의. Pizza를 상속받는 모든 클래스는 반드시 이름과 가격을 알려주는 기능을 구현해야 함.

2) Pizza를 상속받는 BasicPizza 클래스. 이름과 가격을 "Basic Pizza", 5.0 으로 구현했다.

3) Pizza를 상속받는 PizzaDecorator 클래스. 생성자에서 Pizza* p 를 받아서 멤버 변수에 저장하고, 소멸자에서는 자신이 감싸고 있던 피자 객체까지 함께 메모리에서 해제한다. Pizza* pizza; 라고 하면, 최종적으로는 모든 토핑이 붙은 pizza가 남아있게 된다.

(소멸자?)소멸자는 클래스 이름 앞에 ~ 기호를 붙여 정의하고, 객체가 사라질 때 그 자원을 다시 시스템에 돌려준다. 상속관계가 있는 클래스에서는 소멸자에 virtual 키워드를 붙이는 것이 매우 중요. 부모 클래스인 Pizza의 소멸자에 virtual이 없다면 부모 타입의 포인터로 자식 객체를 삭제할 때 부모의 소멸자만 호출되고 자식 클래스의 토핑이나 기능들이 메모리에 그대로 남아버린다. 모든 자원을 해제하기 위해 virtual을 붙이는 것.

4) 구체적인 토핑 클래스에서는 PizzaDecorator 클래스를 상속받는다. 따라서 반드시 부모 클래스의 생성자를 먼저 호출해야 한다. 

CheeseDecorator(Pizza* p) : ==> 치즈 자식 클래스가 만들어질 때 피자 p 하나를 받고, : (콜론) 을 통해 초기화를 한다.

PizzaDecorator(p) {} ==> 내가 받은 p (피자)를 PizzaDecorator 클래스의 생성자에게 그대로 전달한다. 부모가 할일을 줬으니 딱히 더 할건 없다.

5) 메인 함수는 이름과 가격이 합산된다.

생성자를 통해 새로운 BasicPizza가 만들어진다. 이 때 부모 클래스인 Pizza* 타입의 포인터로 받는다. pizza라는 객체의 주소값을 계속 Decorator에서 가리키게 만들어서 새로운 pizza 객체로 만들어줘야 하기 때문이다. 또한 pizza->getName(), pizza->getPrice()와 같이 매번 똑같은 명령을 쉽게 내릴 수 있다.

6) 결론적으로 새로운 토핑이 필요하면 새로운 Decorator 클래스 하나를 추가하면 된다. (개방-폐쇄 원칙의 사례이기도 함)

 

만약 Decorator 가 없으면 어떻게 될까.

핵심은 코드 중복의 발생, 관리 효율성 문제이다.

 

1) 포인터와 로직 변경

포인터 Pizza* pizza 대신에 unique_ptr을 사용하기로 변경되었을 때, Decorator가 있으면 그냥 포인터 타입과 로직을 한번만 수행하면 끝난다. 그 아래 자식 클래스는 건드릴 필요가 거의 없다.

반면 Decorator가 없으면 Cheese, Pepperoni... 등의 클래스에 포인터 Pizza* pizza가 직접 삽입되어 있을 것이기 때문에, 일일이 전부 찾아가서 포인터와 로직을 수정해주어야 한다.

 

2) 토핑을 추가하는 과정에서 추가적인 기능을 삽입할 때 (토핑 개수를 세는 기능을 삽입하는 사례)

예를 들어 Decorator가 있으면 부모 클래스인 Decorator에 함수를 만들면 모든 자식 클래스에 기능이 자동으로 적용된다.

반면 Decorator가 없으면 모든 개별 토핑 클래스에 변수와 로직을 복사해서 일일이 붙여넣어야 한다. 

 

3) delete 과정을 보장함.

Decorator 클래스는 토핑 클래스들의 공통된 포인터와 공통된 소멸 행위를 하나로 모아 관리할 수 있게 해준다.

Decorator 클래스가 없다면 delete 과정도 모든 개별 토핑 클래스에 붙여 넣어주며 신경써야한다.

 

3. 행동 패턴(옵저버 패턴의 경우)

특정 개체가 변할 때 다른 객체들과의 상호작용과 관련된 패턴. 데이터의 변경을 여러 객체에 자동으로 알린다.

Subject는 상태를 관리하고 변경되었음을 옵저버에게 알리고,

Observer는 Subject를 관찰하며 상태 변경 시 반응한다.

#include <iostream>
#include <vector>
#include <string>
#include <algorithm>

using namespace std;

// 관찰 대상의 변화를 전달받음(인터페이스)
class Observer {
public:
    virtual ~Observer() = default;
    virtual void update(int data) = 0;
};

// 데이터의 상태를 가지고 있는 관찰 대상(주체)
class ExcelSheet {
private:
    vector<Observer*> observers;
    int data;

public:
    ExcelSheet() : data(0) {}

    //옵저버를 등록
    void attach(Observer* observer) {
        observers.push_back(observer);
    }

    //옵저버를 해제
    void detach(Observer* observer) {
        observers.erase(remove(observers.begin(), observers.end(), observer), observers.end());
    }

    //모든 옵저버에게 변경 사항을 알리기
    void notify() {
        for (Observer* observer : observers) {
            observer->update(data);
        }
    }

    // 데이터를 수정하고 알림 보내기
    void setData(int newData) {
        data = newData;
        cout << "ExcelSheet: Data updated to " << data << endl;
        notify();
    }
};

//옵저버: 막대 그래프
class BarChart : public Observer {
public:
    void update(int data) override {
        cout << "BarChart: Displaying data as vertical bars: ";
        for (int i = 0; i < data; ++i) {
            cout << "|";
        }
        cout << " (" << data << ")" << endl;
    }
};

//옵저버: 선 그래프
class LineChart : public Observer {
public:
    void update(int data) override {
        cout << "LineChart: Plotting data as a line: ";
        for (int i = 0; i < data; ++i) {
            cout << "-";
        }
        cout << " (" << data << ")" << endl;
    }
};

//옵저버: 파이 차트
class PieChart : public Observer {
public:
    void update(int data) override {
        cout << "PieChart: Displaying data as a pie chart slice: ";
        cout << "Pie [" << data << "%]" << endl;
    }
};

int main() {
    ExcelSheet excelSheet;

    BarChart* barChart = new BarChart();
    LineChart* lineChart = new LineChart();
    PieChart* pieChart = new PieChart();

    excelSheet.attach(barChart);
    excelSheet.attach(lineChart);
    excelSheet.attach(pieChart);

    excelSheet.setData(5);
    excelSheet.setData(10);

    delete barChart;
    delete lineChart;
    delete pieChart;

    return 0;
}

1) Observer 클래스 : 모든 차트 객체들의 부모인 추상 클래스. ExcelSheet 라는 관찰 대상으로부터 변화를 전달받기 위해 update 함수 정의.

vector<Observer*> observers; 를 선언해서 옵저버 명단을 담을 수 있는 공간을 만들었다.

2) ExcelSheet 클래스 : 변화의 주체(Subject). 내부적으로 데이터를 보관하고, 데이터가 변할 때마다 옵저버들에게 알린다.

attach(), detach() 함수를 통해서 관찰 대상에 알림을 받을 객체(Observer)를 추가(push_back)하거나 제거(erase)할 수 있다. 이후 notify() 함수를 통해 등록된 모든 옵저버의 update() 함수를 순차적으로 호출(for문을 사용해서 Observers 명단을 확인)하고, 업데이트 하라고 신호만 보낸다. setdata() 함수는 데이터를 실제로 변경하는 함수로, 값이 바뀌면 자동으로 notify() 함수를 호출하게 했다(데이터가 변경되면 Observer에게 알림을 보내도록).

3) 각각의 Chart 클래스는 Observer를 상속받아 실제로 화면에 데이터를 그리는 구체적인 객체들이다.

4) main함수에서 new 객체들을 생성 후, attach() 함수를 통해 서로 연결한다. 마지막에는 동적 할당한 메모리를 해제한다.

 

*옵저버 명단 제거 시 쓰는 함수가 어려워서 적었음*

// observers.erase(remove(observers.begin(), observers.end(), observer), observers.end());

remove 함수는 내가 삭제할 데이터를 제외한 '살려야 할 데이터를 앞으로 밀어내고', '삭제할 데이터를 맨 뒤로 보낸 다음', '삭제하고 싶은 데이터가 시작되는 위치를 가리키고 반환'하는 함수이다. 따라서 erase와 함께 자주 사용된다.

erase 함수는 시작지점부터 끝지점까지를 잘라내는 함수이다.

이 둘이 결합해서 옵저버 명단을 제거하는 것임.

이런 머리 아픈 건 안쪽에 있는 것부터 확인하면 된다. 

1) remove(observers.begin(), observers.end(), observer) : observers 명단의 처음부터 끝까지 훑어서, 우리가 삭제하고 싶은 observer 찾기. 찾으면 해당 observer를 맨 뒤로 밀어내고, 삭제할 observer가 시작되는 지점의 주소값을 반환한다.

2) observers.erase(remove(), observers.end()); : remove()를 통해 삭제할 observer가 시작되는 지점의 주소값이 반환되었고, erase()를 실행할 시작점이 정해졌다. 당연히 삭제할 녀석이 맨 뒤로 간 상태이므로 observers.end()를 통해 끝부분까지 삭제하면 된다.

3) 공식처럼 정리하면 observers.erase(remove(시작, 끝, 삭제하고 싶은 대상), observers.end()); 꼴로 사용할 수 있다.

이렇게 사용하면 중간에 있는 걸 지우면 뒤의 데이터를 다 옮겨와야 되는 불상사를 미리 방지할 수 있다.

 


* 오늘의 코드카타 *

 

자연수 N이 주어지면, N의 각 자릿수의 합을 구해서 return 하는 solution 함수를 만드세요.

N의 범위는 100,000,000 이하의 자연수입니다.

#include <stdio.h>
#include <stdbool.h>
#include <stdlib.h>

int solution(int n) {
   int sum = 0;
    for (sum = 0; n > 0; n /= 10) {
        sum += n % 10;
    }
    return sum;
}

자연수 N은 10진법이니까 10으로 나눴을 때의 나머지가 소수점 한자리가 나올 거라고 생각해보면,

결국 그 나머지 값은 1의 자리의 자릿수 값이 된다.

그리고 어차피 n은 int 자료형이니까, 소수점은 강제로 제거되고 맨 뒷자리 숫자만 빠진 10진법 숫자만 남게 된다.

그리고 다시 10으로 나누면 10의 자리의 자릿수 값이 소수점 한자리로 나오게 되고, 그 값이 10의 자리의 자릿수 값이다.

 

이걸 for문을 활용한 반복문으로 구현해 보았다.

 

int sum = 0; 은, for문에서 어차피 sum = 0;으로 초기화를 하니까 int sum; 으로 선언만 해도 충분할 것 같다.

 

반복문이니까 while문으로도 작성해보았다.

#include <stdio.h>
#include <stdbool.h>
#include <stdlib.h>

int solution(int n) {
	int answer = 0;
    
    while (n>0) {
    	answer += n % 10;
   		n /= 10;
    }
    
	return answer;
}

 

+ Recent posts