프로그래밍 공부
작성일
2024. 2. 8. 23:24
작성자
WDmil
728x90

8.8 안정된 인터페이스 만들기

클래스는 추상화를 위한 C++의 핵심 도구다. 클래스를 디자인할 떄는 추상화 원칙을 적용하여 구현과 인터페이스를 분리해야 한다. 구체적으로는 모든 데이터 멤버를 private로 선언하고 외부에서 멤버에 접근할 필요가 있을 때는 get과 set메서드를 이용한다. SpreadSheetCell 클래스는 이러한 방식으로 구현되었다.

 

mValue와 mString은 private 멤버며 set(), getValue(), getString()으로 값을 읽고 쓸 수 있다. 이러한 방식으로 클래스의 사용자가 mValue와 mString을 잘못 세팅 할 걱정 없이 내부적으로 동기화한다.


8.8.1 인터페이스 클래스와 구현 클래스의 활용

앞어 설명한 여러 가지 방법과 디자인 원칙에도 C++는 원천적으로 추상화 원칙에 협조적이지 않다. 클래스 정의 문법은 public인터페이스와 private 데이터 멤버와 메서드를 한 클래스에서 정의하도록 하고 있기 때문에 내부 구현이 사용자에게 노출된다.

 

이러한 방식은 public이 아닌 메서드를 추가하여 인터페이스에 아무런 변화가 없는 상황에서도 해당 클래스의 모든 사용자가 컴파일을 다시 해야 하는 문제가 있다. 큰 프로젝트에는 이런 문제가 부담된다.

 

하지만 다행이도 이런 단점을 극복하여 구현 사항을 완전하게 숨기고 한정적인 인터페이스를 만들 방법이 있다. 단, 이것은 우회적인 방법으로 조금 까다롭다. 기본적인 아이디어는 클래스를 만들 때마다 매번 인터페이스 클래스 와 구현 클래스를 만드는 것이다. 구현 믈래스는 원래 만들던 방식의 클래스와 똑같다.

 

하지만 인터페이스 클래스는 구현 클래스의 public메서드만 담는다. 인터페이스 클래스의 메서드들은 단순히 구현 클래스의 매서드 호출을 중계하는 역할만 한다. 그리고 인터페이스 클래스는 단 하나의 데이터 멤버로 구현 클래스의 포인터를 가진다.

 

이러한 방식을 pimpl(Pointer To Implementation)관례 라고 부른다. 이렇게 클래스를 분리하면 구현 클래스가 어떻게 바뀌든 pulbic 인터페이스 클래스에는 변화가 없음으로 컴파일을 다시 해야 할 필요를 줄일 수 있다. 즉, 구현 부분이 어떻게 바뀌든지 인터페이스만 그대로라면 사용자로서는 컴파일을 다시 할 필요가 없다.

 

컴파일 종속성의 배제는 구현 클래스의 객체를 인터페이스 클래스가 포인터로 가질 때만 유효하다. 만약 구현 클래스를 포인터가 아니라 값으로 가진다면 여젼히 컴파일을 다시 해야한다.

 

이러한 방식을 SpreadSHeet클래스에 적용하기 위해 기존의 SpreadSheet클래스 이름을 SpreadSheetImpl로 바꾼다. 다음은 새로운 SpreadSheetImpl클래스 정의로 앞서 정의했던 SpreadSheet클래스 정의와 이름만 다르다.

#pragma once
#include "SpreadSheetCell.h"
class SpreadSheetApplication;
class SpreadSheetImpl
{
public:
	SpreadSheetImpl(int inWidth, int inHeight,
		const SpreadSheetApplication& theApp);
	SpreadSheetImpl(const SpreadSheetApplication& theApp,
		int inWidth = kMaxWidth, int inHeight = kMaxHeight);
	SpreadSheetImpl(const SpreadSheetImpl& src) = delete;
	~SpreadSheetImpl();
	void setCellAt(int x, int y, const SpreadSheetCell& cell);
	SpreadSheetCell& getCellAt(int x, int y);
	int getId() const;

	static const int kMaxHeight = 100;
	static const int kMaxWidth = 100;

public:
	SpreadSheetImpl& operator=(const SpreadSheetImpl& rhs);

private:
	bool inRange(int val, int upper);
	void copyFrom(const SpreadSheetImpl& src);
	int mWidth, mHeight;
	int mId;
	SpreadSheetCell** mCells;
	const SpreadSheetApplication& mTheApp;
	static int sCounter;
};

그리고 새로운 SPreadSheet클래스를 다음과 같이 정의한다.

#pragma once
#include "SpreadSheetCell.h"
#include "SpreadSheetImpl.h"
#include <memory>
// 포워드 선언
class SpreadSheetImpl;
class SpreadSheetApplication;
class SpreadSheet
{
public:
	SpreadSheet(const SpreadSheetApplication& theApp,
		int inWidth, int inHeight);
	SpreadSheet(const SpreadSheetApplication& theApp);
	SpreadSheet(const SpreadSheet& src);
	~SpreadSheet();
	SpreadSheet& operator=(const SpreadSheet& rhs);
	void setCellAt(int x, int y, const SpreadSheetCell& inCell);
	SpreadSheetCell& getCellAt(int x, int y);
	int getId() const;
private:
	std::unique_ptr<SpreadSheetImpl> mImpl;
};

