본문 바로가기
Object Oriented Programming(C++)/전문가를 위한 C++

전문가를 위한 C++ | Chapter 09 클래스와 객체 마스터하기(하)

by continue96 2022. 1. 16.

9.3 메서드의 종류

 C++에서 제공하는 메서드의 종류는 다양하다. 이 절에서 하나씩 자세히 소개한다.

 

9.3.1 static 메서드

 메서드도 데이터 멤버처럼 객체 단위가 아닌 클래스 단위로 적용되는 것이 있다. 이를 정적(static) 메서드라 부른다. 예를 들어 8장에서 정의한 SpreadsheetCell 클래스를 살펴보자. 여기서 stringToDouble와 doubleToString 헬퍼 메서드는 객체 정보에 접근하지 않기 때문에 다음과 같이 static으로 선언할 수 있다.

/* static 메서드 */
class SpreadsheetCell {
private:
	static std::string doubleToString(double inValue);
	static double stringToDouble(std::string_view inString);
};

 static 메서드는 특정 객체에 대해 호출되지 않기 때문에 this 포인터를 가질 수 없으며 어떤 객체의 비정적(non-static) 객체에 접근하는 용도로 호출할 수 없다. 다시 말해, static 메서드는 일반 함수와 비슷하지만 클래스의 private static이나 protected static 멤버만 접근할 수 있다는 점이 다르다.

 

 9.3.1.1 클래스 안과 밖에서 static 메서드 호출하기

 같은 클래스 안에서는 static 메서드를 일반 함수처럼 호출할 수 있다. 클래스 밖에서 호출할 때는 메서드 이름 앞에 스코프 지정 연산자(::)를 이용하여 클래스 이름을 붙여야 한다. 접근 제한 방식은 일반 메서드와 똑같다.

string str = SpreadsheetCell::doubleToString(10.0);

 stringToDouble와 doubleToString을 public으로 선언하면 클래스 외부에서 호출할 수 있다.

 

9.3.2 const 메서드

 어떤 메서드가 데이터 멤버를 변경하지 않는다고 보장하고 싶을 때 const 키워드를 붙인다. 예를 들어 데이터 멤버를 변경하지 않는 메서드를 SpreadsheetCell 클래스에 추가하려면 다음과 같이 메서드를 const로 선언한다.

/* const 메서드 */
class SpreadsheetCell {
public:
	double getValue() const;
	std::string getString() const;
};

double SpreadsheetCell::getValue() const {
	return mValue;
}

std::string SpreadsheetCell::getString() const {
	return doubleToString(mValue);
}

 메서드를 const로 선언하면 이 메서드 안에서 객체 내부 값을 변경하지 않겠다는 계약을 사용자 코드와 맺는 셈이다. 데이터 멤버를 수정하는 메서드를 const로 선언하면 컴파일 에러가 발생한다. 프로그램에서 const 객체에 대한 레퍼런스를 사용할 수 있도록 객체를 수정하지 않는 메서드는 모두 const로 선언하는 습관을 들이기 바란다.

 

 9.3.2.1 mutable 데이터 멤버

 때로는 의미상으로는 const인 메서드에서 객체의 데이터 멤버를 변경하는 경우가 있다. 이렇게 해도 사용자 데이터에 아무런 영향을 미치지 않지만 엄연히 수정하는 행위이기 때문에 이런 메서드를 const로 선언하면 컴파일 에러가 발생한다. 이럴 때는 데이터 멤버를 mutable로 선언해서 컴파일러에 이 변수를 const 메서드에서 변경할 수 있다고 알려주면 된다. 이렇게 SpreadsheetCell 클래스를 수정하면 다음과 같다.

/* mutable 데이터 멤버 */
class SpreadsheetCell {
private:
	double mValue = 0;
	mutable size_t mNumAccess = 0; /* 데이터를 얼마나 자주 읽는지 확인합니다. */
};

