static 클래스 멤버
class StringBad
{
private:
char * str;
int len;
static int num_strings;
public:
StringBad(const char * s);
StringBad();
~StringBad();
// vmfpsem gkatn
friend std::ostream & operator<<(std::ostream & os, const StringBad & st);
}
이 선언에서 우리은 두 가지 점에 주목해야 한다.
첫째, 이름을 나타내기 위해 char형의 배열이 아니라 char형을 지시하는 포인터를 사용한다는 점이다. 이것은 그 클래스 언언이 문자열 자체를 위해 기억 공간을 대입하지 않는 뜻이다.
둘째, num_string 멤버를 static 기억 공간에 속하는 것으로 선언한다. static 클래스 멤버는 특별한 성질을 가진다. 즉, 생성되는 객체 수와 상관없이 static 클래스 변수를 단 하나만 생성한다. 하나의 전화번호를 모든 가족이 공유하듯이, 하나의 static 클래스 멤버는 그 클래스의 모든 객체가 공유한다. 예를 들어 10개의 StringBad 객체를 생성하면, 10개의 str 멤버와 10개의 len 멤버가 생기지만 num_strings 멤버는 하나만 생긴다.
프로그램 분석
void callme1(StringBad &);
void callme2(StringBad);
int main()
{
StringBad headline1("Celery Stalks at Midnight");
StringBad headline2("Lettuce Prey");
StringBad sports("Spinach Leaves Bow1 for Dollars");
callme1(headline1);
callme1(headline2);
StringBad sailor = sports;
}
자동 기억 공간에 저장되는 객체들은 생성된 순서와 반대 순서로 파괴되기 된다. 해당 코드에 문제점은 생성된 객체는 3개가 아닌 5개가 되고 2개는 str에 이상한 문구를 출력한다는 것이다.
프로그램에서 하나의 객체를 다른 객체로 초기화하면, 컴파일러는 복사 생성자(copy constructor)라고 불리는 생성자를 자동으로 발생시킨다. 복사 생성자는 객체의 사본을 만든다. 이 프로그램에서 자동으로 발생되는 복사 생성자는, static 변수인 num_strings를 갱신하는 것에 대해서 알지 못한다. 그래서 객체 수의 카운트가 엉망이 된 것이다. 이 예제가 보여 주는 모든 문제점들은, 컴파일러가 자동으로 발생시키는 멤버 함수 때문에 일어나는 것이다. 그러므로 이제 그 주제에 대해서 알아보자.
특별 멤버 함수
StringBad 클래스가 일으키는 문제점들은 자동으로 정의된 특별 멤버 함수 때문에 일어나는 것이다. 즉, 특별 멤버 함수의 행동이 StringBad 클래스 설계에 어울리지 않기 때문에 그러한 문제가 발생하는 것이다. 특별히 C++는 다음과 같은 멤버 함수를 자동으로 제공한다.
- 생성자를 전혀 정의하지 않았을 경우에 디폴트 생성자
- 디폴트 파괴자를 정의하지 않았을 경우에 디폴트 파괴자
- 복사 생성자를 정의하지 않았을 경우에 복사 생성자
- 대입 연산자를 정의하지 않았을 경우에 대입 연산자
- 주소 연산자를 정의하지 않았을 경우에 주소 연산자
좀 더 정확하게 말해서, 마지막 네 항목이 요구하는 각각의 방식으로 프로그램이 객체들을 사용한다면, 컴파일러는 각 항목에 해당하는 정의들을 생성한다. 예를 들면, 한 객체를 다른 객체에 대입하면, 프로그램은 대입 연산자를 위한 정의를 제공한다.
암시적 복사 생성자와 암시적 대입 연산자가 StringBad 클래스의 문제를 일으키고 있는 것이다.
디폴트 생성자
사용자가 어떠한 생성자도 제공하지 않으면, C++가 디폴트 생성자를 제공한다. 예를 들어, Klunk 클래스를 정의할 때 어떠한 생성자도 제공하지 않았다고 가정하자. 이 경우에, 컴파일러는 다음과 같은 디폴트 생성자를 제공한다.
Klunk::Klunk() {} // 암시적 디폴트 생성자
그 디폴트 생성자는 lunk를 보통의 자동 변수처럼 만든다. 즉, 초기에 그 값이 알려지지 않는다.
사용자가 어떤 생성자를 정의했다면, C++는 디폴트 생성자를 제공하지 않는다. 명시적으로 초기화하지 않는 객체를 만들고 싶거나, 객체들의 배열을 만들고 싶다면, 디폴트 생성자를 명시적으로 정의해야 한다. 디폴트 생성자는 매개변수를 사용하지 않는 생성자이다. 그러나 특별한 값들이 설정하는 데 그것을 사용할 수 있다.
Klunk::Klunk() //명시적 디폴트 생성자
{
klunk_ct = 0;
}
매개변수를 사용하는 생성자들도, 모든 매개변수들에 디폴트 값을 제공한다면, 디폴트 생성자가 될 수 있다. 예를 들어, Klunk 클래스는 다음과 같은 인라인 생성자를 가질 수 있다.
Klunk(int n = 0) { Klunk_ct = n; }
그러나 디폴트 생성자는 하나만 가질 수 있다. 즉 다음과 같이 할 수 없다.
Klunk() { klunk_ct = 0; } // 생성자 #1
Klunk(int n = 0) { klunk_ct = 0; } // 모호한 생성자 #2
이것이 왜 모호할까? 다음과 같은 두 선언을 고려해 보자.
Klunk kar(10); // Klunt(int n)에 명백하게 부합된다.
Klunk bux; // 두 생성자에 모두 부합될 수 있다.
두 번째 선언은 생성자 #1에 부합된다. 그러나 그것은 또한 생성자 #2에도 부합된다. 이것 때문에 컴파일러는 에러 메시지를 내보낸다.
복사 생성자
복사 생성자는 어떤 객체로 새로 생성되는 객체에 복사하는 데 사용된다. 즉, 복사 생성자는 일반적인 대입에 사용되는 것이 아니라 값 전달에 의한 함수 매개변수 전달을 포함한 초기화 작업에 사용된다. 클래스의 복사 생성잔는 일반적으로 다음과 같은 원형을 가진다.
Class_nam(const Class_name &);
이것은 클래스 객체에 대한 const 참조를 매개변수로 사용한다. 예를 들어, StringBad 클래스의 복사 생성자는 다음과 같은 원형을 가질 것이다.
StringBad(const StringBad &);
복사 생성자에 대해서는 두 가지를 알아야 한다. 즉, 언제 사용되는지 그리고 무슨 일을 하는지 알아야 한다.
복사 생성자는 언제 사용되는가?
복사 생성자는, 새로운 객체가 생성되어 같은 종류의 기존의 객체로 초기화될 때 마다 호출된다. 이것은 여러 가지 상황에서 발생한다. 가장 명백한 상황은, 새로운 객체를 기존의 객체로 명시적으로 추기화할 때이다. 예를 들어, motto가 StringBad 객체라고 가정하면, 다음과 같은 네 가지의 정의 선언이 복사 생성자를 호출한다.
StringBad ditto(motto); // StringBad(const StringBad &)을 호출한다.
StringBad motoo = motto; // StringBad(const StringBad &)을 호출한다.
StringBad also = StringBad(motto); // StringBad(const StringBad &)을 호출한다.
StringBad * pStringBad = new StringBad(motto); // StringBad(const StringBad &)을 호출한다.
사용하는 C++ 시스템에 따라 가운데에 있는 두 선언은, 복사 생성자를 직접 사용하여 metoo와 also를 생성할 수도 있고, 복사 생성자로 임시 객체를 생성하고 그 임시 객체의 내용을 metoo와 also에 대입할 수도 있다. 마지막에 있는 선언은 이름이 없는 객체를 motto로 초기화하고, 그 새로운 객체의 주소를 pStringBad 포인터에 대입한다.
덜 명백하지만, 프로그램이 객체의 복사본을 생성할 때마다 컴파일러는 복사 생성자를 사용한다. 특히 함수가 객체를 값으로 전달하거나 함수가 객체를 리턴할 때 복사 생성자가 사용된다. 값으로 전달한다는 것은, 오리지널 변수의 복사본이 만들어진다는 것을 의미하낟. 또한 컴파일러는 임시 객체를 생성할 때마다 복사 생성자를 사용한다.
객체를 값으로 전달하면 복사 생성자가 호출되기 때문에, 참조로 전달하는 것이 더 좋다. 참조로 전달하면 생성자를 호출하는 시간과, 새로운 객체를 저장하는 메모리 공간이 절약된다.
복사 생성자는 무슨 일을 하는가?
디폴트 복사 생성자는 static 멤버를 제외한 멤버들을 멤버별로 복사한다.(멤버별 복사 또는 얕은 복사라고 부른다.) 각 멤버는 값으로 복사된다.다음과 같은 구문은
StringBad sailor = sports;
private 멤버에 대한 접근이 허용되지 않기 때문에 그대로는 컴파일할 수 없다는 점을 제외하고 다음과 같은 것과 동등하다.
StringBad sailor;
sailor.str = sports.str;
sailor.len = sailor.len;
멤버 자체가 클래스 객체라면, 그 클래스의 복사 생성자는 그 멤버 객체를 다른 객체에 복사한다. num_strings와 같은 static 멤버는, 개별적인 객체들에 속하는 것이 아니라 그 클래스 전체에 속하기 때문에 영항을 받지 않는다.
Stringbad로 회귀: 복사 생성자에서 어디가 잘못 되었나
첫 번째 이상한 행동은, 생성된 객체보다 파괴된 객체가 두 개 많다고 프로그램 출력이 알려 주는 것이다. 이것은 프로그램이 복사 생성자를 사용하여 두 개의 부가적인 객체를 생성하기 때문이다. callme2() 함수가 호출되었을 때 callme2()의 형식 매개변수를 초기화하는 데 복사 생성자가 사용된다. 그래리고 sailor 객체를 sports로 초기화하는 데에도 복사 생성자가 사용된다.
카운트를 갱신하는 다음과 같은 명시적 복사 생성자를 제공함으로써 이 문제를 해결할 수 있다.
StringBad::StringBad(const StringBad & s)
{
num_string++;
}
프로그램이 보이는 두 번째 이상한 행동은 좀 더 이해하기 어렵고 위함한 것이다.
한 가지 증상은 문자열 내용의 왜곡이다. 또 다른 증상은 프로그램의 컴파일된 버전이 중지 되는 것이다.
암시적 복사 생성자가 값으로 복사하는 것이 이 문제를 일으키는 원인이다. 예를 들어 복사 생성자의 효과는 결국 다음과 같을 것이다.
sailor.str = sports.str;
이것은 문자열 자체를 복사하지 않고 문자열을 지시하는 포인터를 복사한다. 즉, sailor가 sports로 초기화 되면, 두 포인터는 동일한 문자열을 지시하게 된다. 이것은 operator<<() 함수가 문자열을 출력하기 위해 그 포인터를 사용할 때 에는 문제를 일으키지 않는다. 그러나 파괴자가 호출될 때에는 문제를 일으킨다. StringBad 파괴자는 str 포인터가 지시하는 메모리를 해제한다는 사실을 기억하라. 따라서 sailor를 파괴하는 것은 결과적으로 다음과 같은 효과를 가진다.
delete [] sailor.str; // sailor.str가 지시하는 문자열을 삭제한다.
sailor.str 포인터는, "Spinach Leaves Bow1 for Dollars"를 지시하는 sports.str의 값이 대입되기 때문에, 그와 동일하게 "Spinach Leaves Bow1 for Dollars"를 지시한다. 따라서 그 delete 구문은 "Spinach Leaves Bow1 for Dollars"가 차지하고 있던 메모리를 해제한다. 그다음에, sports를 파괴하는 것은 다음과 같은 효과를 가진다.
delete [] sports.str; // 무슨 일을 저지를지 알 수 없다.
여기서, sports.str는, sailor를 파괴할 때 이미 해제된 메모리 위치를 지시하고 있다. 따라서 이것의 결과는 알 수도 없고, 매우 위험할 수도 있다.
명시적 복사 생성자로 이 문제를 해결하자
클래스 설계상의 문제점을 해결하는 방법은 깊은 복사(deep copy)를 하게 만드는 것이다. 즉, 복사 생성자가 문자열의 주소만 달랑 복사하지 말고, 문자열 자체를 복사하고, 그 복사본의 주소를 str 멤버에 대입하게 하는 것이다. 이렇게 하면 각 객체는 다른 객체의 문자열을 참조하지 않고, 가지 몫을 문자열을 따로 갖게 된다. 이제 각각의 파괴자 호출은 동일한 하나의 문자열을 해제하려는 중복된 시도를 하지 않고, 각각 서로 다른 문자열을 해제한다. 다음은 String 복사 생성자를 새롭게 코딩한 것이다.
StringBad::StringBad(const StringBad & st)
{
num_strings++;
len = st.len;
str = new char[len + 1];
std::strcpy(str, st.str);
cout << num_strings << ": " << str << "객체 생성\n;
}
복사 생성자를 필수적으로 정의해야 하는 이유는, 일부 클래스 멤버들이 데이터 자체가 아니라, new에 의해 초기화되는 데이터를 지시하는 포인터들이기 때문이다.
기타 Stringbad 문제점: 대입 연산자
보이는 문제점들을 디폴트 복사 생성자의 탓으로 돌릴 수는 없다. 디폴트 대입 연산자도 함께 살펴보아야 한다. 이 연산자는 다음과 같은 원형을 가진다.
Class_name & Class_name::operator=(const Class_name &);
이것은 클래스 객체에 대한 참조를 매개변수로 사용하고 또 그것을 리턴한다. 예를 들어, 다음은 StringBad 클래스에 대한 그 연산자의 원형이다.
StringBad & StringBad::operator=(const StringBad &);
대입 연산자는 언제 사용되고 무슨 일을 하는가?
오버로딩 대입 연산자는 하나의 객체를 기존의 다른 객체에 대입할 때 사용된다. 대입 연산자는 객체를 초기화할 때 반드시 사용되는 것은 아니다.
StringBad metoo = knot; // 복사 생성자를 사용한다. 대입도 가능하다.
여기서 metoo는 knot의 값으로 초기화되면서 새로 생성되는 객체이다. 따라서 복사 생성자가 사용된다. 그러나 앞에서도 말했듯이, 어떤 C++ 시스템은 이 구문을 두 단계로 처리한다. 복사 생성자를 사용하여 임시 객체를 먼저 생성한 후, 대입을 사용하여 그 값들을 그 새로운 객체에 복사한다. 즉, 초기화는 복사 생성자를 항상 호출한다. 또한 = 연산자를 사용하는 형식들은 대입 연산자를 호출할 수도 있다.
복사 생성자와 마찬가지로, 대입 연산자의 암시적 구현은 멤버별 복사를 수행한다. 멤버 자체가 어떤 클래스의 객체라면, 프로그램은 그 클래스에 대해 정의된 대입 연산자를 사용하여 그 특별한 멤버를 복사한다. static 데이터 멤버들은 영향을 받지 않는다.
대입에서 발생하는 문제의 해결책
잘못된 디폴트 대입 연산자 때문에 발생하는 문제의 해결책은, 깊은 복사를 하는 대입 연산자의 정의를 사용자가 직접 제공하는 것이다. 그것은 복사 생성자와 비슷하지만, 다음과 같은 점에서 몇 가지 차이가 있다.
- 타깃 객체가, 이전에 대입된 데이터를 참조하고 있을 수도 있으므로, 그 함수는 delete [] 를 사용하여 이전의 의무를 해제해 주어야 한다.
- 그 함수는 어떤 객체를 자기 자신에게 대입하지 못하게 막아야 한다. 그것을 막지 않으면, 앞에서 설명한 메모리의 해제가, 내용을 다시 대입하기도 전에 그 객체의 내용을 먼저 지울 수 있다.
- 그 함수는 호출한 객체에 대한 참조를 리턴한다.
다음은 StringBad 클래스를 위해 작성한 대입 연산자이다.
StringBad & StringBad::operator=(const StringBad & st)
{
if(this == &st) // 객체가 자기 자신에 대입되었다면
return *this; // 이것으로 끝낸다
delete [] str; // 옛 문자열을 해제한다
len = st.len;
str = new char[len + 1]; // 새 문자열을 위한 공간을 확보한다
std::strcpy(str, st.str); // 문자열을 복사한다
return *this; // 호출한 객체에 대한 참조를 리턴한다
}
*책과 무관한 이야기
위의 코드를 보면 str을 해제해지 말고 실제값만 바꿔서 사용하면 안되는가 라는 생각을 하게 된다. 그래서 조사해봤지만 명확하게 안된다 라는건 없는것 같았다. 그래서 내린 결론은 기존에 시용하던 주소를 사용하면 새로운 것이 아니기에 기존 주소를 호출하는 코드가 존재한다면 오류가 발생한다고 생각이 된다.
개선된 디폴트 생성자
새롭게 개선된 디폴트 생성자도 살펴볼 가치가 있다. 그것은 다음과 같다.
String::String()
{
len = 0;
str = new char[1];
str[0] = "\0"; // 디폴트 문자열
}
왜 다음과 같이 코딩하지 않고
str = new char;
다음과 같이 코딩하는지 그 이유가 궁금할 것이다.
str = new char[1];
두 형식 모두 같은 크기의 메모리를 대입한다. 다만 차이가 있다면, 첫 번째 형식은 클래스 파괴자와 호환되지 않지만, 두 번째 형식은 클래스 파과자와 호환이 된다는 것이다. 파과자가 다음과 같은 코드를 가지고 있기 때문이다.
delete [] str;
delete []를 사용하는 것은, new []를 사용하여 초기화시킨 포인터 및 널포인터와 호환이 된다.
[] 표기를 사용하여 개별 문자에 접근하기
C++ const 함수 시그내처와 const가 아닌 함수 시그내처를 구별한다. 그래서 const String 객체가 사용할 수 있는, operator[] ()의 제2의 버전을 제공할 수 있다.
char & String::operator[] (int i)
{
return str[i];
}
const char & String::operator[] (int i) const
{
return str[i];
}
두 정의를 모두 제공한다면, 보톤의 String 객체에는 읽기 쓰기 접근이 모두 허용되고, const String 데이터는 읽기 접근만이 허용 된다.
String Text("Once Upon a time");
const String answer("futile");
cout << text[1]; // operator[] ()의 const가 아닌 버전을 사용한다.
cout << answer[1]; // operator[] ()의 const 버전을 사용한다.
cout >> text[1]; // operator[] ()의 const가 아닌 버전을 사용한다.
cout >> answer[1]; // 컴파일할 때 에러가 발생한다.
static 클래스 멤버 함수
첫째, static 멤버 함수는 객체에 의해 호출될 필요가 없다. 사실, static 멤버 함수는 this 포인터도 갖지 않는다. static 멤버 함수가 public 부분에 선언되면, 그 함수는 클래스 이름과 사용 범위 결정 연산자를 사용하여 호출된다. String 클래스에 HowMany() 라는 static 멤버 함수를 제공할 수 있다. 그 멤버 함수는 다음과 같은 원형/정의를 클래스 선언에 가지고 있다.
static int HowMany() { return num_string; }
이것은 다음과 같이 호출될 수 있다.
int count = String::HowMany(); // static 멤버 함수를 호출한다.
둘째, static 멤버 함수는 어떤 특정 객체와도 결합하지 않기 때문에, 사용할 수 있는 데이터 멤버는 static 데이터 멤버밖에 없다. 예를 들면, static 메서드 HowMany()는 str나 len에는 접근할 수 없고, static 멤버 num_strings에만 접근할 수 있다.
const 객체 리턴
operator+()에 대한 정의는 이상한 성질을 가지고 있다. 원래 의도한 사용 방법은 다음과 같은 것이다
new = force1 + force2; // 1: 세 개의 Vector 객체
그러나, 이 정의는 다음과 같이 사용하는 것도 허용한다.
force1 + force2 = net; // 2: 난독 프로그래밍
cout << (force1 + force2 = newt).magval() << endl; // 3: 미친 프로그래밍
세가지 의문이 즉각 일어난다. 도대체 누가 그런 구문을 왜 작성할까? 그 구문들이 왜 가능한가? 그 구문들은 무슨 일을 할까?
첫째, 그러한 코드 작성에 대한 합당한 이유는 없다. 그러나 모든 코드가 합당한 이유를 가지고 작성되는 것은 아니다. 사람들은, 프로그래머조차도, 실수를 한다.
둘째 리턴값을 나타내기 위해 복사 생성자가 임시 객체를 생성하기 때문에 이 코드가 가능하다. 그래서 앞의 코드에서, force1 + force2는 그 임시 객체를 나타낸다.
셋째, 임시 객체는 사용되고 나서 폐기된다. 예를 들면, 구문 2에서, 프로그램은 force1과 force2의 합을 계산하고, 그 결과를 임시 리턴 객체에 복사하고, 그 내용을 net의 내용으로 덥어쓰고, 그 임시 객체를 폐기한다, 원래 백터들은 변경되지 않는다. 구문 2에서, 임시 객체의 크기는 객체가 삭제되기 전에 디스플레이 된다.
이와 같은 행동이 만드는 오용과 남용에 신경이 쓰인다면, 간단한 해결택이 있다. 리턴형을 const 객체로 선언하는 것이다. 그러면 구문 1은 허용이 되지만 구문 2와 3은 허용되지 않는다.
클래스 메서드들
const인 변수를 어떠한 값으로 생성자가 초기화 하기 위해 멤버 초기화 리스트(member initializer list)라는 특별한 문법을 제공한다. 멤버 초기자 리스트는 앞에 콜론(:)이 붙어 있고, 초기자들을 콤마로 분리해 놓은 리스트이다.
'C++ > C++ 기초 플러스 6판(Book)' 카테고리의 다른 글
[C++] 코드의 재활용(다중 상속) (0) | 2025.03.11 |
---|---|
[C++] 클래스의 상속 (0) | 2025.03.10 |
[C++] 클래스의 활용 (0) | 2025.03.08 |
[C++] 객체와 클래스 (0) | 2025.03.07 |
[C++] 메모리 모델과 이름 공간 (0) | 2025.03.06 |