이 클래스는 단 하나의 데이터 멤버인 SPreadSheetImpl 객체에 대한 포인터를 가진다. public 메서드는 기존의 SpreadSheet가 가졌던 것과 한 가지를 제외하고는 같다. 한 가지 다른 점은 SpreadSheet생성자로 초기화할 const멤버가 없어졌기 때문에 디폴트 파라미터를 설정할 수 없어서 생성자가 두 개로 나누어졌다는 것이다. 대신 SpreadSheetImpl 클래스에서 해당 디폴트 값을 설정한다.

 

SetCellAt()과 getSellAt() 같은 SpreadSheet 메서드의 구현부는 단지 SpreadSheetImpl객체의 해당 메서드 호출을 중계해준다.

 

void SpreadSheet::setCellAt(int x, int y, const SpreadSheetCell& inCell)
{
	mImpl->setCellAt(x, y, inCell);
}

SpreadSheetCell& SpreadSheet::getCellAt(int x, int y)
{
	return mImpl->getCellAt(x, y);
}

int SpreadSheet::getId() const
{
	return mImpl->getId();
}

SpreadSheet의 생성자와 소멸자는 반드시 SpreadSheetImpl 객체를 함꼐 생성/ 소멸 해주어야 한다. 그래야 메서드 호출을 SpreadSheetimpl 객체로 전달할 수 있다. SpreadSheetImpl클래스는 디폴트 파라미터를 활용해서 생성자 하나로 대응하고 있지만, SpreadSheet클래스는 세개의 생성자를 다음처럼 따로 구현해야 한다.

SpreadSheet::SpreadSheet(const SpreadSheetApplication& theApp, int inWidth, int inHeight)
{
	mImpl = std::make_unique<SpreadSheetImpl>(theApp, inWidth, inHeight);
}

SpreadSheet::SpreadSheet(const SpreadSheetApplication& theApp)
{
	mImpl = std::make_unique<SpreadSheetImpl>(theApp);
}

SpreadSheet::SpreadSheet(const SpreadSheet& src)
{
	mImpl = std::make_unique<SpreadSheetImpl>(*src.mImpl);
}

SpreadSheet::~SpreadSheet()
{
}

복제 생성자의 구현부는 조금 이상하게 보일 수도 있다. 내부 멤버인 mlmpl 객체를 원본에서 복제해오려면 그 기반 클래스인 SpreadSheetImpl의 복제 생성자를 이용해야 한다.

 

따라서 복제 생성자의 참조형 인자로 전달할 수 있도록 mImpl 포인터 변수를 역참조 하고 있다

 

SpreadSheet의 대입 연산자도 SpreadSheetImpl의 대입 연산자를 중계해야 한다.

SpreadSheet& SpreadSheet::operator=(const SpreadSheet& rhs)
{
	*mImpl = *rhs.mImpl;
	return *this;
}

대입 연산자 구현부의 첫 번째 라인은 복제 생성자에서와 비슷한 이유로 역참조를 사용하고 있다. 만약 역참조를 하지 않고 다음처럼 하면 문제가 된다.

mImpl = rhs.mImpl; // 잘못된 대입연산!

위 코드는 컴파일에 실패한다. 데이터 멤버 mImpl은 std::unique_ptr로 선언되어 있는데 이 타입은 대입 연산자를 지원하지 않는다. SpreadSheet의 대입 연산자는 SpreadSheetImpl의 대입 연산자를 이용하는데 이 연산자는 객체를 직접 복제할 떄만 동작한다.

 

mImpl 포인터를 역참조하여 직접적인 객체 대입 연산이 일어나도록 할 수 는 있지만, 이 경우  SpreadSheet이 아니라 SpreadSheetImpl의 대입 연산자가 호출된다.

 

이러한 테크닉은 인터페이스와 구현을 완전히 분리해주기 떄문에 매우 강력하다. 비록 처음에는 성가시고 까다로운 면이 있지만 한 번 익숙해지고 나면 자연스럽게 이런 방식을 사용하게 된다.

 

새롭게 도입해야 될 수도 있고, 팀원들의 저항에 부딛힐 수 도 있다. 이때 도입을 설득할 수 있는 가장 효과적인 정책은 컴파일 시간의 단축이다. 큰 프로젝트일수록 전체 빌드는 모든 프로그래머에게 부담된다. 이러한 테크닉을 도입하면 전체 빌드가 필요한 경우를 극히 줄일 수 있다.

 

더 나아가서 프리컴파일된 헤더를 이용하여 기본적인 빌드시간 자체를 줄일 수 도 있다.


8.9 요약

객체 안에서 동적 메모리 할당을 이용할 때에는 여러가지 까다로운 문제가 생긴다.

  • 소멸자에서 해당 메모리를 반드시 헤제해야 한다.
  • 복제 생성자에서 그 메모리를 적절히 복제 할 수 있도록 구현해야 한다.
  • 대입 연산자에서 메모리 헤제와 복제를 모두 챙겨야 한다.
  • 복제 생성자와 대입 연산자를 private로 선언하여 값에 의한 전달과 대입 연산을 방지해야 한다.

 

여러 종류 데이터멤버 static, const, const참조, mutable, static, inline, const메서드 그리고 메서드 오버로딩, 디폴트 파라미터를 기억하자.

 

중첩된 클래스를 정의하고 이용하는 방법, friend키워드의 활용도 기억하자.

 

연산자 오버로딩, 산술 연산자와 비교연산자를 확인하고, 연산자가 전역 함수 또는 메서드로서 friend설정이 필요한 이유를 기억하자.

 

추상화 원칙을 온전하게 실현하기 위해 인터페이스 클래스와 구현 클래스를 분리작성 하는 기법을 배웠다.  

728x90