/* getValue 메서드가 호출될 때 호출 횟수를 업데이트합니다. */
double SpreadsheetCell::getValue() const {
	mNumAccess++;
	return mValue;
}

/* getString 메서드가 호출될 때 호출 횟수를 업데이트합니다. */
std::string SpreadsheetCell::getString() const {
	mNumAccess++;
	return doubleToString(mValue);
}

 

9.3.3 메서드 오버로딩

 앞에서 한 클래스에 생성자를 여러 개 정의할 수 있다고 배웠다. 이렇게 정의한 생성자는 모두 이름은 같고 매개변수의 타입이나 개수만 달랐다. 메서드나 함수도 이와 마찬가지로 여러 개 정의할 수 있다. 이것을 오버로딩(overloading)이라고 한다.

 

 9.3.3.1 매개변수 타입·개수 기반 오버로딩

 메서드나 함수는 매개변수의 타입이나 개수를 다르게 지정해서 이름이 같은 메서드나 함수를 여러 개 정의할 수 있다. 예를 들어 다음과 같이 SpreadsheetCell 클래스에서 setString 메서드와 setValue 메서드를 모두 set으로 통일할 수 있다.

/* 매개변수 타입, 개수 기반 메서드 오버로딩 */
class SpreadsheetCell {
public:
	void set(double inValue);
	void set(std::string_view inString);
};

 컴파일러가 set 메서드를 호출하는 코드를 발견하면 매개변수의 정보를 보고 어느 버전의 set을 호출할지 결정한다. 이를 오버로딩 결정(overloading resolution)이라 부른다. C++는 메서드의 리턴 타입에 대한 오버로딩은 지원하지 않는다. 호출할 메서드의 버전을 정확히 결정할 수 없기 때문이다.

 

 9.3.3.2 const 기반 오버로딩

 const를 기준으로 오버로드할 수도 있다. 메서드를 두 개 정의할 때 이름과 매개 변수는 같지만 하나는 const로 선언한다. 그러면 const 객체에서 이 메서드를 호출하면 const 메서드가 실행되고, non-const 객체에서 호출하면 non-const 메서드가 실행된다.

 

 9.3.3.3 메서드 오버로딩 금지하기

 오버로딩된 메서드를 명시적으로 삭제할 수 있다. 그러면 특정한 인수에 대해서 메서드를 호출하지 못하게 된다. 예를 들어 다음과 같이 정의된 클래스를 보자.

class MyClass {
public:
	void foo(int i);
	void foo(double d) = delete;
};

MyClass c;
c.foo(123);
c.foo(1.23); /* 컴파일 에러 */

 컴파일러가 1.23을 1로 변환해서 foo를 호출할 수 없도록 하려면 다음과 같이 foo 메서드의 double 버전을 명시적으로 삭제한다. 이렇게 하면 double 값으로 foo를 호출할 때 정수로 변환하지 않고 컴파일 에러가 발생한다.

 

9.3.4 디폴트 인수

 디폴트 인수(default argument) 기능을 이용하면 함수나 메서드의 선언에 매개 변수의 디폴트 값을 지정할 수 있다. 매개 변수의 디폴트 값을 지정할 때는 반드시 오른쪽 끝의 매개 변수부터 시작해서 중간에 건너뛰지 않고 연속적으로 나열해야 한다. 그렇지 않으면 컴파일러는 중간에 빠진 인수에 디폴트 값을 매칭할 수 없다. 예를 들어 Spreadsheet 생성자에 높이와 너비에 대한 디폴트 값을 다음과 같이 지정할 수 있다. 여기서 주의할 점은 디폴트 인수는 메서드를 정의하는 코드가 아닌 메서드를 선언하는 코드에서 지정한다는 것이다.

class Spreadsheet {
public:
	Spreadsheet(size_t width = 100, size_t height = 100); /* 디폴트 인수 100을 전달합니다. */
};

