- 소개
- 8.1 스프레드시트 예제
- 8.2 클래스 작성 방법
- 8.3 객체의 라이프 사이클
- 8.4 요약
- 이번 장에서는 클래스의 정의, 메서드 정의, 스택과 힙에서 객체를 생성해서 사용하는 방법, 생성자 작성법, 디폴트 생성자, 컴파일러가 생성하는 생성자, 생성자 이니셜라이저, 복제 생성자, 이니셜라이저 리스트 생성자, 소멸자, 대입 연산자 등 클래스와 객체에 관련된 핵심 개념을 알아보자.
- 나만의 클래스 작성법
- 메서드와 데이터 멤버에 대한 접근 권한 지정법
- 스택이나 힙에 객체를 생성하는 방법
- 객체의 라이프 사이클
- 객체가 생성될 때 실행할 코드 작성법
- 객체를 복사 또는 대입하는 코드 작성법
- 간단한 스프레드시트 예제로 여러 개념을 설명할 예정
- SpreadsheetCell이라는 기본 클래스 중심으로 소개
- 객체에 적용할 동작(메서드)과 객체마다 가질 속성(데이터 맴버) 지정
- 클래스를 정의하는 단계와 메서드를 정의하는 단계로 구성
- SpreadsheetCell 클래스의 첫 버전
class SpreadsheetCell {
public:
// SpreadsheetCell 클래스에서 지원하는 메서드 선언
void setValue(double inValue);
double getValue() const;
private:
// 클래스의 데이터 멤버 선언
double mValue;
};
- 클래스 정의는 문장 이므로 끝날 때, 세미콜론(;)을 빼먹지 말자!
- 클래스 정의를 작성한 파일은 보통 클래스 이름.h 로 짓는다.
- 클래스는 여러 멤버를 갖는다. 멤버는 멤버 함수와 멤버 변수(데이터 멤버) 로 구성
- 멤버 함수 : 메서드 생성자 소멸자
- 멤버 변수(=데이터 멤버) : 열거형, 타입 앨리어스, 중첩 클래스 등..
- 각 클래스의 멤버들은 세 가지 접근 제한자를 갖는다.
- 클래스에 접근 제한자를 지정하지 않는다면 private가 적용
class SpreadsheetCell {
void setValuedouble inValue); // private
public:
double getValue() const;
private:
double mValue;
};
- struct도 마찬가지로 class처럼 메서드를 가질 수 있음.
- 하지만 메서드가 없거나 개수가 적다면(x, y좌표계) class보다 struct 구조를 사용
// struct ver
struct SpreadsheetCell {
void setValue(double inValue);
double getValue() const;
private:
double mValue;
};
- 선언 순서는 따로 지정한게 없다. 편한대로 하자.
- 가독성을 위해서 public, protected, private로 묶어서 선언하도록 하자!
- 클래스를 정의할 때는 멤버 변수를 선언하는 동시에 초기화가 가능
class SpreadsheetCell{
// 클래스 정의의 나머지 부분 생략
private:
double mValue = 0; // 기본값 0으로 초기화
};
- 함수를 만들 때는 프로토타입 뿐만 아니라 함수를 구현하는 정의 코드를 함께 작성하듯이
- 메서드도 프로토타입 뿐만 아니라 메서드를 구현하는 정의 코드도 반드시 작성!
- 메서드 정의 코드보다 클래스 정의 코드가 먼저 나와야 함!
#include "SpreadsheetCell.h"
// 메서드 이름 앞에 클래스 이름과 콜론 두 개가 붙은 것을 잘 보자
// :: 는 스코프 지정 연산자 라고 함. 즉, setValue, getValue는 SpreadsheetCell 클래스에 속함
void SpreadsheetCell::setValue(double inValue) {
mValue = inValue;
}
double SpreadsheetCell::getValue() const {
return mValue;
}
- setValue() or getValue()는 클래스에 정의된 데이터 멤버 중 현재 객체에 속한 멤버에 대해 접근
- setValue() 메서드를 호출 -> mValue 변수 값을 변경함
- SpreadsheetCell 클래스에 텍스트 데이터를 지원하도록 수정한 첫 번째 버전
#include <string>
#include <string_view>
class SpreadsheetCell{
public:
void setValue(double inValue);
double getValue() const;
void setString(std::string_view inString);
std::string getString() const;
private:
std::string doubleToString(double inValue) const;
double stringToDouble(std::string_view inString) const;
double mValue;
};
- 다음 예제는 위의 네 메서드의 구현이다.
#include "SpreadsheetCell.h"
using namespace std;
void SpreadsheetCell::setValue(double inValue){
mValue = inValue;
}
double SpreadsheetCell::getValue() const{
return mValue;
}
void SpreadsheetCell::setString(string_view inString){
mValue = stringToDouble(inString);
}
string SpreadsheetCell::getString() const{
return doubleToString(mValue);
}
string SpreadsheetCell::doubleToString(double inValue) const {
return to_string(inValue);
}
double SpreadsheetCell::stringtoDouble(string_view inString) const {
return strtod(inString.data(), nullptr);
}
- 일반 메서드를 호출하면 항상 메서드가 속한 객체의 포인터인 this가 숨겨진 매개변수 형태로 전달
- this 포인터로 해당 객체의 데이터 멤버나 메서드에 접근 가능
- 다른 메서드나 함수에 매개변수로 전달 가능
- 이름을 명확히 구분하는 용도
void SpreadsheetCell::setValue(double value){
value = value; // 모호한 표현
}
// this 포인터 사용
void SpreadsheetCell::setValue(double value){
this.value = value; // 명확히 구분
}
-
객체를 생성해서 사용하는 방법에 대해서 알아보자. 크게 두가지가 있다.
- 스택에 생성하는 방법
- 힙에 생성하는 방법
// 객체를 스택에 생성한 예제
SpreadsheetCell myCell, anotherCell;
myCell.setValue(6);
anotherCell.setString("3.2");
cout << "cell 1: " << myCell.getValue() << endl;
cout << "cell 2: " << anotherCell.getValue() << endl;
- 변수 타입이 클래스 이름이라는 것이 다름.
- 데이터 멤버를 public으로 선언하는 것은 바람직하지 않다.
// new 를 사용하여 힙에 동적으로 생성한 예제
SpreadsheetCell* myCellp = new SpreadsheetCell();
myCellp->setValue(3.7);
cout << "cell 1: " << myCellp->getValue() << " " << myCellp->getString() << endl;
delete myCellp;
myCellp = nullptr;
- 힙에 생성하면 -> 연산자를 통해 멤버에 접근
- 이는역참조 연산자(*)와 멤버 접근 연산자(.)를 합친 것
- 힙에 할당한 객체 메모리도 반드시 delete를 통해서 해제 필수
- 반드시 아래와 같이 스마트 포인터를 사용하자!
auto myCellp = make_unique<SpreadsheetCell>();
myCellp->setValue(3.7);
cout << "cell 1: " << myCellp->getValue() << " " << myCellp->getString() << endl;
- 스마트 포인터를 사용하면 메모리를 자동으로 해제하므로 delete를 사용할 필요가 없음.
- 객체 라이프 사이클에 대해서 알아보자.
- 생성 소멸 대입의 세 단계로 구성되어짐.
- 동작을 원하는 방식으로 변경하는 방법도 알아두자.
#include <string>
class MyClass{
private:
std::string mName;
};
int main() {
MyClass obj;
return 0;
}
- 객체도 선언과 동시에 초기값을 설정하는 것을 권장
- 이 작업은 생성자 라고 부르는 특수한 메서드에서 객체를 초기화하는 코드를 작성하는 방식으로 처리할 수 있음.
- 생성자를 간단히 ctor라고 부름.
class SpreadsheetCell {
public:
SpreadsheetCell(double initialValue);
// 나머지 부분 생략
};
// 구현 코드
SpreadsheetCell::SpreadsheetCell(double initialValue) {
setValue(initialValue);
}
- 생성자도 일반 메서드를 구현하듯 구현코드를 작성해야 함.
- 생성자 이름은 클래스 이름과 똑같이 지정.
- 리턴값 없으며 필요에 따라서 매개변수를 받음.
- 특히 아무런 인수를 주지 않고 호출하는 생성자를 디폴트 생성자 라고 한다.
- 생성자도 일종의 SpreadsheetCell 클래스 멤버이다! 따라서 지정 연산자를 붙여야 함.
- 객체는 생성자를 통해 생성이 됨.
- 또한 그 객체의 값을 초기화 할 수 있다.
- 스택과 힙 객체에 모두 생성자를 사용 가능
SpreadsheetCell myCell(5), anotherCell(4);
cout << "cell 1: " << myCell.getValue() << endl;
cout << "cell 2: " << anotherCell.getValue() << endl;
- 이 때, SpreadsheetCell 생성자를 아래와 같이 선언과 동시에 호출하면 안 된다.
SpreadsheetCell myCell.SpreadsheetCell(5); // 컴파일 에러!
// 마찬가지로 선언한 뒤 호출도 불가!
SpreadsheetCell myCell;
myCell.SpreadsheetCell(5); // 컴파일 에러!
auto smartCellp = make_unique<SpreadsheetCell>(4);
// ... 셀을 다룸 스마트 포인터이므로 직접 삭제하지 않아도 무방
// 일반 포인터도 사용 가능 but 권장하지는 않음
SpreadsheetCell* myCellp = new SpreadsheetCell(5);
SpreadsheetCell* anotherCellp = nullptr;
anotherCellp = new SpreadsheetCell(4);
// ... 셀을 다룸
// clean
delete myCellp;
myCellp = nullptr;
delete anotherCellp;
anotherCellp = nullptr;
- 클래스에 여러 생성자를 만들 수도 있다.
- 인수의 개수나 타입만 서로 다르게 정의
- 이를 오버로딩 이라고 한다.
// 두 개의 생성자를 갖도록 수정한 예제
class SpreadsheetCell {
public:
SpreadsheetCell(double initialValue);
SpreadsheetCell(std::string_view iitialValue);
// 생략..
};
// 두 번쨰 생성자의 구현 코드
SpreadsheetCell::SpreadsheetCell(string_view initialValue) {
setString(initialValue);
}
// 정의한 생성자를 사용하는 예제
SpreadsheetCell aThirdCell("Test"); // string 타입의 인수를 받는 생성자 사용
SpreadsheetCell aFourthCell(4.4); // double 타입의 인수를 받는 생성자 사용
auto aFifthCellp = make_unique<SpreadsheetCell>("5.5"); // string 타입의 인수를 받는 생성자 사용
cout << "aThirdCell: " << aThirdCell.getValue() << endl;
cout << "aFourthCell: " << aFourthCell.getValue() << endl;
cout << "aFifthCellp: " << aFifthCellp->getValue() << endl;
- 생성자가 여러개라면 한 생성자 안에서 또 다른 생성자를 호출할 수도 있다. (복잡...하다..)
- string 타입을 인수로 받는 생성자에서 double 타입 인수를 받는 생성자를 호출하는 예제를 보자.
SpreadsheetCell::SpreadsheetCell(string_view initialValue){
SpreadsheetCell(stringToDouble(initialValue));
}
- 위의 예제는 의도한 대로 실행되지 않는다! 이는 위임 생성자를 이용하면 가능함.
- 아무런 인자를 받지 않는 생성자.
- 또 다른 말로 영인수(제로 인수) 생성자 라고 함.
디폴트 생성자가 필요한 경우
- SpreadsheetCell 클래스에 디폴트 생성자를 정의하지 않으면 컴파일 에러 발생
SpreadsheetCell cells[3]; // 컴파일 오류!
SpreadsheetCell* myCellp = new SpreadsheetCell[10]; // 여기서도 오류!
- 객체 배열을 생성할 때는 클래스에 디폴트 생성자를 정의하는 것이 편함!
- std::vector와 같은 라이브러리 컨테이너에 저장하려면 디폴트 생성자를 꼭 정의!
디폴트 생성자 작성법
class SpreadsheetCell {
public:
SpreadsheetCell();
// 생략
};
// 정의한 디폴트 생성자 구현 코드
SpreadsheetCell::SpreadsheetCell(){
mValue = 0;
}
- 스택 객체의 디폴트 생성자 호출
SpreadsheetCell myCell;
myCell.setValue(6);
cout << "cell 1: " << myCell.getValue() << endl;
- 가장 짜증나는 파싱 문제
SpreadsheetCell myCell(); // 컴파일 에러 발생 X
myCell.setValue(6); // 이 문장에서 컴파일 에러 발생
cout << "cell 1: " << myCell.getValue() << endl;
- 컴파일러는 첫 문장을 인수로 받지 않고 리턴 타입이 SpreadsheetCell인 myCell 이란 이름의 함수를 선언함.
- 두 번째 문장을 보고 함수 이름을 객체처럼 사용하는 실수를 저질렀다고 착각!
- 따라서 스택 객체를 생성할 때는 디폴트 생성자 이름 뒤에 소괄호를 생략하자.
컴파일러에서 생성한 디폴트 생성자
- SpreadsheetCell 클래스 정의의 첫 번째 버전에서 다음과 같이 코드를 작성해도 컴파일 에러는 발생하지 않는다.
SpreadsheetCell myCell;
myCell.setValue(6);
- 아래의 예시는 디폴트 생성자를 직접 선언하지 않음
class SpreadsheetCell{
public:
SpreadsheetCell(double initialValue); // 디폴트 생성자가 없음
// 생략
};
// 이렇게 작성한 뒤, 아래와 같이 작성시 컴파일 에러
SpreadsheetCell myCell;
myCell.setValue(6);
- 생성자를 지정하지 않으면 컴파일러가 디폴트 생성자를 대신 만들어 주기 때문
- 디폴트 생성자는 생성자를 하나도 선언하지 않아서 자동으로 생성되는 생성자라는 뜻도 있지만, 인수가 없어서 기본으로 호출되는 생성자라는 의미도 된다.
명시적 디폴트 생성자
- 이를 사용하면 클래스 구현 코드에 디폴트 생성자를 작성하지 않아도 가능
class SpreadsheetCell {
public:
SpreadsheetCell() = default;
SpreadsheetCell(double initialValue);
SpreadsheetCell(std::string_view initialValue);
// 생략
};
명시적으로 삭제된 생성자
- 이 개념은 정적 메서드로만 구성된 클래스를 정의하면 생성자를 작성할 필요가 없을 뿐 아니라 컴파일러가 디폴트 생성자를 만들면 안된다.
- 즉, 디폴트 생성자를 명시적으로 삭제해야 함.
class MyClass {
public:
MyClass() = delete;
};
- 지금까지는 데이터 멤버를 생성자 안에서 초기화 했음
- 또 다른 방법인 생성자 이니셜라이저(멤버 이니셜라이저 리스트) 를 제공함.
SpreadsheetCell::SpreadsheetCell(double initialValue)
: mValue(initialValue)
{
}
- 콜론으로 시작하며 각 항목을 쉼표로 구분함.
- 이를 이용하면 데이터 멤버를 생성하는 과정에서 초깃값을 설정할 수 있음.
- 이 방법이 값을 대입하는 것보다 훨씬 효율적
// 예를 들어 SpreadsheetCell를 다음과 같이 정의
class SpreadsheetCell {
public:
SpreadsheetCell (double d);
};
// 위의 클래스는 명시적 생성자만 있을 뿐, 디폴트 생성자는 없다.
// 아래의 클래스는 다른 클래스의 데이터 멤버로 정의하는 경우
class SomeClass {
public:
SomeClass();
private:
SpreadsheetCell mCell;
};
// SomeClass 구현 코드
SomeClass::SomeClass() { } // 컴파일 에러 발생
// 생성자 이니셜라이저 생성
SomeClass::SomeClass() : mCell(1.0) { }
- SomeClass 데이터 멤버인 mCell에 대해 디폴트 생성자가 없기 때문에 컴파일러는 mCell을 초기화 할 방법을 알 수 없음
- 따라서 생성자 이니셜라이저를 작성해야 한다.
생성자 이니셜라이저는 객체를 생성하는 시점에 데이터 멤버를 초기화 한다.
- 주의할 점
- 클래스 정의에 작성한 순서대로 초기화 된다.
- 따라서, 데이터 멤버는 생성자 이니셜라이저에 나온 순서가 아니라 클래스 정의에 나온 순서대로 초기화 한다.
- 특수한 생성자로, 다른 객체와 똑같은 객체를 생성할 때 사용
// SpreadsheetCell의 복제 생성자
class SpreadsheetCell {
public:
SpreadsheetCell(const SpreadsheetCell& src);
// 생략
};
- 복제 생성자는 원본 객체에 대한 const 레퍼런스를 인수로 받음.
- 다른 생성자와 마찬가지로 리턴값 없음
복제 생성자가 호출되는 경우
- 함수나 메서드에 객체를 전달하면 컴파일러는 그 객체의 복제 생성자를 호출하는 방식으로 초기화 진행
// string 매개변수를 값으로 받는 printString ()함수
void printString(string inString) {
cout << inString << endl;
}
- string 매개변수인 inString은 이 클래스의 복제 생성자를 호출하는 방식으로 초기화 진행
string name = "hong gil dong";
printString(name); // name 복제
- 복제 생성자에 const 레퍼런스로 매개변수를 전달하면 오버헤드를 줄일 수 있음.
복제 생성자 명시적 호출
- 주로 다른 객체를 똑같이 복사하는 방식으로 객체를 만들 때 사용
SpreadsheetCell myCell1(4);
SpreadsheetCell myCell2(myCell1); // myCell2는 myCell1과 같음
레퍼런스로 객체 전달
- 레퍼런스로 전달하면 복제 연산이 없으므로 오버헤드를 줄일 수 있음.
- 단지 성능의 이유만으로 레퍼런스를 사용한다면 객체가 변경되어지지 않도록 const를 붙이자
- std::initializer_list를 첫 번째 매개변수
- 다른 매개변수는 없거나 디폴트 값을 가진 매개변수를 추가로 받는 생성자
- 다음 클래스는 짝수 개의 원소를 가진 initializer_list 만 매개변수로 받음
// 짝수 개수가 아니면 예외 발생
class EvenSequence {
public:
EvenSequence(initializer_list<double> args) {
if (args.size() % 2 != 0) {
throw invalid_argument("initializer_list should contain even number of elements.");
}
mSequence.reserve(args.size());
for (const auto& value : args) {
mSequence.push_back(value);
}
}
void dump() const {
for (const auto& value : mSequence) {
cout << value << ", ";
}
cout << endl;
}
private:
vector<double> mSequence;
};
- 또한 표준 라이브러리에 나온 클래스는 모두 이니셜라이저 리스트 생성자 지원.
std::vector<std::string> myVec = {"String 1", "String 2", "String 3"};
- 즉, 생성자 뿐만 아니라 일반 함수에서도 사용이 가능!!
- 같은 클래스의 다른 생성자를 생성자 안에서 호출할 수 있음.
SpreadsheetCell::SpreadsheetCell(string_view initialValue)
: SpreadsheetCell(stringToDouble(initialValue))
{
}
- string_view 타입 생성자가 호출
- 이를 double 타입 생성자(타깃 생성자)에 위임
- 타깃 생성자가 리턴하면 위임 생성자의 코드 실행
- 재귀적으로 호출되지 않도록 주의
- 컴파일러는 모든 클래스에 디폴트 생성자와 복제 생성자를 자동으로 만든다.
- 따라서, 프로그래머가 직접 작성한 생성자에 따라 만들어주는 생성자가 달라질 수 있다!!
-
객체가 제거되는 두 단계 과정
-
객체의 소멸자 호출 -> 할당받은 메모리 반환
-
메서드 또는 코드 블록이 끝날 때
- 즉, 스코프(유효 범위)를 벗어날 때 자동으로 삭제
-
-
스마트 포인터를 사용하지 않는 힙 객체는 자동으로 삭제되어지지 않음
-
delete를 사용해서 메모리를 해제하자
int main() {
SpreadsheetCell* cellPtr1 = new SpreadsheetCell(5);
SpreadsheetCell* cellPtr2 = new SpreadsheetCell(6);
cout << "cellPtr1: " << cellPtr1->getValue() << endl;
delete cellPtr1; // cellPtr1 삭제
cellPtr1 = nullptr;
return 0;
} // cellPtr2에 대해 delete를 직접 호출하지 않았으므로 삭제되어지지 않음
해제되지 않는 객체를 찾아주는 도구는 7장에 설명되어 있음!
- 객체의 값을 다른 객체에 대입할 수 있다.
SpreadsheetCell myCell(5), anotherCell;
anotherCell = myCell;
- 이미 값이 할당된 객체에 덮어쓸 때는 대입(assign) 이라고 표현
- C++는 클래스마다 대입을 수행하는 메서드를 따로 제공 -> 대입 연산자
- 위의 예제에서 anotherCell의 대입 연산자는 myCell이란 인수를 전달해서 호출
- C++에서 객체끼리 서로 대입할 수 있도록 자동으로 만들어줌
- 이를 디폴트 대입 연산자라고 하며 복제 동작과 거의 같음.
- 즉, 원본 데이터 멤버를 대상 객체로 대입하는 작업을 재귀적으로 수행!
class SpreadsheetCell {
public:
SpreadsheetCell& operator=const SpreadsheetCell& rhs); // rhs(right-hand side) : 우항
// 나머지 코드 생략
};
- 대입 연산자는 복제 연산자와 달리 SpreadsheetCell 객체에 대한 레퍼런스를 리턴
- 이유는 아래와 같이 여러 대입 연산이 연달아 나올 수 있기 때문
myCell = anotherCell = aThirdCell;
- 쉽게 말해서 anotherCell에서 제대로 결과를 리턴하지 않으면 myCell로는 아무것도 전달되지 않음
- 위 문장을 풀어쓰면..
myCell.operator=(anotherCell.operator=(aThirdCell));
- 여기서 anotherCell을 직접 리턴하면 성능이 떨어지므로 anotherCell에 대한 레퍼런스를 리턴함
- 복제 생성자와 대입 연산자의 차이점 2가지
- 복제 생성자는 초기화할 때 단 한 번만 호출
- 자세한 내용은 9장에서
- C++은 객체에 자기 자신을 대입할 수 있음
- 다음 예시에서 컴파일 오류가 발생하지 않음
- 복제 생성자는 초기화할 때 단 한 번만 호출
SpreadsheetCell cell(4);
cell = cell; // 자기 자신 대입
SpreadsheetCell& SpreadsheetCell::operator=(const SpreadsheetCell& rhs)
{
if(this == &rhs) { // 자기 자신을 대입하는지 확인
return *this;
}
}
- this 키워드로 좌변 객체 지정, &rhs는 우변 객체 지정하는 포인터
- 만약 두 포인터의 값이 같으면 자기 자신을 대입
- this는 객체를 가리키는 포인터이므로 *this로 리턴
SpreadsheetCell& operator=(const SpreadsheetCell& rhs) = default; // 명시적으로 디폴트 생성
SpreadsheetCell& operator=(const SpreadsheetCell& rhs) = delete; // 명시적으로 삭제
- C++11 부터는 클래스에 사용자가 선언한 복제 대입 연산자 또는 소멸자가 있다면 복제 생성자를 생성해주는 기능을 지원하지 않는다.
// 굳이 사용한다면..
MyClass(const MyClass& src) = default;
- 선언처럼 생겼다면 복제 생성자
- 대입문처럼 생겼다면 대입 연산자
SpreadsheetCell myCell(5);
SpreadsheetCell anotherCell(myCell); // 복제 생성자
SpreadsheetCell aThirdCell = myCell; // 복제 생성자
//anotherCell의 operator= 호출하는 경우
anotherCell = myCell;
- 함수나 메서드에서 객체를 리턴할 때 복제될지 아니면 대입될지 판단하기 힘들다.
string SpreadsheetCell::getString() const {
return doubleToString(mValue);
}
// 호출
SpreadsheetCell myCell2(5);
string s1;
s1 = myCell2.getString(); // 이 한줄의 코드에서 복제 생성자와 대입 연산자가 서로 다른 두 객체에 대해서 호출되어짐.
- 값을 리턴할 때 복제 생성자의 오버헤드가 크다면 리턴값 최적화 또는 복제 생략을 적용해서 초기화한다.
- 객체와 클래스의 개념을 확실히 짚고 넘어갈 수 있도록 하자.
- 다음 장에서는 객체와 클래스에 관련된 보다 자세한 사항과 활용 기법에 대해서 알아보자.