[C++ Primer Plus] Chapter 12. 클래스와 동적 메모리 대입
Table of Contents
C++ 기초 플러스 책을 읽고 공부한 노트입니다.
서론 #
- 어떤 사람의 이름을 저장하는 클래스를 만들고 싶다고 하자, 만약 이름 길이가 40자가 넘어가면 어떻할 것인가?
- 40개를 저장할 수 있는 문자 배열을 만들면 될까?
- 그럼 극히 일부만 채워지는 멤버 때문에 많은 메모리가 낭비될 것이다.
- 문자열의 길이가 컴파일 할 때가 아니라 실행할 때 결정되도록 하면 좋겠다.
- 일반적으로
string
클래스를 사용하면 되지만,new
와delete
를 사용해서 메모리에 대해 배워보기 위해string
클래스와 비슷한 클래스를 직접 만들어보면서 배워보자.
무언가 잘못된 StringBad 클래스 #
string
클래스와 비슷한 기능을 하는StringBad
클래스를 만들어보았다.
// StringBad.h
#include <iostream>
using namespace std;
class StringBad
{
private:
char * str;
int len;
// static 클래스 멤버는 생성되는 객체 수와 상관없이 단 하나만 생성된다.
static int stringCount;
public:
StringBad(const char * s);
StringBad();
~StringBad();
friend ostream & operator<<(ostream & os, const StringBad & st);
};
// StringBad.cpp
#include <cstring>
#include "StringBad.h"
// static 클래스 멤버를 초기화 한다.
int StringBad::stringCount = 0;
StringBad::StringBad(const char * s)
{
// str = s로 하면 안 된다. 주소만 저장하기 때문이다.
// 아래와 같이 문자열의 복사본을 만들어서 저장해야 한다.
len = strlen(s);
str = new char[len + 1]; // 문자열은 힙에 저장되겠다.
strcpy(str, s);
stringCount++;
cout << str << " 이 생성됨. " << " stringCount: " << stringCount << endl;
}
StringBad::StringBad()
{
len = 6;
str = new char[6];
strcpy(str, "empty");
stringCount++;
cout << str << " 이 생성됨. " << " stringCount: " << stringCount << endl;
}
StringBad::~StringBad()
{
--stringCount;
cout << str << " 이 파괴됨. " << " 남은 stringCount: " << stringCount << endl;
// 객체를 파괴할 때 객체가 차지하는 메모리는 해제되지만
// 객체의 멤버인 포인터가 가리키는 메모리는 자동으로 해제되지 않는다.
// 따라서 아래와 같이 해제해준다.
delete [] str;
}
ostream & operator<<(ostream & os, const StringBad & st)
{
os << st.str;
return os;
}
static
멤버 변수- 해당 클래스로 만들어지는 객체의 전체 수를 카운트해보기 위해 만들었다.
- 생성되는 객체 수에 상관 없이 이 변수는 단 하나만 생성되고, 공유된다.
- 초기화를 클래스 선언 안에서 할 수 없다.
- 왜냐하면
static
멤버 변수는 객체의 일부분으로 저장되는 것이 아니라 별도로 저장되기 때문이다. 그래서 클래스 선언 바깥에서 초기화해주어야 한다. - 헤더 파일에서 초기화한다면, 헤더파일이 여러번 포함되어 그 개수만큼 여러번 반복되어 초기화하기 때문에 에러가 발생한다.
- 따라서 멤버 함수 구현 파일에 넣는다.
- 정수형/열거형의
const
인 경우에는 클래스 선언 안에서 초기화할 수 있다.
- 왜냐하면
// main.cpp
#include <iostream>
#include "StringBad.h"
using namespace std;
void CallByReference(StringBad& s)
{
cout << s << " 가 참조로 전달되었다" << endl;
}
// 값으로 객체를 전달하면, 복사 생성자가 호출된다.
void CallByValue(StringBad s)
{
cout << s << " 가 값으로 전달되었다" << endl;
}
int main()
{
{
StringBad one("one");
StringBad two("two");
StringBad three("three");
cout << endl;
CallByReference(one);
cout << "돌아온 one: " << one << endl << endl;
CallByValue(two);
cout << "돌아온 two: " << two << endl << endl;
// 하나의 객체를 다른 객체로 초기화하면 복사 생성자가 호출된다.
cout << "three 객체로 초기화하기" << endl;
StringBad three2 = three;
cout << "three2: " << three2 << endl << endl;
// 하나의 객체러르 다른 객체에 대입하면 대입 연산자가 호출된다.
cout << "one 객체로 대입하기" << endl;
StringBad one2;
one2 = one;
cout << "one2: " << one2 << endl << endl;
}
}
- 출력 결과
one 이 생성됨. stringCount: 1
two 이 생성됨. stringCount: 2
three 이 생성됨. stringCount: 3
one 가 참조로 전달되었다
돌아온 one: one
two 가 값으로 전달되었다
two 이 파괴됨. 남은 stringCount: 2 // 문제 1
돌아온 two: L
three 객체로 초기화하기
three2: three
one 객체로 대입하기
empty 이 생성됨. stringCount: 3
one2: one
one 이 파괴됨. 남은 stringCount: 2
three 이 파괴됨. 남은 stringCount: 1
� 이 파괴됨. 남은 stringCount: 0
signal: aborted (core dumped) // 문제 2
- 다음과 같은 문제가 있다.
- (1) 값으로 전달된 후 파괴자가 호출되었다. 그리고 원본
two
객체의 값이 이상해졌다. - (2) 블록을 빠져나오면 파괴자가 줄줄이 호출되는데 객체 수가 2개나 부족하다. 무언가 또 다른 것이 파괴자를 호출한 것임에 분명하다.
- (1) 값으로 전달된 후 파괴자가 호출되었다. 그리고 원본
-
C++이 자동으로 다음과 같은 멤버 함수들을 제공한다.
- (1) 생성자를 전혀 정의하지 않았을 경우에 디폴트 생성자
- (2) 디폴트 파괴자를 정의하지 않았을 경우에 디폴트 파괴자
- (3) 복사 생성자를 정의하지 않았을 경우에 복사 생성자
- (4) 대입 연산자를 정의하지 않았을 경우에 대입 연산자
- (5) 주소 연산자를 정의하지 않았을 경우에 주소 연산자
- 이동 생성자, 이동 대입 연산자 (C++11) (Chapter 18)
-
암시적 주소 연산자는 호출한 객체(
this
포인터의 값)의 주소를 리턴한다.- 이것은
StringBad
클래스의 설계 목적에 합당하다. - 하지만 복사 생성자와 대입 연산자는 그렇지 않다.
- 이것은
복사 생성자 #
- 원형
StringBad(const StringBad &);
- 복사 생성자가 불리는 경우: 프로그램이 객체의 복사본을 생성할 때
- 새로운 객체가 생성되어 같은 종류의 기존의 객체로 초기화될 때
- 함수가 객체를 값으로 전달할 때
- 함수가 객체를 리턴할 때
- 임시객체를 생성할 때
StringBad one("one")
// 초기화 시에는 모두 복사 생성자를 호출한다.
StringBad two(one);
StringBad three = StringBad(one);
StringBad * four = new StringBad(one);
- 하는 일
static
멤버를 제외한 멤버들을 멤버별로 복사한다(얕은 복사).- 각 멤버는 값으로 복사한다.
- 따라서 아래의 두 코드는 같은 말이다.
StringBad two = one; // 이것과
StringBad two; // 이것은 같은 말이다.
two.str = one.str;
two.len = one.len;
-
StringBad 예시에서 값으로 전달된 후에 원본 객체의 값이 이상해진 건 복사 생성자 때문이었다.
CallByValue()
가 호출될 때 값으로 전달되는 매개변수를 초기화하는 데 복사 생성자가 사용된다.CallByValue()
함수가 끝나면서 파괴자를 호출해서 전달된 객체 원본의 메모리를 해제해버린다.
-
StringBad 예시에서 파괴된 객체가 두 개 더 많았던 것 중 하나는 복사 생성자 때문이었다.
three2
객체를three
객체로 초기화할 때 복사 생성자가 사용된다.- 우리는 복사 생성자를 정의하지 않았으므로 이 때 불리는 복사 생성자는
stringCount
를 올려주지 않는다. - 또한 복사 생성자는 문자열 자체를 복사하지 않고 문자열을 지시하는 포인터를 복사한다. 즉, 얕은 복사를 한다. 두 개의 포인터가 하나의 문자열을 가리키는 형상이 되는 것이다. 따라서 다음과 같은 문제가 생겼던 것이다.
- 그래서
three2
의 파괴자가 불렸을 때three
의 메모리를 해제해버린다. 그리고three
의 파괴자는 이미 삭제한 문자열을 다시 삭제하려고 시도한다.
- 명시적 복사 생성자를 제공함으로써 문제를 해결할 수 있다.
- 아래의 코드는 깊은 복사를 수행한다. 즉, 문자열 자체를 복사하고, 그 복사본의 주소를 str 멤버에 대입하게 한다.
StringBad::StringBad(const StringBad & st)
{
stringCount++;
len = st.len;
str = new char[len + 1];
strcpy(str, st.str);
}
클래스 멤버 중에 포인터가 있다면 복사 생성자를 필수로 정의해야 한다.
대입 연산자 #
- 원형
StringBad & StringBad::operator=(const StringBad &);
- 대입 연산자가 불리는 경우
- 하나의 객체를 기존의 다른 객체에 대입할 때
StringBad one("one");
StringBad one2;
one2 = one;
- 하는 일
- 복사 생성자와 마찬가지로
static
멤버를 제외한 멤버들을 멤버별로 복사한다(얕은 복사).
- 복사 생성자와 마찬가지로
- StringBad 예시에서 파괴된 객체가 두 개 더 많았던 것 중 하나는 대입 연산자 때문이었다.
one2
객체를one
객체로 초기화할 때 대입 연산자가 사용된다.- 우리는 대입 연산자를 정의하지 않았으므로 이 때 불리는 대입 연산자는
stringCount
를 올려주지 않는다. - 또한 대입 연산자는 얕은 복사를 하기 때문에
one2
의 파괴자가 불렸을 때 one의 메모리를 해제해버린다. 그리고one
의 파괴자는 이미 삭제한 문자열을 다시 삭제하려고 시도한다.
- 명시적 대입 연산자를 제공함으로써 문제를 해결할 수 있다.
- 복사 연산자와의 차이에 유념하며 구현해야 한다. 차이점은 아래와 같다.
- (1) 이전에 대입된 데이터를 참조하고 있을 수도 있으므로 먼저 해제해주어야 한다.
- (2) 자기 자신에게 대입하지 못하게 막아야 한다.
- (3) 호출한 객체에 대한 참조를 리턴해야 한다. 그래야
one = two = three;
와 같은 연산이 가능해진다.
StringBad & StringBad::operator=(const StringBad & st)
{
if (this == &st) return *this // 2
delete [] str; // 1
len = st.len;
str = new char[len + 1];
strcpy(str, st.str);
return *this; // 3
}
- 대입은 새로운 객체를 만들지 않는다. 따라서
stringCount
를 올릴 필요는 없다.
개선된 String 클래스 #
- 위와 같은 문제들을 개선한
String
클래스이다.
// String.h
#include <iostream>
using namespace std;
class String
{
private:
char * str;
int len;
static int stringCount;
static const int CINLIM = 80; // cin 입력 제한 글자수
public:
String(const char * s);
String();
String(const String &); // 명시적 복사 생성자
~String();
int length () const { return len; }
String & operator=(const String &); // 명시적 대입 연산자
String & operator=(const char *); // 보통의 문자열을 String 객체에 복사하기 위한 대입 연산자
char & operator[](int i); // []표기를 사용해서 개별 문자에 접근하기
const char & operator[](int i) const; // const 객체에 사용하기 위함
friend bool operator<(const String &st, const String &st2);
friend bool operator>(const String &st1, const String &st2);
friend bool operator==(const String &st, const String &st2);
friend ostream & operator<<(ostream & os, const String & st);
friend istream & operator>>(istream & is, String & st);
static int HowMany(); // static 멤버 함수
};
// string1.cpp
#include <cstring>
#include "String.h"
using namespace std;
int String::stringCount = 0;
// static 멤버 함수
int String::HowMany()
{
return stringCount;
}
String::String(const char * s)
{
len = std::strlen(s);
str = new char[len + 1];
std::strcpy(str, s);
stringCount++;
}
// 개선된 디폴트 생성자
String::String()
{
len = 0;
str = new char[1];
str[0] = '\0';
stringCount++;
}
String::String(const String & st) // 명시적 복사 생성자
{
stringCount++;
len = st.len;
str = new char [len + 1];
std::strcpy(str, st.str);
}
String::~String()
{
--stringCount;
delete [] str;
}
String & String::operator=(const String & st) // 명시적 대입 연산자
{
if (this == &st)
return *this;
delete [] str;
len = st.len;
str = new char[len + 1];
std::strcpy(str, st.str);
return *this;
}
// 보통의 문자열을 String 객체에 복사하기 위한 대입 연산자
String & String::operator=(const char * s)
{
delete [] str;
len = std::strlen(s);
str = new char[len + 1];
std::strcpy(str, s);
return *this;
}
// []표기를 사용해서 개별 문자에 접근하기
char & String::operator[](int i)
{
return str[i];
}
// const 객체에 사용하기 위함
const char & String::operator[](int i) const
{
return str[i];
}
// friend 함수들
bool operator<(const String &st1, const String &st2)
{
return (std::strcmp(st1.str, st2.str) < 0);
}
bool operator>(const String &st1, const String &st2)
{
return st2 < st1;
}
bool operator==(const String &st1, const String &st2)
{
return (std::strcmp(st1.str, st2.str) == 0);
}
ostream & operator<<(ostream & os, const String & st)
{
os << st.str;
return os;
}
istream & operator>>(istream & is, String & st)
{
char temp[String::CINLIM];
is.get(temp, String::CINLIM);
if (is)
st = temp;
while (is && is.get() != '\n')
continue;
return is;
}
1. 개선된 디폴트 생성자
String::String
{
len = 0;
// 이것은 파괴자에 있는 delete [] str;과 맞추기 위함이다.
str = new char[1];
str[0] = '\0';
// 이렇게 써도 된다.
str = nullptr;
stringCount++;
}
- 널 포인터
- 아무것도 가리키는 않는 포인터이다.
- C++가 소스 코드에서 0을 표현할 때 널 포인터를 사용했다.
- 하지만 종종 포인터 상수와 정수 모두 0으로 표현하기 때문에 문제가 발생했고 새로운 키워드
nullptr
을 제공했다.- 이것은 포인터 타입이며 정수형으로 변환할 수 없다.
nullptr == 0
은true
지만, 예를 들어서int
형 매개변수를 받는 함수에서0
은 통과되지만nullptr
은 컴파일 에러가 난다.- 따라서 컴파일러가 분명하고 안전하게 받아들일 수 있도록
nullptr
를 사용해야 한다.
2. []표기를 사용해서 개별 문자에 접근하기
char name[10] = "Kim";
cout << name[0] << endl;
name
은 첫번째 피연산자이고,[]
은 연산자이고,0
은 두 번째 피연산자이다.- 따라서
[]
연산자를 다음과 같이 만들 수 있다. - 리턴형이
char &
이기 때문에 값을 대입하는 것 또한 가능하다.
- 따라서
char & String::operator[](int i)
{
return str[i];
}
String one("one");
cout << one[0] << endl;
cin >> one[0]; // (O)
- 하지만
const
객체에는 사용이 불가능하다.- C++이
const
함수 시그내처와const
가 아닌 함수 시그내처를 구별하기 때문이다. - 따라서
const String
객체가 사용할 수 있는 제 2의 버전을 제공할 수 있다.
- C++이
const char & String::operator[](int i) const
{
return str[i];
}
const String one("one");
cout << one[0] << endl;
cin >> one[0]; // (X) const 객체는 쓰기가 불가능하다.
3. 보통의 문자열을 String
객체에 복사하기를 원한다면 어떻게 해야 할까?
String name;
char temp[40];
cin.getline(temp, 40);
name = temp;
-
이 예제의 마지막 구문은 이렇게 동작한다.
- (1)
String(const char *)
생성자(변환 함수의 역할)를 사용해서temp
를String
임시 객체로 만든다. - (2)
String & operator=(const String & )
함수를 사용해서String
임시 객체의 내용을name
에 담는다. - (3)
String
임시 객체를 파괴한다.
- (1)
-
따라서 다음과 같은 대입 연산자를 오버로딩하면, 임시 객체를 생성하고 파괴하는 절차가 생략되겠다.
String & String::operator=(const char * s)
{
delete [] str;
len = strlen(s);
str = new char[len + 1];
strcpy(str, s);
return *this;
}
4. static
멤버 함수
static
키워드는 함수 선언에 나타나야 한다.- 다음과 같은 특징을 가진다.
- 객체에 의해 호출될 필요가 없다. 클래스 이름과 사용범위 결정 연산자를 사용하여 호출된다.
- 어떤 특정 객체와도 결합하지 않기 때문에 사용할 수 있는 데이터 멤버는
static
데이터 멤버밖에 없다.
class String
{
public:
// …
static int HowMany() { return stringCount; } // static 멤버에만 접근할 수 있다.
};
int main()
{
int count = String::HowMany(); // static 멤버 함수를 호출한다.
}
객체 리턴에 대한 관찰 #
1. const
객체를 참조 리턴하는 경우
- 효율성 측면에서 유리하다.
class Vector {};
// 리턴형이 Vector 라면 복사 생성자를 호출할 것이다.
// 참조를 리턴함으로써 좀 더 효율적이게 된다.
const Vector & Max(const Vector & v1, const Vector & v2)
{
if (v1.magval() > v2.magval()) return v1;
else return v2;
}
Vector vec1(30, 60);
Vector vec2(10, 70);
Vector max = Max(vec1, vec2);
2. const
가 아닌 객체를 참조 리턴 하는 경우
- 대입 연산자 오버로딩
cout
과 함께 사용하기 위한<<
연산자 오버로딩
String s1("Good Stuff");
String s2, s3;
s3 = s2 = s1;
// s2 = s1의 리턴 값이 s1의 참조여야 s3 = s1가 가능하다.
// 참조가 아니면 복사 생성자가 호출되므로, 참조로 리턴하는 게 효율적이다.
cout << s1 << " is coming!";
// ostream은 public 복사 생성자를 만들지 않으므로
// 참조가 아니면 동작하지 않는다.
3. const
객체를 리턴하는 경우
- 리턴되는 객체가 지역적이면 참조를 리턴하면 안 된다.
Vector vec1(50, 60);
Vector vec2(10, 70);
Vector sum = vec1 + vec2;
// 두 벡터의 합의 결과인 임시벡터를 참조로 리턴하면 안된다. 실제 객체를 리턴해야 한다.
Vector Vector::operator+(const Vector & v) const
{
return Vector(x + v.x, y + v.y);
}
4. const
가 아닌 객체를 리턴하는 경우
- 위의 코드인 경우 아래와 같은 것이 가능해진다.
sum = vec1 + vec2;
// 이것도 가능하다. vec1 + vec2의 결과로 임시객체를 만들고, 그것에 sum의 값이 대입된다.
vec1 + vec2 = sum;
- 따라서 다음과 같이 리턴형을
const
로 하면 두번째 경우는 불가능해 진다.
const Vector Vector::operator+(const Vector & v) const
{
return Vector(x + v.x, y + v.y);
}
위치 지정 new와 클래스 #
char * buffer = new char[512];
TestClass *t1, *t2;
t1 = new (buffer) TestClass("test one");
t2 = new (buffer) TestClass("test two"); // t1의 데이터를 덮어쓴다
delete [] buffer;
t2
의 경우t1
의 데이터를 덮어 쓴다.- 이것은
t1
에 대한 파괴자가 호출하지 않기 때문에 위험하다.
- 이것은
- 따라서 다음과 같이 바꿀 수 있겠다.
t2 = new (buffer + sizeof(TestClass)) TestClass("test two");
- 이 구현은
t1
,t2
객체에 대한 파괴자를 부르지 않는다.delete [] t1;
으로 한다고 해도t1
은buffer
를 해제할 것이다.
- 따라서 파괴자를 명시적으로 호출할 수 있겠다. (역순으로)
t2->~TestClass();
t1->~TestClass();
큐 시뮬레이션 만들기 #
1. 구조체, 클래스, 열거체가 어떤 클래스 안에서 선언되면, 그 선언은 그 클래스의 사용 범위를 가진다.
- 데이터 객체를 생성하지 않고, 그 클래스 안에서 내부적으로 사용할 수 있는 데이터형을 서술한다.
public
부분에 있다면, 클래스 바깥에서 변수를 선언할 수 있다.
class Queue
{
private:
struct PrivateNode
{
int data;
};
public:
struct PublicNode
{
int data;
};
};
int main()
{
PrivateNode n1; // (X) private struct는 그 클래스 안에서만 사용할 수 있다.
PublicNode n2; // (X) public struct는 아래 처럼 사용해야 한다.
Queue::PublicNode n3; // (O)
}
2. 멤버 초기자 리스트
const
형 멤버를 초기화하기 위해서는 어떻게 해야할까?
class Queue
{
private:
const int queueSize; // 이것을 0으로 초기화해야 한다.
};
- 생성자에서 하면 될까?
const
변수는 초기화될 수는 있지만, 대입될 수는 없다.- 생성자의 중괄호 안의 내용이 실행되기 전에 객체가 먼저 생성된다.
- 따라서 중괄호 안의 내용은
const
멤버 변수를 초기화하는 것이 아니라 대입하는 것이다.
Queue::Queue(int size)
{
queueSize = size; // (X) 대입이다. 안 된다.
}
- 이럴 경우 객체가 생성될 때 초기화하는 멤버 초기자 리스트를 사용할 수 있다.
Queue::Queue(int size) : queueSize(size)
{
// …
}
- 멤버 초기자 리스트 문법은 생성자만이 사용할 수 있다.
- 참조로 선언된 클래스 멤버들에 대해서도 이 문법을 사용해야 한다.
- 참조는 생성될 때만 초기화될 수 있기 때문이다.
class Agency { };
class Agent
{
private:
Agency & belong; // 참조로 선언된 클래스 멤버
public:
Agent(Agency & a);
};
// 멤버 초기자 리스트를 사용해서 초기화해야 한다.
Agent::Agency(Agency & a) : belong(a)
{
// …
}
3. 복사와 대입을 막는 복사 연산자와 대입 연산자를 구현 방법
- 지금 당장은 깊은 복사를 하는 복사 연산자와 대입 연산자가 필요 없다.
- 하지만 위험하므로 복사와 대입은 막고 싶다면?
- 명목상의
private
메서드로 정의한다. 그러면 바깥에서 사용하지 못하게 된다. - 또 다른 방법은
delete
키워드를 사용하는 것이다. (Chapter 18)
- 명목상의
class Queue
{
private:
// 선점 정의
Queue(const Queue & q) : queueSize(0) { }
Queue & operator=(const Queue & q) { return *this; }
};
int main()
{
Queue q1;
Queue q2(q1); // (X)
Queue q3;
q3 = q1; // (X)
}