Spreadsheet s1; /* Spreadsheet(100, 100);과 같습니다. */
Spreadsheet s2(10); /* Spreadsheet(10, 100);과 같습니다. */
Spreadsheet s3(10, 20);

 이렇게 선언하면 Spreadsheet에 비복제 생성자가 하나만 있어도 다음과 같이 인수를 0~2개로 지정해서 호출할 수 있다.

 

9.3.5 인라인 메서드

 C++는 메서드를 별도의 코드 블록에 구현해서 호출하지 않고 메서드를 호출하는 부분에서 곧바로 정의 코드를 작성하는 방법을 제공한다. 이를 인라인(inline)이라 부르며, 이렇게 구현한 메서드를 인라인 메서드라 부른다. 일반적으로 #define 매크로보다 인라인 메서드를 사용하는 것이 더 안전하다. 인라인 메서드를 정의하려면 메서드 정의 코드에서 이름 앞에 inline 키워드를 지정한다. 

inline double SpreadsheetCell::getValue() const {
	mNumAccess++;
	return mValue;
}

inline std::string SpreadsheetCell::getString() const {
	mNumAccess++;
	return doubleToString(mValue);
}

 여기서 한 가지 제약 사항이 있다. 인라인 메서드를 호출하는 코드에서 이를 정의하는 코드에 접근할 수 있어야 한다. 따라서 인라인 메서드는 반드시 프로토타입과 구현 코드를 헤더 파일에 작성한다.

 

 한편, C++는 inline 키워드를 사용하지 않고 클래스 정의에서 곧바로 메서드 정의 코드를 작성하면 인라인 메서드로 처리해준다. 다음은 SpreadsheetCell 클래스를 이렇게 정의한 예이다.

class SpreadsheetCell {
public:
	double getValue() const {
		mNumAccess++;
		return mValue;
	}
	std::string getString() const {
		mNumAccess++;
		return doubleToString(mValue);
	}
};

 컴파일러는 메서드나 함수에 선언한 inline 키워드를 단지 참고만 할  실제로는 간단한 메서드나 함수만 인라인으로 처리한다. 최신 컴파일러는 코드 비대화와 같이 몇 가지 기준에 따라 인라인으로 처리할지 판단해서 큰 효과가 없다면 인라인으로 처리하지 않는다.

 

9.4 데이터 멤버의 종류

 C++는 데이터 멤버의 종류를 다양하게 제공한다. 클래스의 모든 객체가 데이터 멤버를 공유할 수 있도록 static으로 지정할 수도 있고, const 멤버, 레퍼런스 멤버, const 레퍼런스 멤버 등도 지정할 수 있다.

 

9.4.1 static 데이터 멤버

 클래스의 모든 객체마다 똑같은 변수를 가지는 것은 비효율적이거나 바람직하지 않을 수 있다. 데이터 멤버의 성격이 객체보다 클래스에 가깝다면 객체마다 그 멤버의 복사본을 가지지 않는 것이 좋다. C++에서 제공하는 정적(static) 데이터 멤버를 이용하면 이 문제를 해결할 수 있다. static 데이터 멤버는 객체가 아닌 클래스에 속한다. static 데이터 멤버로 카운터를 구현하도록 Spreadsheet 클래스를 정의하면 다음과 같다.

class Spreadsheet {
private:
	static size_t sCounter; /* 정적(static) 데이터 멤버 */
};

 이렇게 static 데이터 멤버를 정의하면 소스 파일에서 이 멤버에 대한 공간을 할당해야 한다. 이 작업은 주로 해당 클래스의 메서드를 정의하는 소스 파일에서 처리한다. sCounter 멤버의 공간을 할당하고 값을 0으로 초기화하려면 소스 파일에 다음과 같이 작성한다.

size_t Spreadsheet::sCounter = 0;

 

 C++17부터 static 데이터 멤버를 inline으로 선언할 수 있다. 그러면 소스 파일에 공간을 따로 할당하지 않아도 된다. 예를 들면 다음과 같다.

