프로젝트의 성공을 위해서 C++의 기능에 의존하지 말고 소프트웨어의 전반적인 구조에 의존해야한다.
소프트웨어에서 변하지 않는 것은 소프트웨어가 변경될 것이라는 사실이다. 그렇기 때문에 소프트웨어를 소프트웨어(software)라고 부르는 이유다. 하드웨어 비해 부드럽고 변하기 쉽기(malleable) 떄문이다.
켄트 벡(kent Beck)은 테스트 주도 개발(test-driven development)에 관한 자신의 책에서 다음처럼 표현했다.
의존성은 모든 규모의 소프트웨어 개발에서 핵심적인 문제이다.

위 그림의 예시를 위해 건축으로 비유한다. 소프트웨어 아키텍처 또는 디자인 패턴은 건물의 전체적인 구조를 설계하는 일에 비유한다. 구현 상세는 건물의 세부적인 내용, 냉장고, 액자, 침대 등등 으로 비유한다.
소프트웨어 디자인에서는 큰 그림에 초점을 맞추는 것으로 시작한다.
소프트웨어 아키텍처는 미래에 변경하기 가장 어려운 소프트웨어의 한 측면인 커다란 결정을 수반한다.
소프트웨어 아키텍처는 미래에 변경하기 가장 어려운 소프트웨어의 한 측면인 커다란 결정을 수반한다.
아키텍처는 프로젝트에서 초기에 제대로 할 수 있기를 바라는 결정이지만, 반드시 다른 결정보다 더 제대로 할 가능성이 높지는 않다.
- 랄프 존슨(Ralph Johnson
소프트웨어 아키텍처는 소프트웨어에 접근하는 전체 전략을 나타내는 반면, 소프트웨어 디자인은 전략을 펼치는 전술이라고 볼 수 있다.
관용구(idiom)는 반복적인 문제에 흔히 사용되는 언어별 해결책이다. C++에서 관용구 대부분은 구현 상세로 분류할 수 있다. 예를 들면 복사 대입 연산자 구현을 통해 알고있을 복사-교환(copy-and-swap) 관용구와 RAII(Resouce acquisition is initialization) 관용구가 있다. 그 외 소프트웨어 디자인에 속하는 관용구는 비가상 인터페이스(Non-Virtual Interface, NVI) 관용구와 Pimple 관용구가 있다.
C++ 공동체가 기능에 집중하는 이유
프로젝트 성공의 기초는 디자인
좋은 소프트웨어에 기본적으로 기대하는 것 중 하나는 쉽게 변경할 수 있는 능력이다. 하지만 그것은 쉽지 않다.
인위적인 의존성을 줄이고 변경을 단순화하는 최선의 입증된 해결책 중 하나는 관심사 분리(separation of concerns)다. 관심사 분리의 의도는 복잡도를 더 잘 이해하고 관리해 더 많은 모듈형 소프트웨어를 설계하는 것이다.
단일 책임 원칙(Single Responsibility Principle, SRP)
클래스를 변경하는 이유는 단 하나여야 한다.
모든 것은 오직 한 가지 일만 해야 한다.
//#include <some_json_library.h> // 잠재적인 물리적 의존성
class Document
{
public:
// ...
virtual ~Document() = default;
virtual void exportToJSON( /* ... */ ) const = 0;
virtual void serialize( ByteStream&, /* ... */) const = 0;
/// ...
};
첫 번째로 파생 클래스에서 exportToJSON()을 구현해야 한다. 파생 클래스는 JSON 내보내기를 수동으로 구현하는 부담을 지고 싶지 않을 가능성이 높으므로 json, rapidjson, simdjson 등 외부의 서드파티 JSON 라이브러리에 의존할 것이다. 이를 위해 선택한 라이브러리가 무엇이든 exportToJSON() 멤버 함수로 인해 파생 문서가 갑자기 그 라이브러리에 의존한다. 그리고 모든 파생 클래스는 일관성이라는 이유만으로 같은 라이브러리에 의존할 가능성이 크다. 따라서 파생 클래스는 실제로 독립적이지 않으며 특정 디자인 결정과 인위적으로 결합(coupling)한다. 또한 특정 JSON 라이브러리에 대한 의존성은 더 이상 가볍지 않으므로 상속 계통(hierarchy) 재사용성을 확실히 제한한다. 게다가 다른 라이브러리로 전환하면 모든 파생 클래스를 조정해야 하므로 큰 변화가 생긴다.
두 번째로 serialize() 함수로 인해 같은 인위적 의존성이 생긴다. serialize() 또한 protobuf 나 Boost.serialization 같은 서드 파티 라이브러리로 구현할 것이다. 이는 직교적이고 관련 없는 두 가지 디자인 관심사 (JSON과 serialize) 사이에 결합(coupling)을 만들므로 의존성 상황을 상당히 악화시킨다.
세 번째는 serialize() 함수로 인한 것이다. 이 함수로 인해 Documnet에서 파생한 클래스는 문서를 직렬화하는 방법에 관한 전역 결정에 의존한다. 리틀엔디언(little endian), 빅엔디언(big endian)? PDF 파일, WORD 파일? 예를 들어 열거형(enum)을 사용할 수 있다.
enum class DocumentType
{
pdf,
word,
// ... 있을 수 있는 더 많은 문서유형
};
이 접근법은 직렬화에 매우 일반적이다. 하지만 이 저수준 문서 표현을 Document 클래스 구현 내에서 사용하면 실수로 서로 다른 모든 종류의 문서를 결합할 수 있다. 모든 파생 클래스는 다른 모든 Document 타입에 관해 암시적으로 알 고 있으며, 결과적으로 새로운 문서 종률르 추가하면 모든 기존 문서 유형에 직접 영향을 미친다.

SRP는 관심사와 진적으로 속하지 않는 것, 즉 비응집적(접착적)인 것을 분리해야 한다고 조언한다. 여러 가지 이유로 변경하는 것을 변형점(variation point)으로 분리하도록 조언한다.

class Document
{
public:
// ...
virtual ~Document() = default;
// 'exportToJSON()' 과 'serialize()' 함수는 더 이상 없다.
// 강한 결합을 일으키지 않는 매우 기본적인 문서 연산만 남아있다.
// ...
}

// ---- <Money.h> ----
class Money { /* ... */ };
Money operator*(Money money, double factor);
Money operator*(Money lhs, Money rhs);
// ---- <Item.h> ----
#include <Money.h>
class Item
{
public:
virtual ~Item() = default;
virtual Money price() const = 0;
};
// ---- <CppBook.h> ----
#include <Item.h>
#include <Money.h>
#include <string>
class CppBook : public Item
{
public:
explicit CppBook(std::string title, std::string author, Money price)
: title_(std::move(title)), author_(std::move(author)), priceWithTax_(price * 1.15)
{}
std::string const& title() const { return title_; }
std::string const& author() const { return author_; }
Money price() const override { return priceWithTax_; }
private:
std::string title_;
std::string author_;
Money priceWithTax_;
};
// ---- <ConferenceTicket.h> ----
#include <Item.h>
#include <Money.h>
#include <string>
class ConferenceTicket : public Item
{
public:
explicit ConferenceTicket(std::string name, Money price)
: name_(std::move(name)), priceWithTax_(price * 1.15) // 15%
{}
std::string const& name() const { return name_; }
Money price() const override { return priceWithTax_; }
private:
std::string name_;
Money priceWithTax_;
};
#include <CppBook.h>
#include <ConferenceTicket.h>
#include <algorithm>
#include <cstdlib>
#include <memory>
#include <numeric>
#include <vector>
int main()
{
std::vector<std::unique_ptr<Item> items{};
items.emplace_back(std::make_unique<CppBook>("Effective C++", "Scott Meyers", 19.99));
items.emplace_back(std::make_unique<CppBook>("C++ Templates", "David Vandevoorde et al.", 49.99));
items.emplace_back(std::make_unique<ConferenceTicket>("CppCon", 999.0);
items.emplace_back(std::make_unique<ConferenceTicket>("Meeting C++", 699.0);
items.emplace_back(std::make_unique<ConferenceTicket>("C++ on Seq", 499.0);
Money const total_price = std::accumulate(std::begin(items), std::end(items), Money{},
[](Money accu, auto const& item) {
return accu + item->price();
});
// ...
return EXIT_SUCCESS;
}
잘 짜여진 코드처럼 보이지만 세율이 변경될 시 코드베이스 전체가 변경되어야한다.
SRP가 변형점을 분리하라고 조언하는 만큼 코드베이스 전체에 정보를 중복하지 않도록 주의해야한다. 모든 것에 책임이 하나(단 하나의 변경 이유)이어야 하는 만큼, 모든 책임은 시스템에 단 한 번 존재해야 한다. 이 아이디어를 반복하지 말 것(DRY) 원칙이라 한다.
SOLID를 달성하려고 하지 말고 SOLID를 사용해 유지보수성을 달성하라
YAGANI 원칙(You aren't gonna need it)은 필요한 것만 할것이라는 원칙이다.
쉬운 변경의 한 측면은 그 변경이 기대하는 행위를 깨뜨지 않는다는 것을 확인할 수 있는 단위테스트(unit test)를 준비하는 것이다.
class Document
{
public:
// ...
virtual ~Document() = default;
virtual void exportToJSON( /* ... */ ) const = 0;
virtual void serialize( ByteStream&, /* ... */) const = 0;
/// ...
};
void exportDocument(Document const & doc)
{
// ...
doc.exportToJSON(/* 필요한 인자를 전달한다 */);
// ...
}
class JSONExportable
{
public:
// ...
virtual ~JSONExportable() = default;
virtual void exportToJSON( /* ... */ ) const = 0;
// ...
};
class Serializable
{
public:
// ...
virtual ~Serializable() = default;
virtual void serialize(ByteStream& bs, /* ... */) const = 0;
// ...
};
class Document : public JSONExportable, public Serializable
{
public:
// ...
};
void exportDocument(Document const & exportable)
{
// ...
doc.exportToJSON(/* 필요한 인자를 전달한다 */);
// ...
}
ISP가 기초 클래스에만 적용할 수 있는 것처럼 보이고 대부분 객체 지향 프로그래밍을 통해 도입하지만, 인터페이스로 인한 의존성을 최소화하는 일반적인 아이디어는 템플릿에도 적용할 수 있다.
template<typename InputIt, typename OutputIt>
OutputIt copy( InputIt first, InputIt last, OutputIt d_first);
C++20에서는 콘셉트(concepts)를 적용해 요구사항을 표현할 수 있다.
template<std::input_iterator InputIt, std::output_iterator OutputIt>
OutputIt copy( InputIt first, InputIt last, OutputIt d_first);
std::copy()는 복사할 범위인 입력 반복자(input iterator)와 대상 범위인 출력 반복자(output iterator)쌍을 기대한다.
만약 std::copy()가 std::input_iterator와 std::output_iterator 대신 std::forward_iterator를 요구한다고 가정하면
template< std::forward_iterator ForwardIt >
OutputIt copy( ForwardIt first, ForwardIt last, OutputIt d_first);
불행히도 이는 std::copy() 알고리즘의 유용성(usefullness)을 제한한다. 일반적으로 다중 경로 보증(multipass gaurantee)을 하지 않으며 기록할 수 없으므로 더 이상 입력 스트림에서 복사할 수 없다. 의존성에 초점을 맞추면 이제 std::copy()는 필요하지 않은 연산과 요구 사항에 의존한다. 그리고 std::copy()에 전달한 반복자는 추가 연산을 제공하도록 강제되므로 std::copy()는 이 반복자에 대한 의존성을 강제한다.
다음 updateCollection() 멤버 함수를 어떻게 테스트할까?
class Widget
{
// ...
private:
void updateCollection( /* collection을 갱신하는 데 필요한 인자 */ );
std::vector<Blob> blobs_;
/* 있을 수 있는 다른 데이터 멤버 */
};
updateCollection() 멤버 함수를 비공개 구역에 선언했다.
class Widget
{
public:
// ...
void addBlob( Blob const& blob, /* ... */)
{
// ...
updateCollection(/* ... */);
// ...
}
private:
void updateCollection( /* collection을 갱신하는 데 필요한 인자 */ );
std::vector<Blob> blobs_;
/* 있을 수 있는 다른 데이터 멤버 */
};
이러한 테스트를 화이트박스 테스트(white box test)라고 한다. 화이트박스 테스트는 어떤 함수의 내부 구현 상세를 알고 그 지식을 바탕으로 테스트한다. 이렇게 하면 테스트 코드가 제품 코드의 구현 상세에 의존한다. 이 접근법의 문제는 소프트웨어가 변한다는 점이다. 코드는 바뀌고 세부내용도 변한다. 예를 들어 미래 어느 시점에 addBlob() 함수를 재작성해 collection을 더 이상 갱신하지 않을 수도 있다. 그러면 해당 테스트는 그렇게 작성한 작업을 더 이상 수행하지 않는다. 심지어 그 사실을 알아채지도 못한 채 updateCollection() 테스트를 잃게 될 것이다. 따라서 화이트박스 테스트는 위험을 품고 있다. 제품 코드에서 의존성을 피하고 줄여야 하는 만큼('지침 1: 소프트웨어 디자인의 중요성을 이해하라' 참고) 테스트와 제품 코드의 세부 내용 간 의존성도 피해야 한다.
블랙박스 테스트(blackbox test)는 내부 구현 상세에 대해 어떤 가정도 하지 않고 예상하는 행위에 대해서만 테스트한다.
class Widget
{
// ...
private:
friend class TestWidget;
void updateCollection( /* collection을 갱신하는 데 필요한 인자 */ );
std::vector<Blob> blobs_;
/* 있을 수 있는 다른 데이터 멤버 */
};
Widget은 TestWidget의 존재를 몰라야하지만 인위적인 의존성이 추가되었다.
friend는 인위적인 결합을 추가한다. 이것은 피하는 것이 좋다. 패스키(Passkey) 관용구는 숨겨진 프렌드로 사용할 수 있는 관용구이다.
class Widget
{
// ...
protected:
void updateCollection( /* collection을 갱신하는 데 필요한 인자 */ );
std::vector<Blob> blobs_;
/* 있을 수 있는 다른 데이터 멤버 */
};
class TestWidget : private Widget
{
// ...
};
상속이 답인 경우는 드물다.
상속을 사용하여 테스트하는 방법은 public으로 변경하여 테스트하는 방법과 거의 동일하다.
자유 함수로 분리하는 방법
void updateCollection( std::vector<Blob> blobs,
/* collection을 갱신하는 데 필요한 인자 */ );
class Widget
{
// ...
private:
std::vector<Blob> blobs_;
/* 있을 수 있는 다른 데이터 멤버 */
};
클래스로 분리하는 방법
namespace widgetDetails {
class BlobCollection
{
public:
void updateCollection( /* collection을 갱신하는 데 필요한 인자 */ );
private:
std::vector<Blob> blobs_;
};
} // namespace widgetDetails
class Widget
{
// ...
private:
widgetDetails::BlobCollection blos_;
/* 있을 수 있는 다른 데이터 멤버 */
};
관심사 분리가 가지는 이점
class Document
{
public:
// ...
virtual ~Document() = default;
virtual void serialize( ByteStream&, /* ... */) const = 0;
/// ...
};
class PDF : public Document
{
public:
// ...
void serialize( ByteStream& bs /*, ... */) const override;
// ...
};
enum class
{
pdf,
word,
// ... 있을 수 있는 더 많은 문서 유형
];

enum class DocumentType
{
pdf,
word,
xml, // 새 문서 유형
// ...
};
새로운 DocumentType을 추가하려면 pdf, word 를 다시 컴파일 해야하는 문제가 있다.
개방-폐쇄 원칙
(클래스, 모듈, 함수 등) 소프트웨어 산출물은 확장에는 열려 있지만 수정에는 닫혀 있어야 한다.

Document 예시는 이런 디자인 고려 사항을 모두 런타임 다형성에 적용한다는 인상을 줄 수 있다. 하지만 동일한 고려 사항과 주장이 컴파일 시점 문제에도 적용된다.
std::swap 알고리즘을 보면, C++11 이후 std::swap()은 다음처럼 정의한다.
namespace std {
tmpplatee <typename T >
void swap( T& a, T& b )
{
T tmp( std::move(a) );
a = std::move(b);
b = std::move(tmp);
}
} // namespace std
namespace custom {
class CustomType
{
/* 특수한 형태의 swap이 필요한 구현 */
}
void swap( CustomType& a, CustomType& b )
{
/* 'CustomType' 타입인 두 인스턴스를 교환하는 특수한 구현 */
}
} // namespace custom
template< typename T >
void some_function( T& value )
{
// ...
T tmp( /* ... */ );
using std::swap; // 컴파일러가 후속 호출에 std::swap을 고려하게 한다.
swap( tmp, value ); // 두 값을 교환한다. 한정하지 않은 호출과 ADL 덕분에
//'T'가 'CustomType'일 때 'custom::swap()'을 호출한다.
// ...
}
template< typename InputIt, typename T >
constexpr InputIt find( inputIt first, InputIt last, T const& value );
template< typename InputIt, typename UnaryPredicate >
constexpr InputIt find_if( InputIt first, InputIt last, UnaryPredicate p );
template <>
struct std::hash<CustomType>
{
std::size_t operator()( CustomType const& v ) const noexcept
{
return /* ... */;
}
};