Skip to main content

[C++ Primer Plus] Chapter 12. 클래스와 동적 메모리 대입

C++ 기초 플러스 책을 읽고 공부한 노트입니다.




서론 #

  • 어떤 사람의 이름을 저장하는 클래스를 만들고 싶다고 하자, 만약 이름 길이가 40자가 넘어가면 어떻할 것인가?
  • 40개를 저장할 수 있는 문자 배열을 만들면 될까?
  • 그럼 극히 일부만 채워지는 멤버 때문에 많은 메모리가 낭비될 것이다.
  • 문자열의 길이가 컴파일 할 때가 아니라 실행할 때 결정되도록 하면 좋겠다.
  • 일반적으로 string 클래스를 사용하면 되지만, newdelete를 사용해서 메모리에 대해 배워보기 위해 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개나 부족하다. 무언가 또 다른 것이 파괴자를 호출한 것임에 분명하다.

  • 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 == 0true지만, 예를 들어서 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의 버전을 제공할 수 있다.
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 *) 생성자(변환 함수의 역할)를 사용해서 tempString 임시 객체로 만든다.
    • (2) String & operator=(const String & ) 함수를 사용해서 String 임시 객체의 내용을 name에 담는다.
    • (3) String 임시 객체를 파괴한다.
  • 따라서 다음과 같은 대입 연산자를 오버로딩하면, 임시 객체를 생성하고 파괴하는 절차가 생략되겠다.

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; 으로 한다고 해도 t1buffer를 해제할 것이다.

  • 따라서 파괴자를 명시적으로 호출할 수 있겠다. (역순으로)
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)
}