class Spreadsheet {
private:
	static inline size_t sCounter = 0;
};

 

 9.4.1.1 메서드 안에서 static 데이터 멤버 접근하기

 클래스 메서드 안에서는 static 데이터 멤버를 일반 데이터 멤버인 것처럼 사용한다. 예를 들어 Spreadsheet에 mId란 데이터 멤버를 만들고 이 값을 Spreadsheet 생성자에서 sCounter의 값으로 초기화하도록 구현하면 다음과 같다.

class Spreadsheet {
private:
	static size_t sCounter; /* static 데이터 멤버 */
	size_t mId = 0;
};

Spreadsheet::Spreadsheet(size_t width, size_t height)
	: mId(sCounter++), mWidth(width), mHeight(height) {
	...
}

 

 9.4.1.2 메서드 밖에서 static 데이터 멤버 접근하기

 static 데이터 멤버를 private로 선언하면 클래스 메서드 밖에서 접근할 수 없다. public으로 선언하면 클래스 메서드 밖에서 접근할 수 있는데, 이때 변수 앞에 클래스 이름과 스코프 지정 연산자(::)를 붙여야 한다.

int i = Spreadsheet::sCounter;

 

9.4.2 const static 데이터 멤버

 특정 클래스에만 적용되는 클래스 상수(class constant)를 정의할 때는 반드시 const static 데이터 멤버로 선언한다. 정수 및 열거 타입의 const static 데이터 멤버는 클래스 정의 코드에서 선언과 동시에 초기화할 수 있다. 예를 들어 스프레드시트의 최대 높이와 폭을 지정하려면 최대 높이와 폭을 Spreadsheet 클래스의 const static 멤버로 정의하면 된다.

class Spreadsheet {
public:
	const static size_t kMaxWidth = 100; /* const static 데이터 멤버 */
	const static size_t kMaxHeight = 100;
};

Spreadsheet::Spreadsheet(size_t width, size_t height)
	: mId(sCounter++)
	, mWidth(std::min(width, kMaxWidth))
	, mHeight(std::min(height, kMaxHeight)) {
	...
}

 

이렇게 정의한 상수를 생성자 매개 변수의 디폴트값으로 사용해도 된다.

class Spreadsheet {
public:
	Spreadsheet(size_t width = kMaxWidth, size_t height = kMaxHeight);
	...
};

 

9.4.3 레퍼런스 · const 레퍼런스 데이터 멤버

 레퍼런스를 데이터 멤버로 사용할 수 있다. 선언한 레퍼런스는 생성자로 전달되는데 레퍼런스는 실제로 가리키는 대상 없이 존재할 수 없다. 따라서 생성자 이니셜라이저로 값을 지정해야 한다.

 일반 레퍼런스와 마찬가지로 레퍼런스 멤버도 const 객체를 가리킬 수 있다. const 레퍼런스 데이터 멤버는 객체의 const 메서드만 호출할 수 있다. const 레퍼런스에서 non-const 메서드를 호출하면 컴파일 에러가 발생한다.

 

9.5 클래스에 열거 타입 정의하기

 클래스 안에서 상수를 여러 개 정의할 때는 각각을 #define으로 작성하지 말고 열거 타입을 활용하는 것이 좋다. 예를 들어 SpreadsheetCell 클래스에 셀 컬러를 지정하는 기능을 추가하려면 다음과 같이 선언한다.

/* 열거 타입이 추가된 클래스 */
class SpreadsheetCell {
public:
	enum class Color { RED = 1, GREEN, BLUE, YELLOW };
	void setColor(Color color);
	Color getColor() const;
private:
	Color mColor = Color::RED;
};

 그러면 setColor와 getColor 메서드의 구현 코드를 다음과 같이 간단히 정의할 수 있다.

void SpreadsheetCell::setColor(Color color) {
	mColor = color;
}

SpreadsheetCell::Color SpreadsheetCell::getColor() const {
	return mColor;
}

SpreadsheetCell myCell(10);
myCell.setColor(SpreadsheetCell::Color::Blue);
auto color = myCell.getColor();

 

9.6 연산자 오버로딩

 객체끼리 연산을 수행할 때가 많다. 객체를 서로 더하거나, 비교하거나, 파일에 객체를 스트림으로 전달하거나, 반대로 가져올 수도 있다.

 

9.6.1 SpreadsheetCell에 대한 덧셈 구현

 SpreadsheetCell에 덧셈 기능을 객체지향 방식으로 구현하려면 SpreadsheetCell 객체에 다른 SpreadsheetCell 객체를 더하게 만들어야 한다. 어떤 셀에 다른 셀을 더하면 제3의 셀이 결과로 나온다.

 

 9.6.1.1 멤버 함수 operator+ 오버로딩하기

 C++는 덧셈(+)을 자신이 정의한 클래스 안에서 원하는 형태로 정의하는 덧셈 연산자(addition operator)라는 기능을 지원한다. 구체적인 방법은 다음과 같이 operator+란 이름으로 메서드를 정의하면 된다.

/* operator+ 멤버 함수 오버로딩 */
class SpreadsheetCell {
public:
	SpreadsheetCell operator+(const SpreadsheetCell& rhs) const;
};

SpreadsheetCell SpreadsheetCell::operator+(const SpreadsheetCell& rhs) const {
	return SpreadsheetCell(getValue() + rhs.getValue());
}

SpreadsheetCell aThirdCell = myCell + anotherCell;
SpreadsheetCell aThirdCell = myCell.operator(anotherCell);

 

① 묵시적 변환

 놀랍게도 이렇게 operator+를 정의하면 셀끼리 더할 수 있을 뿐만 아니라 셀에 string_view, double, int 값도 더할 수 있다. 이렇게 할 수 있는 이유는 컴파일러가 이 타입을 정확하게 변환할 수 있는 방법을 찾기 때문이다. 생성자는 이 타입을 변환하는 역할을 하기에 적합하다.

SpreadsheetCell myCell(4), anotherCell;
string str = "hello";

anotherCell = myCell + string_view(str);
anotherCell = myCell + 5.5;
anotherCell = myCell + 5;

 하지만 SpreadsheetCell 객체에 string_view를 더하는 것은 상식적이지 않은 행위이다. SpreadsheetCell에서 string_view와 같은 값을 묵시적으로 변환하지 않게 하려면 string_view를 인수로 받는 생성자 앞에 explicit 키워드를 붙인다. explicit 키워드는 클래스를 정의하는 코드에서만 지정할 수 있다. 또한 인수를 하나만 지정해서 호출할 수 있는 생성자에만 적합하다.

 

 9.6.1.2 전역 함수 operator+ 오버로딩하기

 묵시적 변환은 SpreadsheetCell 객체가 연산자의 좌변에 있을 때만 적용된다. 우변에 있을 때는 적용할 수 없다. 하지만 덧셈은 원래 교환 법칙이 성립해야 하기 때문에 이렇게 하면 뭔가 어색하다. operator+는 반드시 SpreadsheetCell 객체에 대해 호출해야 하고 이를 위해 객체가 항상 operator+의 좌변에 나와야 하기 때문이다. 하지만 클래스에 정의했던 operator+를 전역 함수로 만들면 가능하다.

/* operator+ 전역 함수 오버로딩 */
SpreadsheetCell operator+(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs) const;

SpreadsheetCell operator+(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs) const {
	return SpreadsheetCell(lhs.getValue() + rhs.getValue());
}

anotherCell = myCell + 5.5;
anotherCell = myCell + 5;
anotherCell = 5.5 + myCell;
anotherCell = 5 + myCell;
NOTE C++에서는 연산자의 우선순위를 바꿀 수 없다. 예를 들어 */는 +-보다 먼저 적용된다. 또한, 새로운 연산자 기호를 정의하거나 연산자의 인수 개수를 변경하는 것을 허용하지 않는다.

 

9.6.2 산술 연산자 오버로딩

① 기본 산술 연산자의 오버로딩

 연산자를 다음과 같이 정의하면 <op> 자리에 +, -, *, / 연산자를 대입해서 각각의 연산을 수행할 수 있다. operator-와 operator*의 구현 코드는 operator+와 거의 같아서 생략한다. operator/를 처리할 때는 0으로 나누지 않도록 주의한다.

/* 산술 연산자 전역 함수 오버로딩 */
Spreadsheet operator<op>(const Spreadsheet& rhs, const Spreadsheet& lhs) const;

SpreadsheetCell operator<op>(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs) const {
	return SpreadsheetCell(lhs.getValue() <op> rhs.getValue());
}

/* /연산자 전역 함수 오버로딩 */
Spreadsheet operator/(const Spreadsheet& rhs, const Spreadsheet& lhs) const;

SpreadsheetCell operator/(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs) const {
	if (rhs.getValue() == 0) {
		throw invalid_argument("divide by zero.");
	}
	return SpreadsheetCell(lhs.getValue() / rhs.getValue());
}

 

② 축약형 산술 연산자의 오버로딩

 C++는 기본 산술 연산자뿐만 아니라 축약형 연산자(+=, -= 등)도 제공한다. 축약형 산술 연산자는 좌변에 반드시 객체가 나와야 한다. 따라서 전역 함수가 아닌 멤버 함수로 구현해야 한다. 예를 들어 SpreadsheetCell 클래스에 축약형 산술 연산자를 선언하는 방법은 다음과 같다.

/* 축약 연산자 멤버 함수 오버로딩 */
class SpreadsheetCell {
public:
	SpreadsheetCell& operator+=(const SpreadsheetCell& rhs);
	SpreadsheetCell& operator-=(const SpreadsheetCell& rhs);
	SpreadsheetCell& operator*=(const SpreadsheetCell& rhs);
	SpreadsheetCell& operator/=(const SpreadsheetCell& rhs);
};

SpreadsheetCell& SpreadsheetCell::operator+=(const SpreadsheetCell& rhs) {
	set(getValue() + rhs.getValue());
	return *this;
}

SpreadsheetCell myCell(5), anotherCell(10);
anotherCell -= myCell;
anotherCell += 5;

 

9.6.3 비교 연산자 오버로딩

 >, <, ==, !=과 같은 비교 연산자도 클래스에서 직접 정의하면 편하다. 비교 연산자는 전역 함수로 구현해야 연산자의 좌변과 우변을 모두 묵시적으로 변환할 수 있다. 비교 연산자는 모두 bool 타입을 리턴한다. 다음과 같이 <op> 자리에 ==, !=, <, <=, >, >= 연산자가 적용되도록 선언한다.

/* 비교 연산자 전역 함수 오버로딩 */
bool operator<op>(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);

bool operator<op>(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs) {
	return (lhs.getValue() <op> rhs.getValue() ? true : false);
}

 

 클래스에 데이터 멤버가 많을 때는 ==과 <부터 구현하고 이를 바탕으로 나머지 비교 연산자를 구현하면 한결 간편하다. 예를 들어 다음과 같이 operator>=을 operator<으로 정의할 수 있다.

/* >=연산자 전역 함수 오버로딩 */
bool operator>=(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs) {
	return !(lhs < rhs);
}

 

9.6.4 연산자 오버로딩을 지원하는 타입 정의하기

 연산자 오버로딩에서 중요한 점은 클래스를 int나 double 같은 기본 타입에 최대한 가깝게 정의하는 것이다. 객체를 더하는 기능을 +로 구현하면 훨씬 기억하기 쉽다.

NOTE 클래스를 정의할 때 그 클래스를 사용하는 이들에게 서비스를 제공한다는 생각으로 연산자 오버로딩을 구현하면 좋다.

댓글