28-1.연산자 함수
28-1-가.기본형의 연산자
연자자를 오버로딩할 수 있다는 것은 C++언어의 큰 특징이며 클래스가 타입임을 보여주는 단적인 예라고 할 수 있다. 조금 어렵기는 하지만 문법이 체계적이어서 이해하고 나면 언어의 질서를 느낄 수 있으며 오히려 재미있기도 하다. 좀 세삼스럽기는 하지만 C/C++언어가 제공하는 기본형의 연산문을 한 번 살펴보자. 대표적으로 덧셈 연산문을 보면 다음과 같은 구문이 가능하다.
int i1=1,i2=2;
double d1=3.3,d2=4.4;
int i=i1+i2; // 정수 덧셈
double d=d1+d2; // 실수 덧셈
하나는 정수끼리 더해 정수형 변수에 대입하고 하나는 실수끼리 더해 실수형 변수에 대입하는데 둘 다 잘 동작한다. 연산 결과 i는 3이 되고 d는 7.7이 될 것이다. 덧셈 연산자인 +는 피연산자의 타입이 달라도 문제없이 정확하게 연산을 해 낸다. 너무 상식적이어서 당연한 것처럼 생각되겠지만 이 연산이 성립하는 이유도 알고보면 나름대로 복잡하다. 정수형과 실수형은 길이도 다르고 비트 구조도 상이해서 각 타입을 더하는 알고리즘이 분명히 다르겠지만 똑같은 연산자로 두 타입의 덧셈이 가능한 것이다.
이렇게 되는 이유는 덧셈 연산자가 피연산자의 타입에 따라 오버로딩되어 있기 때문이다. 즉, 정수 덧셈을 하는 코드와 실수 덧셈을 하는 코드가 각각 따로 작성되어 있으며 컴파일러는 덧셈 연산자의 양변에 있는 피연산자의 타입을 점검한 후 둘 다 정수일 경우 정수끼리 더하는 코드를 호출하고 둘 다 실수일 경우 실수끼리 더하는 코드를 호출한다. 정수의 경우 부호가 같으면 절대값을 더하고 부호가 다르면 절대값끼리 빼고 부호는 큰 쪽을 따를 것이며 실수의 경우 지수를 일치시킨 후 덧셈을 할 것이다. 인수의 타입이 다르면 같은 이름으로 함수를 중복 정의할 수 있는 것처럼 연산자도 피연산자의 타입에 따라 중복 정의할 수 있다. + 기호를 덧셈을 하는 함수의 이름이라고 했을 때 이 함수의 원형은 아마도 다음과 같이 오버로딩되어 있을 것이다.
int +(int, int);
double +(double, double);
위쪽 함수는 정수끼리 더한 후 정수를 리턴하고 아래쪽 함수는 실수끼리 더한 후 실수를 리턴한다. i1+d1같이 정수와 실수를 섞어서 더할 경우는 컴파일러의 형변환 기능에 의해 i1이 실수로 상승 변환된 후 실수끼리 덧셈을 하게 될 것이다. 또 포인터와 정수의 덧셈도 산술적인 덧셈과 다르게 정의되어 있는데 이 연산도 일종의 오버로딩된 예라고 할 수 있다. 이에 비해 char * +(char *, char *) 따위의 원형은 정의되어 있지 않으므로 문자열이나 포인터끼리는 더할 수 없다. 마찬가지로 포인터에 실수를 더할 수도 없는데 이런 동작을 처리할 수 있는 연산자가 존재하지 않기 때문이다.
기본형에 대해 연산자가 중복 정의되어 있는 것은 정말 다행스러운 일이다. 피연산자의 타입에 따라 사용해야 하는 연산자가 달라진다면 얼마나 피곤하겠는가? 피연산자의 타입이 달라도 +라는 똑같은 모양의 연산자로 일관되게 덧셈 연산을 할 수 있는 것은 다형성의 예이다. 정수든 실수든 더하고 싶으면 + 연산자를 쓰기만 하면 된다. 그러나 연산자의 이런 중복 정의는 어디까지나 컴파일러가 기본적으로 제공하는 타입에 대해서만 적용되며 사용자가 직접 정의하는 타입인 클래스에 대해서는 이런 규칙이 적용되지 않는다. 다음 예제는 복소수를 표현하는 Complex 클래스의 객체끼리 + 연산자로 더한다.
예 제 : ComplexAdd
|
#include <Turboc.h>
class Complex
{
private:
double real;
double image;
public:
Complex() { }
Complex(double r, double i) : real(r), image(i) { }
void OutComplex() const { printf("%.2f+%.2fi\n",real,image); }
};
void main()
{
Complex C1(1.1,2.2);
Complex C2(3.3,4.4);
C1.OutComplex();
C2.OutComplex();
Complex C3;
C3=C1+C2;
C3.OutComplex();
}
이 상태로 컴파일해 보면 C3=C1+C2; 연산문에서 "Complex 클래스는 + 연산을 정의하지 않았다"는 에러가 발생한다. C++은 언어 차원에서 복소수를 지원하지 않기 때문에 Complex가 어떤 타입인지 알지 못하며 따라서 두 객체를 어떻게 더해야 하는지도 모르는 것이다. 복소수끼리 더하는 방법을 모르니 + 연산을 처리할 수가 없다. 사용자 정의 타입인 클래스의 객체끼리 더하는 방법은 클래스별로 고유하기 때문에 클래스를 만든 사람이 덧셈 연산을 직접 정의할 필요가 있다.
C3=C1+C2; 연산문이 제대로 컴파일되려면 복소수에 대한 덧셈 연산자를 중복 정의해야 한다. 고등 수학을 배운 사람이라면 복소수끼리 더할 때 실수부는 실수부끼리 허수부는 허수부끼리 더한다는 것을 잘 알고 있겠지만 컴파일러는 이런 방법을 모르는 것이다. 따라서 똑똑한 개발자가 멍청한 컴파일러에게 복소수끼리 더하는 방법을 알려 줘야 하는데 이것을 연산자 오버로딩이라고 한다. 새로 만들어지는 + 연산자는 아마도 다음과 같은 원형을 가질 것이다.
Complex +(Complex, Complex);
두 개의 Complex 객체를 인수로 취하고 그 합을 구해 Complex형으로 리턴한다. 정수끼리 더할 때나 실수끼리 더할 때 사용하는 똑같은 + 연산자로 복소수끼리도 덧셈을 할 수 있도록 중복 정의하는 것이 바로 연산자 오버로딩이다. 고정된 타입만 제공되는 C에서는 이런 기능이 그다지 필요하지 않았었다. 그러나 C++은 사용자가 타입을 정의할 수 있게 되었고 사용자가 만든 타입도 기본 타입과 똑같은 자격을 주기 위해 연산 방법을 정의할 필요가 생겼다. 그래야 사용자가 정의한 타입이 컴파일러가 제공하는 기본 타입과 대등한 자격을 가지며 일관된 방법으로 사용할 수 있기 때문이다.
클래스가 완전한 타입이 되려면 int가 할 수 있는 모든 일을 할 수 있어야 한다. 이 절의 주제가 바로 객체의 연산 방법을 정의하는 것이며 더 직관적으로 얘기 하자면 임의의 객체에 대해 A=B+C; 가 가능하도록 하는 것이다. 물론 + 뿐만 아니라 *나 ==, % 등 대부분의 연산자도 오버로딩할 수 있다. 개념은 무척이나 간단하지만 복잡한 규칙이 존재하며 또한 많은 함정들이 도사리고 있다.
28-1-나.연산자 함수
포인터끼리 더하는 것이 의미가 없는 것처럼 하루중의 한 시점을 가리키는 시각을 더하는 것은 사실 별 의미가 없다. 아침이 9:00이고 점심이 12:30일 때 이 둘을 더한 21:30은 어떤 의미도 부여할 수 없다. 그러나 경과 시간끼리 더하는 것은 분명히 의미가 있는데 밥먹는데 40분, 커피 마시는데 25분이 걸린다면 이 둘을 더한 1:5분은 밥먹고 커피 마시는데 필요한 시간이라고 할 수 있다.
시간을 표현하는 Time이라는 클래스를 정의했다면 시간끼리 더할 수 있는 방법도 제공할 필요가 있는데 시간이란 과연 어떻게 더할 수 있을까? 시간이라는 타입은 시, 분, 초의 세 가지 요소로 구성되며 적어도 int나 double 같은 기본 타입보다는 훨씬 더 복잡한 처리가 필요하다. 초는 초끼리 더하고 분은 분끼리 더해야 하며 시는 시끼리 각각 더하되 각 자리에서 60이 넘는 결과가 나오면 자리 올림 처리를 해야 한다. 예를 들어 1:26:42초라는 시간과 2:38:55초라는 시간을 더하면 3:64:97초가 되는 것이 아니라 4:5:37초가 되어야 한다.
사람은 시간이라는 포맷에 아주 익숙하고 일상 생활에서 늘상 사용하므로 쉽게 연산할 수 있지만 컴퓨터는 이런 복잡한 타입의 연산 방법을 모른다. Time 클래스에 대해 덧셈을 하는 멤버 함수를 정의해 보자. 다음 예제의 AddTime 멤버 함수는 또 다른 Time 객체 T를 인수로 전달받아 자기 자신과 더한 결과를 리턴한다. 시간끼리 덧셈 연산을 하므로 이 동작을 잘 설명할 수 있는 AddTime이라는 이름을 주었다.
예 제 : TimeAdd
|
#include <Turboc.h>
class Time
{
private:
int hour,min,sec;
public:
Time() { }
Time(int h, int m, int s) { hour=h; min=m; sec=s; }
void OutTime() {
printf("%d:%d:%d\n",hour,min,sec);
}
const Time AddTime(const Time &T) const {
Time R;
R.sec=sec + T.sec;
R.min=min + T.min;
R.hour=hour + T.hour;
R.min += R.sec/60;
R.sec %= 60;
R.hour += R.min/60;
R.min %= 60;
return R;
}
};
void main()
{
Time A(1,1,1);
Time B(2,2,2);
Time C;
A.OutTime();
B.OutTime();
C=A.AddTime(B);
C.OutTime();
}
더하는 방법은 비교적 간단한데 임시 객체 R을 선언한 후 자기 자신과 T의 시, 분, 초를 각각 더해 R의 대응되는 멤버에 대입하고 자리 올림을 한다. 자리 올림은 나누기 연산자와 나머지 연산자를 적절히 활용하면 간단하게 처리할 수 있다. main 함수의 테스트 코드는 A와 B를 더해 C에 대입한 후 C를 출력해 본다. 실행 결과는 다음과 같다.
1:1:1
2:2:2
3:3:3
아주 간단한 연산을 해 보았는데 1:36:42와 5:42:29처럼 조금 복잡해 보이는 시간끼리 더해도 자리 올림까지 고려하여 7:19:11라는 정확한 연산을 한다. Time 클래스가 시간 포맷에 대해 캡슐화를 잘 하고 있으며 AddTime이라는 이름의 멤버 함수를 정의함으로써 시간 객체끼리 더하는 방법을 컴파일러에게 알려 주었으므로 main에서는 AddTime 함수를 호출하여 A와 B를 더하기만 하면 된다. 동작상의 문제는 전혀 없지만 연산을 위해 함수를 호출하는 방식이 연산자를 쓰는 방법에 비해 직관적이지 못하며 기본형의 연산문과 모양이 다르다는 것도 불만이다. 그래서 이런 동작을 하는 연산자 함수를 정의할 수 있다.
사실 연산자는 모양이 좀 특이한 함수라고 볼 수 있는데 인수를 취한다는 것과 연산 결과를 리턴한다는 점에서 함수와 공통적이다. 연산자 함수의 이름은 키워드 operator 다음에 연산자 기호를 써서 작성하는데 연산자 기호를 명칭으로 쓸 수 없으므로 operator라는 키워드를 앞에 두는 것이다. 덧셈 연산자 함수의 이름은 operator +가 되는데 중간의 공백은 무시되므로 operator+라고 붙여 써도 상관없다. 함수명은 명칭이므로 영문자, 숫자, _만 쓸 수 있지만 연산자 함수의 이름은 예외적으로 기호를 사용할 수 있다. 연산자 자체가 기호로 되어 있으므로 여기에는 예외를 적용할 수밖에 없다. 위 예제에서 AddTime이라는 함수의 이름을 operator +로 바꿔 보자.
class Time
{
....
const Time operator +(const Time &T) const {
....
}
};
리턴값, 인수, 본체는 그대로 두고 AddTime이라는 이름만 operator +로 바꾼 것 뿐이다. 이렇게 연산자 함수를 정의하면 이 클래스 타입의 객체를 좌변으로 가지는 + 연산자를 쓸 수 있다. main 함수의 AddTime 호출문도 다음과 같이 수정한다.
void main()
{
Time A(1,1,1);
Time B(2,2,2);
Time C;
A.OutTime();
B.OutTime();
C=A+B;
C.OutTime();
}
C=A.AddTime(B)가 C=A+B로 바뀌었는데 함수의 본체 코드가 똑같으므로 동작도 완전히 동일하다. AddTime이라는 함수의 이름이 operator +로 바뀌었고 함수를 호출하는 방법이 연산문으로 바뀌었을 뿐이다. C=A+B를 다음과 같이 작성해도 똑같이 동작한다.
C=A.operator +(B); // C=A+B; 와 같다.
C=A+B는 연산문이고 C=A.operator +(B)는 함수 호출문의 형태를 띠고 있을 뿐 실행되는 코드는 둘 다 동일하다. 표현만 다른 같은 구문이다. A+B 연산문에 중단점을 설정하고 디버거로 실행하여 함수 안쪽으로 파고 들어가 보면 이 연산문에 의해 operator + 함수가 호출된다는 것을 확인할 수 있다. 그렇다면 AddTime 일반 함수와 operator +연산자 함수는 과연 어떤 차이점이 있을까 비교해 보자.
첫 번째로 연산자 형태의 호출 방식이 길이가 짧아 타이핑하기 편리하며 오타가 발생할 가능성도 극히 낮다. 몇 자 되지는 않지만 자주 사용하는 연산이라면 이 차이도 결코 무시할 수 없다.
두 번째로 연산자 함수는 호출 형식이 연산문 형태로 작성되기 때문에 훨씬 더 직관적이고 기본형의 연산 방법과 일치하므로 사용하기 쉽다. A+B라는 표현식 자체가 A와 B를 더한다는 것을 잘 표현한다. 물론 Add라는 영어 단어도 뭔가를 더한다는 것을 의미하기는 하지만 + 연산자보다 쉽지는 않다. Add는 영어지만 +는 초등학생들도 아는 기호가 아닌가?
세 번째로 연산자는 함수와는 달리 우선 순위와 결합 방향의 개념이 있어 괄호를 쓰지 않아도 연산 순서가 자동으로 적용되어 편리하다. 어떤 객체 A와 B의 곱과 C와 D의 곱을 더해 E에 대입한다고 해 보자.
일반 함수 : E=(A.Multi(B)).Add(C.Multi(D));
연산자 함수 : E=A*B+C*D;
어느쪽이 더 보기 좋고 읽기 좋은지는 굳이 강조하지 않더라도 쉽게 판단될 것이다. 일반 함수는 호출 순서를 괄호로 분명히 명시해야 하므로 식을 작성하는 프로그래머도 골치 아프고 이 식을 읽는 사람은 더 골치 아프다. 자연어로 표현하면 "A와 B를 곱하고 C와 D를 곱하고 두 곱셈 결과를 더해 E에 대입한다"가 되어 훨씬 더 복잡해진다. 이런 복잡한 동작을 E=A*B+C*D로 간략하게 표기할 수 있으므로 한마디로 가독성의 차이가 엄청나며 이 차이로 인해 유지 보수 비용의 규모가 달라진다. 그래서 C++은 문법이 복잡해지는 대가를 치르더라도 객체에 대한 연산자 오버로딩을 지원하는 것이며 우리는 이것을 애써 배우고 적극적으로 활용해야 한다.
물론 이런 연산자 함수를 일일이 정의한다는 것은 상당히 번거로운 일이며 또 정확하게 작성하기 위해 알아야 할 것도 많다. 하지만 OOP의 철학은 소수의 객체 작성자에게 편리함을 주는 것보다 무수히 많은 사용자들을 편하게 하는 쪽에 치중되어 있음을 생각해 본다면 연산자 오버로딩은 진정으로 사용자를 위한 기능임이 분명하다.
28-1-다.연산자 함수의 형식
클래스의 연산자 함수를 정의하는 방법은 다음 두가지가 있다.
① 클래스의 멤버 함수로 작성한다.
② 전역 함수로 작성한다.
우선 상대적으로 좀 더 간단한 멤버 연산자 함수를 작성하는 형식부터 알아보자. 전역 함수로 작성하는 방법에 대해서는 다음 절에서 상세하게 알아볼 것이다. 멤버 연산자 함수의 기본 형식은 다음과 같다.
리턴타입 Class::operator 연산자(인수 목록)
{
함수 본체;
}
일반적인 멤버 함수 선언문과 동일하되 함수 이름이 키워드 operator와 연산자로 구성되어 있다는 점만 다르다. 연산자 자리에는 +, -, *, /, <<, != 등 대부분의 연산자 기호가 올 수 있다. 이 형식대로 앞 항에서 작성한 ComplexAdd 예제의 Complex 클래스에 덧셈 연산자를 추가해 보자.
class Complex
{
private:
double real;
double image;
public:
Complex() { }
Complex(double r, double i) : real(r), image(i) { }
void OutComplex() const { printf("%.2f+%.2fi\n",real,image); }
const Complex operator +(const Complex &T) const {
Complex R;
R.image = image + T.image;
R.real = real + T.real;
return R;
}
};
임시 객체 R을 선언하고 R에 덧셈 결과를 작성하되 허수부와 실수부를 각각 따로 더했다. 이 연산자가 정의되면 이제 Complex 객체에 대해 + 연산자로 간편하게 덧셈을 할 수 있으며 Complex가 기본형과 비슷한 자격을 가지게 된다. 실행 결과는 다음과 같다.
1.10+2.20i
3.30+4.40i
4.40+6.60i
C3=C1+C2 연산문에 의해 두 복소수가 제대로 더해졌다. 멤버 연산자 함수의 원형이 다소 복잡한데 이 원형을 간략하게 분석해 보면 다음과 같다.
클래스 선언문 내부의 인라인 함수로 정의했기 때문에 함수명앞에 소속 클래스에 대한 표기(Complex::)는 빠져 있는데 외부에서 정의한다면 Complex::operator + 등으로 소속 클래스 이름도 밝혀야 한다. 이 예를 통해 멤버 연산자 함수의 각 요소에 대해 상세하게 연구해 보자. 각각의 const 키워드가 가지는 의미, 레퍼런스를 넘기는 이유, 값을 리턴하는 이유 등이 나름대로 복잡하다.
인수의 타입
연산자 함수의 인수란 피연산자를 의미하는데 함수를 호출하는 자기 자신(this)과 함수로 전달되는 인수가 연산 대상이다. 이항 연산자의 경우 멤버 연산자 함수를 호출하는 객체가 좌변이 되고 인수로 전달되는 대상이 우변이 된다.
원칙적으로 연산자 함수의 인수는 임의의 타입을 모두 받아들일 수 있지만 논리적으로 객체와 연산 가능한 대상이어야 한다. Complex 객체의 경우 다른 Complex 객체나 실수 또는 정수형이 피연산 대상이 될 수 있다. 복소수를 복소수와 덧셈하는 것은 논리적으로 합당하지만 복소수에 시간을 더하거나 Person, Position 따위의 전혀 관련없는 객체를 더하는 것은 별 의미가 없다. 자신과 같은 타입의 다른 객체인 경우가 가장 보편적이고 가끔 호환되는 타입과 연산하기도 한다.
객체는 값으로 넘길 수도 있지만 아무래도 기본형보다는 덩치가 크기 때문에 값으로 넘기면 비효율적이므로 레퍼런스로 넘기는 것이 유리하다. 인수 T앞에 &기호를 빼고 값으로 넘겨도 동작에는 별 이상은 없지만 객체가 커지면 다소 느릴 것이다. 포인터를 넘기는 것도 연산자 함수가 피연산 대상을 읽을 수 있으므로 일단 가능은 하다. 위 예제의 + 연산자를 다음과 같이 Complex *를 받도록 수정해 보자.
Complex operator +(const Complex *T) const {
Complex R;
R.image = image + T->image;
R.real = real + T->real;
return R;
}
포인터로 넘겨진 피연산자의 멤버를 참조하려면 . 연산자 대신 ->연산자를 사용하기만 하면 된다. 그러나 연산자 함수가 포인터를 받아들이면 이 함수를 호출할 때 피연산자의 주소를 넘겨야 하므로 호출부의 모양이 C3=C1.operator +(&C2);가 될 것이고 이를 연산식으로 표현하면 C3=C1+&C2; 가 되는데 이런 형식은 연산문의 일반적인 표기법에 어긋나며 전혀 직관적이지 못하다. 정수형의 경우 i=j+&k;로 연산하지 않는 것과 마찬가지이다.
연산자 오버로딩의 목적은 객체의 연산문을 기본형과 같은 방법으로 표현함으로써 가독성을 높이고 클래스의 직관적인 활용성을 향상시키는 것인데 이런 식으로 매번 &연산자를 사용해야 한다면 차라리 AddComplex 따위의 일반 함수를 쓰는 편이 더 나을 것이다. 연산자 함수로 피연산자를 넘기는 방법은 사실 세 가지 모두 가능하다. 값으로 넘기는 방법은 객체가 커지면 효율이 좋지 못하다는 문제가 있고 포인터로 넘기는 방법은 효율은 좋지만 호출 구문이 요상해진다. 레퍼런스로 넘기면 효율과 직관적인 표기라는 두 마리 토끼를 다 잡을 수 있다. C++이 레퍼런스 타입을 지원하는 주된 이유 중의 하나가 바로 객체 연산식의 직관적인 표현을 위해서이다.
인수의 상수성
피연산자로 전달된 인수는 보통 읽기만 한다. a+b, a*b, a>>b, a[b], a->b 등 우리가 알고 있는 모든 이항 연산자를 관찰해 보면 인수로 전달되는 우변의 값을 변경하는 경우는 전혀 없으며 단지 연산할 값을 얻기 위해 읽기만 한다. 그래서 연산자 함수로 전달되는 인수는 읽기 전용의 const 로 받는 것이 좋다. 연산자 함수로 객체의 레퍼런스를 전달할 때 이 함수가 객체의 상태를 함부로 변경하지 못하도록 하기 위해 const 지정자를 붙이는 것이 안전하다.
만약 레퍼런스로 전달되는 T가 const가 아니라면 operator + 함수 내부에서 T.real=12.34; 로 실인수를 마음대로 바꿔 버릴 수도 있다. 이항 연산자의 피연산자는 연산의 재료일 뿐이지 연산 대상이 아니므로 이는 분명히 잘못된 연산이다. 또한 다음과 같은 연산문도 불가능해진다.
const Complex C2(1.0, 2.0);
C3=C1+C2;
상수 객체도 피연산자로 사용할 수 있어야 하는데 인수가 상수가 아니라면 에러로 처리될 것이다. 정수 연산에서 a=b+3;이 허용되므로 복소수 연산에서도 상수 객체를 피연산자로 쓸 수 있어야 한다. 물론 강제 사항은 아니므로 필요에 따라 인수의 상수성을 선택할 수 있겠지만 제대로 된 연산자라면 피연산자를 변경하지 말아야 한다. 직관적인 연산식 표현을 위해 포인터는 안된다고 했으므로 Complex 객체를 인수로 전달받는 operator +의 경우 다음 4가지 형식의 인수를 받아들일 수 있다.
① Complex
② Complex &
③ const Complex
④ const Complex &
이 중 ④번 형식이 가장 바람직하다. 레퍼런스를 넘기므로 빠르고 const 지정을 했으므로 안전하기도 하다. 객체의 크기가 아주 작아 굳이 레퍼런스를 쓸 필요가 없다면 ①번 형식이 가장 간단하다. 값으로 넘길 경우는 어차피 사본이 전달되므로 ③번 형식처럼 값에 대해 const 지정자를 붙이는 것은 사실 별 실용성이 없다.
함수의 상수성
Complex의 operator + 연산자가 const 함수로 지정되어 있는데 멤버 연산자 함수가 호출 객체의 상태를 바꾸지 않을 경우는 원칙에 따라 const 함수로 지정하는 것이 좋다. 그래야 함수 내부에서 부주의하게 호출 객체를 변경하는 사고를 방지할 수 있다. 덧셈, 뺄셈, 곱셈 등의 통상적인 이항 연산자들은 객체의 값을 읽기만 할 뿐 객체를 변경하지 않는다. 만약 연산자 함수가 상수성을 가지지 않으면 상수 객체에 대해서는 연산을 할 수 없을 것이다. 다음 코드를 보자.
const int i=4;
int j=3,k;
k=i+j;
이 연산이 가능하기 위해서는 +연산자가 상수 i의 값을 바꾸지 않는다는 보장이 있어야 한다. 반면 객체의 값을 직접 변경하는 연산자는 const로 지정해서는 안된다. 이런 연산자에는 대표적으로 대입 연산자가 있고 증감 연산자, 복합 대입 연산자도 const가 될 수 없는 연산자이다. 같은 타입의 다른 객체를 대입받아 객체의 값을 변경하는 = 연산자가 const라면 말이 안된다.
임시 객체의 사용
위 예제의 operator + 연산자 본체를 보면 Complex형의 임시 객체 R을 선언하고 호출 객체와 피연산자 T를 더한 결과를 R에 작성한 후 임시 객체 R을 리턴하고 있다. 이 연산에 사용된 임시 객체 R은 호출 객체와 피연산자의 값을 변경하지 않고 연산 결과를 잠시 저장하기 위한 용도로 사용되는 것이다. 만약 임시 객체를 쓰지 않고 다음과 같이 이 함수를 작성했다고 해 보자.
const Complex operator +(const Complex &T) {
image = image + T.image;
real = real + T.real;
return *this;
}
호출 객체인 this의 멤버를 직접 변경하고 *this 자체를 리턴했다. 이렇게 수정한 후 컴파일해 보면 별 이상없이 잘 동작하는 것처럼 보인다. 그러나 테스트 코드의 끝에 C1.OutComplex();로 C1값을 확인해 보면 원래 값인 1.1+2.2i를 그대로 가지고 있지 않으며 C3과 같은 값이 되어 있을 것이다. + 연산자의 좌변 객체가 변경되어 버리므로 자세히 따져 보면 본래의 + 연산과는 다른 연산(+=)이 되어 버린다. 이 상황을 좀 더 이해하기 쉬운 정수형 연산을 예로 설명해 보자.
int a=1,b=2,c;
c=a+b;
이 코드의 결과 c에는 3이 대입될 것이고 a와 b는 원래의 값을 그대로 유지해야 하므로 a는 1, b는 2가 되는 것이 옳다. a가 b의 값을 더한 값으로 변경된 후 그 결과가 c에 대입되는 것이 아니라 두 피연산자의 값만 읽어 덧셈을 한 후 그 결과값을 c로 대입해야 한다. 이때의 결과값을 잠시 가지기 위해 정수형 임시 변수가 필요하다.
그래서 Complex의 operator +도 이 요구에 맞추기 위해 호출 객체를 건드리지 말아야 하며 따라서 이 함수는 const가 되어야 하는 것이다. 그러다 보니 연산 결과를 저장할 임시 객체가 필요하며 이 함수는 임시 객체에 연산을 한 후 그 객체를 리턴하는 형식으로 작성해야 한다. 호출측에서는 연산 결과 리턴되는 값을 같은 타입의 다른 객체에 즉시 대입해야 한다. 대입되지 않으면 이 값은 버려진다.
리턴 타입
연산의 결과로 어떤 타입을 리턴할 것인가는 연산자별로 다르다. 정수끼리 더하면 정수가 되고 실수끼리 곱하면 실수가 되는 것처럼 객체에 대한 연산 결과는 보통 객체와 같은 타입이 되지만 반드시 그런 것은 아니다. 논리 연산자의 경우는 BOOL(또는 bool)형이나 int형이 리턴될 수도 있고 첨자 연산자 [ ]의 경우처럼 특수한 연산자는 멤버 중의 하나를 리턴하는 경우도 있다.
앞에서 예를 든 Time의 +, Complex의 +는 둘 다 클래스형의 객체를 리턴했는데 그래야 연산 결과를 제 3 의 객체에게 대입할 수 있다. 만약 + 연산자가 덧셈만 하고 결과를 리턴하지 않는다면 A=B+C같은 대입은 불가능할 것이며 A=B+C+D 같은 연쇄적 연산도 할 수 없을 것이다. 임의의 타입 T에 대한 덧셈 결과는 역시 T형이 되는 것이 합리적이다. -> 리턴은 복사본으로
연산자 함수가 객체를 리턴할 때 레퍼런스를 리턴할 것인가, 값을 리턴할 것인가는 연산자에 따라 다르다. operator +의 경우 임시 객체로 연산 결과를 리턴하기 때문에 레퍼런스형은 안된다. 임시 객체는 함수 호출이 종료되면 사라지며 함수 리턴 직후에 다른 객체로 대입할 수 있는 값을 넘겨야 한다. 임시객체에 대한 레퍼런스도 물론 곧바로 대입한다면 별 문제는 없다. Complex의 + 연산자를 레퍼런스를 리턴하도록 수정해 보자.
Complex &operator +(const Complex &T) const {
...
이렇게 수정한 후 컴파일하면 경고가 발생하기는 하지만 C3=C1+C2; 연산문이 정상적으로 실행된다. 왜냐하면 + 연산 바로 다음 연산이 대입 연산이고 대입 연산은 함수 호출이 아닌 멤버별 복사 코드의 실행이기 때문에 스택에 있는 임시 변수가 대입되는 시점까지 값을 계속 유지하기 때문이다. 그러나 다음 테스트 코드를 작성해 보면 제대로 동작하지 않음을 확인할 수 있다.
void main()
{
Complex C1(1.1,2.2);
Complex C2(3.3,4.4);
Complex C3(5.5,6.6);
Complex C4;
C4=C1+C2+C3;
C1.OutComplex();
C2.OutComplex();
C3.OutComplex();
C4.OutComplex();
}
연산 순위에 따라 C1+C2가 먼저 호출되고 이 연산의 결과 지역변수 R의 레퍼런스가 리턴되며 다음으로 연산결과 R+C3가 호출되는데 이 시점에서 스택에 있는 호출 객체인 R이 깨지기 때문이다. 그러므로 C4는 제대로 된 값을 대입받을 수 없다. 연쇄적인 연산이 아닌 C3=C1+C2 같은 대입문도 = 연산자가 별도의 함수로 오버로딩된 된 경우 마찬가지 현상이 발생한다. 바로 직전의 함수가 만든 지역변수는 다음 함수가 호출되면 완전히 사라진다. 스택은 매 함수 호출마다 새로 재구성되는 임시 기억 장소이기 때문이다.
반면 값으로 리턴할 경우는 아무런 문제가 없다. 값은 리턴될 때 새로 만들어지는 사본이기 때문에 다른 함수 호출에 대해 침범당하지 않기 때문이다. 그래서 Complex의 + 연산자는 Complex의 레퍼런스가 아닌 Complex의 값을 리턴하는 것이 정확하다.
리턴 타입의 상수성
리턴 타입의 상수성도 경우에 따라 다른데 객체 타입을 리턴하는 함수는 보통 상수 객체를 리턴해야 한다. Time이나 Complex는 연산을 위해 임시 객체를 생성하고 연산 결과인 임시 객체를 리턴한다. 이 임시 객체는 값을 리턴하기 위해 잠시 생성되는 것이므로 상수성을 가지는 것이 옳다. 잘 이해가 되지 않으면 정수 연산을 예로 들어 보자.
int i=3,j=4,k;
k=i+j;
이 연산에서 i+j의 결과로 리턴되는 값은 7이라는 정수 상수이지 정수형 변수가 아니다. 즉 우변값이어야지 좌변값이어서는 안된다. 만약 i+j가 값을 변경할 수 있는 정수형 변수를 리턴한다면 i+j=5;라는 연산식도 허용되어야 할 것이다. Complex의 경우 C1+C2는 덧셈을 한 복소수 객체일 뿐 여기에 어떤 변경을 가할 수는 없어야 하며 만약 이를 허용하면 잠시 후면 사라질 임시 객체를 변경하는 쓸데없는 짓을 하게 된다. 이 함수의 원형을 보면 const가 세 번 사용되는데 각각의 의미는 다르다.
읽기 전용 피연산자를 받고 객체를 변경하지 않으며 리턴되는 객체도 읽기만 할 수 있다. 덧셈 연산은 모든 대상을 상수로만 취급한다.
생성자의 활용
Complex 클래스는 실수부 r과 허수부 i를 인수로 전달받는 생성자가 정의되어 있으므로 이를 활용하여 생성자로부터 임시객체를 쉽게 만들 수 있다. operator + 연산자의 본체를 다음과 같이 수정해 보자.
const Complex operator +(const Complex &T) const {
Complex R(real+T.real, image+T.image);
return R;
}
임시 객체 R을 만들 때 생성자로 실수부와 허수부의 연산식을 넘기면 된다. 생성자의 인수로 전달되기 전에 대응되는 멤버끼리 연산이 수행되고 그 결과가 새로 생성되는 객체의 멤버로 대입된다. 또는 아예 임시 객체를 만들지 않고 생성자가 리턴하는 이름없는 임시 객체를 곧바로 리턴할 수도 있다.
const Complex operator +(const Complex &T) const {
return Complex(real+T.real, image+T.image);
}
이 코드는 앞서 만든 코드보다 훨씬 더 짧고 간략해 보일 뿐만 아니라 컴파일러의 리턴값 최적화(RVO:Retrun Value Optimization) 기능의 도움도 받을 수 있어 훨씬 더 유리하다. 제대로 만든 컴파일러는 호출원의 대입되는 좌변에 대해 곧바로 생성자를 호출하며 불필요한 임시 객체를 만들지 않음으로써 훨씬 더 작고 빠른 코드를 생성한다.
임시 객체를 명시적으로 선언하든 아니면 생성자가 리턴하는 임시 객체를 리턴하든 어쨌든 리턴되는 결과는 임시적인 객체이므로 함수 호출이 완료되면 사라진다. 그래서 호출원에서는 C3=C1+C2;처럼 리턴되는 임시 객체를 곧바로 다른 객체에 대입해야 한다. 만약 C1+C2; 연산문으로 더하기만 하고 대입을 받지 않으면 리턴되는 임시 객체는 버려진다. 이 점도 정수형의 연산과 동일하다.
본체
연산자 함수의 본체에는 연산자에 요구되는 논리적인 연산 코드를 작성한다. 실제 연산 코드는 클래스마다, 연산자마다 천차만별로 달라질 것이다. 복소수 연산의 경우 실수부와 허수부를 따로 연산하며 시간은 시분초 요소끼리 연산하되 자리 올림이나 내림을 처리해야 한다. 문자열끼리 더할 때는 버퍼를 재할당하여 연결해야 할 것이며 행렬의 경우 수학적 정의에 따라 행렬 연산을 해야 할 것이다.
이처럼 클래스가 표현하는 대상에 따라 연산하는 방법이 고유하고 특수하기 때문에 클래스를 만든 사람이 연산 방법 자체를 정의할 수 있어야 하며 이런 정의를 가능하도록 하는 C++의 문법적인 장치가 바로 연산자 오버로딩인 것이다. 모든 클래스에 대해, 모든 연산자에 대해 절대적으로 적용되는 법칙 같은 건 없으며 클래스별로 연산자별로 규칙이 달라진다.
이상으로 멤버 연산자 함수를 구성하는 여러 가지 요소에 대해 상세하게 연구해 봤는데 나름대로 합리적인 규칙들이기는 하지만 별로 쉽지는 않을 것이다. 이런 여러 가지 복잡한 규칙들을 골고루 적용하여 제대로 만든 덧셈 연산자 함수의 아름다운 모습을 다시 한 번 더 감상해 보자.
const Complex operator +(const Complex &T) const {
Complex R;
R.image = image + T.image;
R.real = real + T.real;
return R;
}
28-2.전역 연산자 함수
28-2-가.전역 연산자 함수
연산자를 오버로딩하는 방법에는 멤버 함수로 만드는 방법과 전역 함수로 만드는 방법 두 가지가 있다고 했다. 멤버 연산자 함수로 만드는 방법에 대해서는 앞 절에서 충분히 연구해 보았으므로 이번에는 전역 함수로 만드는 방법에 대해 연구해 보자. 전역 연산자 함수는 클래스 외부에 존재하되 인수로 클래스의 객체를 받아들인다.
클래스의 객체가 인수가 된다는 것은 곧 피연산자 중의 하나가 객체가 된다는 뜻이므로 클래스 외부의 전역 함수로도 클래스의 고유한 연산 방법을 정의할 수 있다. 함수란 인수로 전달된 대상을 액세스할 수 있으므로 당연한 얘기다. 다음 예제는 앞에서 만들었던 Time 클래스의 + 연산자를 전역 함수로 새로 작성해 본 것이다.
예 제 : TimeOpPlus
|
#include <Turboc.h>
class Time
{
friend const Time operator+(const Time &T1,const Time &T2);
private:
int hour,min,sec;
public:
Time() { }
Time(int h, int m, int s) { hour=h; min=m; sec=s; }
void OutTime() {
printf("%d:%d:%d\n",hour,min,sec);
}
};
const Time operator+(const Time &T1,const Time &T2)
{
Time R;
R.sec=T1.sec + T2.sec;
R.min=T1.min + T2.min;
R.hour=T1.hour + T2.hour;
R.min += R.sec/60;
R.sec %= 60;
R.hour += R.min/60;
R.min %= 60;
return R;
}
void main()
{
Time A(1,1,1);
Time B(2,2,2);
Time C;
A.OutTime();
B.OutTime();
C=A+B;
C.OutTime();
}
operator + 라는 이름의 전역 함수가 정의되어 있으며 이 함수는 Time형의 레퍼런스 T1, T2를 인수로 전달받아 임시 객체 R에 두 객체의 합을 더해 리턴한다. 시간끼리의 합을 구하는 논리는 멤버 연산자 함수의 경우와 완전히 동일하되 전역 함수라는 점만 다를 뿐이다. 실행 결과는 멤버 연산자 함수의 경우와 완전히 동일하다.
1:1:1
2:2:2
3:3:3
Time 클래스에는 시간끼리 더하는 멤버 연산자 함수가 정의되어 있지 않지만 main 함수는 operator +전역 함수의 도움으로 시간 객체끼리 덧셈을 훌륭하게 수행하고 있다. Time 클래스는 연산자 함수를 멤버로 정의하지 않는 대신 operator + 전역 함수를 friend로 지정하여 자신의 모든 멤버를 자유롭게 액세스할 수 있도록 허락한다.
만약 Time 클래스 선언부의 선두에 있는 friend 선언을 생략해 버리면 수많은 에러 메시지가 출력될 것이다. Time의 주요 멤버인 hour, min, sec은 모두 프라이비트 액세스 속성을 가지고 있으므로 클래스 외부의 전역 함수에서 이 멤버를 참조할 수 없다. 전역 operator + 함수는 시각 객체끼리 덧셈을 하기 위해 이 멤버들을 자유롭게 읽을 수 있어야 하는데 이럴 때 사용하는 것이 바로 프렌드 선언이다.
C=A+B; 연산문은 C=operator +(A,B);의 함수 호출문 형식으로 바꿀 수 있다. 만약 이 함수의 동작이나 호출 방법이 잘 이해되지 않는다면 operator + 함수의 이름을 AddTime이라는 좀 더 친숙한 이름으로 잠시 바꿔 보자. 물론 함수의 본체는 전혀 건드릴 필요가 없다.
const Time AddTime(const Time &T1,const Time &T2)
{
....
}
그리고 main 함수에 있는 C=A+B; 호출문을 C=AddTime(A,B); 로 바꿔 보면 똑같이 동작할 것이다. AddTime은 Time형의 객체를 인수로 취할 뿐이지 단순한 함수에 불과하며 이 함수의 이름만 C++이 정의하는 연산자 함수의 이름 규칙대로 바꾸면 바로 전역 operator + 연산자 함수가 되는 것이다. 결국 전역 연산자 함수란 이름이 조금 특이할 뿐이지 일반적인 함수로 이해하면 쉽다.
객체를 위한 연산자를 오버로딩하는 두 가지 방법, 즉 멤버로 만드는 방법과 전역으로 만드는 방법을 모두 실습해 봤다. 두 함수는 클래스의 내부에 있는가 아니면 외부에 있되 프렌드로 지정되어 있는가만 다를 뿐이며 연산을 하는 논리나 호출하는 방법은 동일하다. 두 형식의 연산자 함수의 차이점은 바로 함수의 원형에 있다.
원형중 가장 다른 부분은 인수의 개수이다. 멤버 연산자 함수의 경우는 원래의 피연산자보다 인수의 개수가 항상 하나 더 적은데 +는 이항 연산자이므로 두 개의 피연산자를 취하지만 멤버 연산자 함수의 인수는 하나만 있으면 된다. 이 함수를 호출하는 객체인 *this가 암시적인 좌변이 되며 나머지 우변이 될 대상만 인수로 전달받는다. 나 자신(this)과 연산될 대상이 누구인가만 알면 되는 것이다. 만약 ++ 단항 연산자를 멤버 연산자 함수로 오버로딩한다면 호출하는 객체 자체가 피연산자가 되므로 인수는 필요없을 것이다.
이에 비해 전역 연산자 함수는 원래의 피연산자와 같은 수의 인수를 가진다. + 연산자가 이항 연산자이므로 operator + 전역 연산자 함수는 두 개의 인수를 취하고 ++ 연산자는 단항 연산자이므로 operator ++ 전역 연산자 함수는 증가시킬 대상 하나만 인수로 전달받으면 된다. 암시적으로 전달되는 this가 없으므로 좌우변 모두 인수로 전달받아야 한다.
그렇다면 연산자 오버로딩이 필요할 때 두 가지 형식중 어떤 함수를 정의하는 것이 좋을까? 두 형식의 연산자 함수는 정의하는 위치만 다를 뿐 큰 차이점은 없으므로 대개의 경우 둘 중 어떤 형식을 쓰더라도 큰 상관은 없다. 클래스의 객체를 다루는 연산이라면 가급적이면 클래스에 소속되는 것이 캡슐화의 원칙에 부합되므로 멤버 연산자 함수로 만드는 것이 더 깔끔하다. 다만 불가피하게 전역으로만 만들어야 하는 경우도 있고 =, ( ), [ ], -> 연산자들은 반드시 멤버 연산자 함수로만 만들어야 한다. 이런 특수한 경우들에 대해서는 뒤에서 개별 연산자를 다룰 때 상세하게 알아보도록 하자.
결국 두 가지 형식이 모두 다 필요하다. 그럴 필요는 없지만 만약 똑같은 연산자 함수를 멤버로도 정의하고 전역으로도 정의한다면 어떻게 될까? 이 경우 정의 자체는 가능하지만 호출할 때 모호하다는 에러 메시지가 출력되므로 양쪽 형식의 연산자를 모두 정의해서는 안되며 그럴 필요도 없다. 컴파일러는 모호한 것을 가장 싫어한다.
참고로 전역 연산자 함수를 사용하면 열거형에 대한 연산도 정의할 수 있다. 열거형도 하나의 타입이며 오버로딩의 재료로 사용할 수 있으므로 열거형을 피연산자로 가지는 연산자도 중복 정의 가능하다. 단, 열거형은 멤버 함수를 가지지 못하므로 전역 연산자 함수로만 정의할 수 있다.
예 제 : EnumOperator
|
#include <Turboc.h>
enum origin { EAST, WEST, SOUTH, NORTH };
origin &operator++(origin &o)
{
if (o == NORTH) {
o = EAST;
} else {
o=origin(o+1);
}
return o;
}
void main()
{
origin mark=WEST;
int i;
for (i=0;i<7;i++) {
printf("%d\n",++mark);
}
}
예제의 ++ 연산자는 origin형의 열거 변수를 다음값으로 증가시키되 마지막 열거값 다음을 선두의 열거값과 연결하여 순환하도록 한다. 이 연산자가 정의되어 있지 않으면 열거형에 대해서는 ++연산을 적용할 수 없다. 루프를 7번 실행했는데 NORTH 다음 값이 EAST가 될 것이다.
28-2-나.객체와 기본형의 연산
연산자를 오버로딩하면 연산문으로 객체끼리 연산할 수 있는 것과 마찬가지로 객체를 정수나 실수형같은 기본형이나 다른 객체와도 연산할 수 있다. 복소수에 실수를 더하거나 뺄 수 있고 시간에 정수형의 초를 연산할 수 있다. 사실 클래스가 타입이므로 굳이 객체와 기본형을 구분할 필요가 없으며 논리적으로 의미만 있다면 오버로딩하기에 따라서 임의 타입의 객체끼리 연산 가능하다.
다음 예제는 시간 객체에 정수형으로 된 초를 더한다. 연산자 함수는 멤버로 되어 있든 전역으로 되어 있든 어쨌든 함수이므로 취할 수 있는 인수의 타입에 근본적인 제약이 없으며 원하는 타입의 인수를 취하기만 하면 임의의 피연산자를 받아들일 수 있다. 물론 시간과 정수처럼 연산이 실질적인 의미가 있어야 한다.
예 제 : TimePlusInt
|
#include <Turboc.h>
class Time
{
private:
int hour,min,sec;
public:
Time() { }
Time(int h, int m, int s) { hour=h; min=m; sec=s; }
void OutTime() {
printf("%d:%d:%d\n",hour,min,sec);
}
const Time operator +(int s) const {
Time R=*this;
R.sec += s;
R.min += R.sec/60;
R.sec %= 60;
R.hour += R.min/60;
R.min %= 60;
return R;
}
};
void main()
{
Time A(1,2,3);
A.OutTime();
A=A+5;
A.OutTime();
}
operator + 멤버 연산자 함수가 int형의 s를 인수로 받아들여 이 값을 임시 객체 R의 sec에 더한 후 자리 올림 처리하고 R을 리턴했다. 객체끼리 더할 때는 시분초를 모두 더하지만 정수형의 초와 더할 때는 sec만 더하는 정도의 차이밖에는 없다. 단 1초라도 더하면 분, 시도 영향을 받을 수 있으므로 자리 올림 처리는 생략할 수 없다. 이 예제는 아주 정상적으로 잘 동작한다.
1:2:3
1:2:8
1:2:3에 5초를 더하면 1:2:8초가 된다. A=A+5; 연산문이 시간 객체와 정수와의 덧셈을 훌륭하게 연산한 것이다. 그렇다면 A=5+A;의 경우는 어떨까? 덧셈은 교환법칙이 성립하는 연산이므로 A+5가 가능하다면 당연히 5+A도 가능해야 한다. main의 A=A+5; 연산문을 A=5+A;로 바꿔 놓고 컴파일해 보자. Time형을 인수로 취하는 + 연산자는 정의되어 있지 않다는 에러가 발생할 것이다. 컴파일러는 5+A라는 연산문을 만났을 때 다음 두 함수 중 하나를 찾는다.
const Time int::operator +(Time);
const Time operator +(int, Time);
위쪽 함수는 멤버 연산자 함수인데 좌변이 5라는 int형 상수이므로 int 클래스에 정의된 멤버 함수이며 Time형 객체를 인수로 취한다. 이런 함수는 int 형에 정의되어 있지 않으며 직접 만드는 것도 불가능하다. int 형은 시스템 내장 타입이기 때문에 사용자가 이 클래스를 마음대로 확장할 수 없다. 아래쪽 함수는 전역 연산자 함수인데 int와 Time형 객체를 인수로 취한다. 이 함수도 아직 만들어져 있지는 않지만 원한다면 직접 만들 수는 있다. 문제를 해결하기 위해 다음 함수를 추가해 보자.
const Time operator +(int s, const Time &T)
{
Time R=T;
R.sec=T.sec + s;
R.min += R.sec/60;
R.sec %= 60;
R.hour += R.min/60;
R.min %= 60;
return R;
}
정수형 변수 s와 Time형 객체 T를 인수로 전달받아 두 객체를 더한다. 이 함수가 정의되어 있으면 컴파일러는 5+A 연산문을 처리할 수 있지만 컴파일해 보면 더 많은 에러가 발생할 것이다. 왜냐하면 전역 함수에서 Time 클래스의 프라이비트 멤버를 액세스하고 있기 때문이다. 이 상황을 해결하는 여러 가지 방법들을 생각해 볼 수 있다.
① 전역 연산자 함수를 아예 삭제해 버리고 클래스 설명서나 소스상의 주석에 A+5 형태로만 호출할 수 있으며 5+A 따위로 호출하지 말라고 분명히 써 놓는다. 만약 5+A 같은 건방진 연산문을 쓸 경우 에러를 잔뜩 토해 버리겠다고 협박할 수도 있다. 어쨌든 객체와 정수의 덧셈 방법은 제공하는 셈이지만 사용자들은 A+5가 되면 5+A도 당연히 될 것이라고 생각하기 때문에 일반적인 기대에 부응하지 못하는 방법이다.
② 전역 연산자 함수가 Time의 멤버를 자유롭게 읽을 수 있도록 프라이비트 멤버를 모두 공개한다. 이렇게 되면 일단 잘 동작하기는 하겠지만 연산자를 오버로딩하기 위해 정보 은폐를 포기하는 꼴이 되므로 OOP의 설계 원칙에 한참 어긋나게 된다. 결코 좋은 방법이 아니다.
③ 프라이비트 멤버는 비공개로 계속 유지하되 이 멤버들을 읽고 쓰는 공개 함수를 모두 작성하고 전역 연산자 함수는 이 액세스 함수들을 통해 멤버를 액세스하도록 한다. 액세스할 필요가 있는 멤버들에 대해 일일이 Get, Set 함수를 만들어야 하므로 무척 번거롭다.
이상의 세 가지 방법은 일단 문제를 해결하기는 하겠지만 모두 다 제대로 된 방법이라 할 수 없다. 이런 어줍짢은 방법보다 훨씬 더 상식적이고 안전하고 편리한 방법이 있으니 이것이 바로 C++ 문법이 제공하는 프렌드 지정이다. 프렌드는 보호가 필요한 멤버를 비공개인채로 유지하면서 특정한 함수나 클래스에 대해서만 예외를 지정할 수 있는 문법적인 장치인데 바로 이럴 때 쓰기 위해 만들어 놓은 것이다. 프렌드의 가장 실용적인 활용예가 바로 전역 연산자 함수이다. 전역 operator + 연산자를 Time 클래스의 프렌드 함수로 지정해 보자.
class Time
{
friend const Time operator +(int s, const Time &T);
....
이 선언을 추가하면 제대로 컴파일되며 A+5나 5+A 모두 잘 실행된다. 양쪽의 요구를 처리하는 함수가 모두 작성되어 있기 때문에 컴파일러가 요구 조건에 맞는 함수를 적절하게 찾아 호출할 것이다. 그런데 아직까지도 불만이 조금 있는데 거의 똑같은 동작을 하는 함수가 두 번 반복된다는 점이다. 두 함수는 인수의 순서만 다를 뿐 코드는 거의 동일하므로 함수 자체는 필요하지만 똑같은 코드를 불필요하게 반복할 필요는 없다. 전역 연산자 함수를 다음과 같이 수정해 보자.
const Time operator +(int s, const Time &T)
{
return T+s;
}
인수로 전달받은 s, T의 순서를 바꿔 T+s 연산문을 리턴하면 멤버 연산자 함수가 이 연산을 대신 처리할 것이다. 이 경우 전역 연산자 함수는 인수의 순서를 바꿔 멤버 연산자 함수를 호출하는 중계 역할만 하며 아주 정상적으로 잘 실행된다. 이렇게 되면 전역 연산자 함수가 Time의 멤버를 직접 액세스하지 않으므로 이 함수에 대한 프렌드 지정은 생략할 수 있다. 하지만 이 함수는 여전히 Time과 관련된 함수이므로 프렌드 지정을 유지하는 것도 별 문제는 없다.
전역 연산자 함수가 중계를 하는 방법 대신 멤버 연산자 함수가 중계를 할 수도 있다. 전역 연산자 함수의 본체를 그대로 유지한채로 멤버 연산자 함수만 다음과 같이 수정해 보자.
class Time
{
....
const Time operator +(int s) const {
return s+*this;
}
};
이번에는 멤버 연산자 함수가 중계를 하는 셈인데 이렇게 해도 역시 잘 동작할 것이다. 실제 연산을 하는 코드는 한쪽에만 있으면 되고 불필요하게 중복시키지 않는 것이 관리하기에 유리하다. 요약하자면 타입이 다른 객체끼리 연산할 때는 교환 법칙이 성립할 수 있도록 전역 연산자 함수를 제공해야 하며 이 함수가 객체 내부의 멤버를 읽을 수 있도록 프렌드 선언을 적절히 활용해야 한다.
28-2-다.오버로딩 규칙
여기까지 객체의 연산을 위해 연산자를 오버로딩하는 두 가지 방법에 대해 연구해 보았는데 지금까지의 내용만 해도 그다지 쉽지는 않았을 것이다. 연산자 오버로딩은 아주 멋진 기능임에 틀림없지만 아주 많은 규칙과 제약이 존재한다. 이 규칙들은 연산자 오버로딩에 따른 부작용을 해소하고 안전하게 연산자를 사용할 수 있도록 마련된 것들이다. 내용이 좀 많기는 하지만 상식 범위를 크게 벗어나는 것은 없으므로 쉽게 익숙해질 수 있을 것이다.
연산자 오버로딩은 이미 존재하는 연산자의 기능을 조금 바꾸는 것이지 아예 새로운 연산자를 만드는 것은 아니다. 원래 C++ 언어가 제공하는 기존 연산자만 오버로딩의 대상이며 C++이 제공하지 않는 연산자를 임의로 만들 수는 없다. 예를 들어 C++은 누승 연산자를 제공하지 않는데(대신 pow라는 표준 함수를 제공한다.) 이런 목적으로 **라는 완전히 새로운 연산자를 정의하고 싶으며 C++이 이를 허용한다고 해 보자. 그렇다면 c=a**b; 라는 연산식이 가능해지는데 컴파일러가 이 식을 해석할 때 두 가지 모호한 상황이 발생한다.
우선 이 연산식을 a를 b만큼 누승하라는 것인지 아니면 a와 포인터 b가 가리키는 곳의 내용을 읽어 곱하라는 것인지를 판별할 수 없다. a**b는 a 누승 b로 볼 수도 있고 a*(*b)로 볼 수도 있어 구문 분석 단계에서 모호함이 발생한다. 그리고 새로 만들어진 연산자의 우선 순위와 결합 순서를 어떻게 정할 것인지도 문제가 된다. c=a**b+d라는 식이 있을 때 누승이 먼저인지 덧셈이 먼저인지를 컴파일러가 임의로 결정할 수 없다. 만약 이것을 정 가능하게 하자면 사용자가 새로 만든 연산자의 우선 순위를 지정할 수 있는 문법을 만들어야 하는데 연산자 하나를 쓰기 위해 이런 복잡한 지정까지 해야 한다면 차라리 안 쓰는 것이 더 나을 것이다.
C++이 사용하지 않는 문자인 $, @ 같은 기호를 새로운 연산자로 정의할 수 있다면 나름대로 편리하겠지만 오버로딩이란 이미 존재하는 것을 중복 정의하는 것이지 없는 걸 아예 새로 만드는 것이 아니므로 이 경우도 부적당하다. 사용자가 임의로 연산자를 만들 수 있도록 하는 것은 이론상 분명히 가능하지만 이런 기능을 지원하기 위해 대폭적인 문법의 확장이 필요하며 예기치 못한 부작용이 생길 수 있다. 득보다 실이 더 많기 때문에 C++은 아예 새로운 연산자를 만드는 문법은 제공하지 않는다.
이미 존재하는 연산자 중에도 오버로딩의 대상이 아닌 것들이 있다. 다음 연산자들은 기능을 변경할 수 없다. 즉, 오버로딩의 대상이 아니다.
.(구조체 멤버 연산자) ::(범위 연산자) ?:(삼항 조건 연산자)
.*( 멤버 포인터 연산자) sizeof typeid
static_cast dynamic_cast const_cast
reinterpret_cast new delete
이 연산자들은 C++의 클래스와 관련된 중요한 동작을 하기 때문에 클래스를 기반으로 하는 연산자 오버로딩의 재료로 쓰기에는 무리가 있다. 클래스의 멤버를 지정하는 . 연산자의 동작을 바꿔 버리면 어떤 혼란이 올지 가히 상상이 갈 것이다. 삼항 조건 연산자는 피연산자가 셋이나 되기 때문에 오버로딩을 하더라도 다른 연산자에 비해 더 복잡한 규칙이 필요할 것으므로 아예 오버로딩하지 못하도록 되어 있다. 이런 몇 가지 특수한 연산자만 빼고 나머지 42개나 되는 연산자들은 모두 오버로딩할 수 있으므로 연산자가 부족한 상황은 발생하지 않을 것이다.
오버로딩 가능한 연산자 중에도 가급적 오버로딩을 삼가해야 하는 것도 있다. 언어가 제공하는 연산자는 우선순위 규칙이 명확하게 정의되어 있어 좌우의 피연산자 중 어떤 것이 먼저 평가될지 예측 가능하다. 그러나 함수로 오버로딩되면 인수의 평가 순서가 정의되지 않으므로 원치 않는 부작용이 발생할 수도 있다. 예를 들어 콤마 연산자의 경우 좌에서 우로 순서대로 평가하지만 함수로 오버로딩되면 이런 우선 순위가 더 이상 적용되지 않는다. 함수의 인수는 보통 우에서 좌로 평가되어 연산자의 평가 순서와는 반대로 되어 있는데 순서가 의미가 있을 때는 문제가 될 수도 있다.
&&, || 논리 연산자의 경우 쇼트 서키트 기능이 동작하도록 설계되어 있지만 오버로딩되면 쇼트 서키트는 더 이상 동작하지 않는다. 문법적으로는 허용된다 하더라도 그 효과를 예측하기 어려우므로 가급적이면 이 연산자들은 오버로딩하지 말아야 한다. 사실 이 연산자들이 오버로딩되어야 하는 경우도 거의 없는 편이다.
기존 연산자의 기능을 바꾸더라도 연산자의 본래 형태는 변경할 수 없다. 여기서 본래 형태라고 하는 것은 피연산자의 개수와 우선 순위를 말한다. + 연산자는 원래 피연산자를 두 개 취하는 이항 연산자이므로 오버로딩된 후에도 이항 연산자여야 하며 반드시 피연산자 두 개를 가져야 한다. operator +가 멤버 연산자 함수일 때는 하나의 인수를 가져야 하며 전역 연산자 함수일 때는 두 개의 인수가 필요하다.
const Time Time::operator +(Time &T) // 가능
const Time Time::operator +(int i) // 가능
const Time operator +(Time &T,int i) // 가능
const Time Time::operator +(Time &T1,Time &T2) // 불가능
const Time operator +(Time &T) // 불가능
const Time operator +(void) // 불가능
우선 순위나 결합 순서도 변경할 수 없다. + 연산자의 기능을 바꾸어 곱셈이나 누승을 하도록 오버로딩했다 하더라도 이 연산자의 우선 순위는 원래의 + 연산자의 것과 동일하다. 컴파일러는 연산자가 논리적으로 어떤 연산을 하는지 다른 연산과의 관계가 어떠한지까지는 판단할 수 없다. 따라서 완전히 새로운 연산을 정의하고자 할 때 적당한 우선 순위를 가지는 연산자를 골라 오버로딩해야 한다.
다른 언어는 누승 연산을 위해 ^ 기호를 사용한다. 그래서 ^ 연산자를 누승 연산자로 재정의한다면 베이직 언어처럼 편리하게 누승 연산을 할 수 있을 것이다. 그러나 C++에서 원래의 ^ 연산자는 곱셈보다 우선 순위가 늦고 심지어 덧셈이나 뺄셈보다도 우선 순위가 늦어 모양이 직관적이더라도 누승 연산자로 재정의하기에는 어울리지 않는다. 2^3+4는 12여야 상식적이지만 +의 순위가 높아 128이 되어 버린다.
아주 당연한 얘기가 되겠지만 한 클래스가 하나의 연산자를 여러 가지 피연산자 타입에 대해 오버로딩할 수 있다. 오버로딩이란 인수의 개수나 타입이 다르면 항상 성립하므로 여러 개의 피연산자에 대한 연산자를 제공할 수 있다는 얘기다. Time 클래스에 다음 두 덧셈 연산자가 동시에 정의되어도 아무런 문제가 없다.
const Time operator+(const Time &T) const { .... }
const Time operator +(int s) const { .... }
위쪽 함수는 시간에 시간을 더하는 것이고 아래쪽 함수는 시간에 정수를 더하는 것이다. 호출부에서 피연산자 타입을 보고 어떤 덧셈을 원하는지 알 수 있으므로 모호하지 않으며 실용성도 있다. 원한다면 시간에 실수나 Date를 더하는 + 연산자도 얼마든지 중복 정의할 수 있다.
오버로딩된 연산자의 피연산자 중 적어도 하나는 사용자 정의형이어야 한다. 연산자의 기능을 바꾸는 목적은 객체에 대한 고유한 연산 방법을 정의하기 위한 것이므로 반드시 객체와 관련있는 연산자만 중복 정의할 수 있다. C++이 기본적으로 제공하는 타입에 대해서는 연산자를 오버로딩할 수 없다. 만약 다음과 같은 연산자 함수를 만들 수 있다고 해 보자.
int operator +(int a, int b)
{
....
}
이 함수는 정수형의 덧셈 연산을 완전히 새로 정의하는데 정수형의 덧셈은 언어의 가장 기본적인 동작이고 CPU의 원자적인 연산이기 때문에 이 동작이 바뀌게 되면 파급효과가 너무 엄청날 것이다. 그래서 컴파일러는 기본형에 대한 연산자 오버로딩은 거부하며 "최소한 하나의 피연산자는 클래스 타입이어야 한다"는 에러 메시지를 출력한다.
사실 위에서 보인 두 개의 정수를 더하는 연산자는 시스템에 이미 존재한다. 오버로딩이란 인수의 개수나 타입이 달라야 하므로 위 연산자는 오버로딩의 대상이 될 수도 없다. 오버로딩이란 이미 존재하는 함수의 기능을 바꾸는 것이 아니라 타입에 따라 다르게 동작하는 함수를 중복 정의하는 것이므로 기본 타입에 대한 연산자는 만들 수 없는 것이다.
강제적인 규칙은 아니지만 연산자의 논리적 의미는 가급적 유지하는 것이 바람직하다. + 연산자를 오버로딩한다면 어떤 클래스에 대해서라도 덧셈의 의미를 가지는 연산을 하는 것이 좋다. 그래야 연산자에 대한 사용자들의 기존 상식을 보호할 수 있다. + 연산자를 더하기와는 전혀 상관없는 다른 연산으로 오버로딩해 버리면 이 객체를 사용하는 사람은 혼란스러워할 것이다. 그러나 이 규칙은 어디까지나 권장 사항일 뿐이므로 불가피할 경우는 지키지 않아도 상관없다.
예를 들어 표준 입출력 스트림인 cout은 쉬프트 연산자인 << 를 모양이 마음에 든다는 이유로 출력 연산자로 재정의하는 만행을 저지르기도 한다. 기본 문법에는 제공되지 않는 완전히 새로운 연산을 정의할 경우는 적당한 우선 순위를 가지고 모양이 좀 그럴싸해보이는 연산자 하나를 선택할 수밖에 없을 것이다.
연산자를 적절하게 오버로딩하면 복잡한 것을 간결하게 논리적으로 표기할 수 있고 어떤 동작을 한다는 것을 명확하게 표현할 수 있어 가독성에 유리하다. 또한 이미 익숙해진 연산자를 직관적인 방법으로 활용할 수 있으므로 생산성 향상에도 큰 기여를 한다. 그러나 연산자 오버로딩에는 여러 가지 부작용도 있음을 알아야 한다. 모든 문법이 마찬가지겠지만 연산자 오버로딩은 꼭 필요할 때, 그리고 논리적으로 무리가 없을 때에 한해 규칙에 맞게 안전하게 사용해야 한다.
C++의 철학은 작성하는 사람을 편리하게 하는 것보다는 만들어진 객체를 사용하는 사람을 편하게 하자는 데 있다. 보다시피 연산자 오버로딩이란 복잡한 규칙이 존재하고 섣불리 잘못 만들 경우 오동작을 할 위험이 도처에 도사리고 있지만 한 번만 잘 만들어 놓으면 여러 사람이 편리하게 사용할 수 있는 그런 장치이다. 예를 들어 str이라는 문자열에 주어진 문자열 s1, s2, s3를 차례대로 앞쪽에 삽입하고 싶다고 하자. 가령 str이 "aaa", s1이 "111", s2가 "222", s3가 "333"이라고 할 때 str을 "333222111aaa"로 만들고 싶은 것이다. 문자열을 뒤쪽에 연결하는 것은 strcat로 간단하게 해결할 수 있지만 앞쪽 연결은 표준 함수의 지원이 없어 직접 해야 한다. C의 해결 방법은 다음과 같다.
char temp[256];
strcpy(temp,str);
strcpy(str,s1);
strcat(str,temp);
strcpy(temp,str);
strcpy(str,s2);
strcat(str,temp);
strcpy(temp,str);
strcpy(str,s3);
strcat(str,temp);
|
앞쪽에 삽입해야 하므로 temp라는 임시 버퍼도 필요하고 복사 및 연결을 여러 번 반복적으로 수행해야 한다. strcat 표준 함수는 뒤쪽에 덧붙이기만 할 뿐 앞쪽에 삽입하지는 못하기 때문이다. 뿐만 아니라 str이 나머지 문자열을 모두 포함할 수 있는 충분한 길이를 가지는가도 항상 신경써야 하며 그렇지 못할 경우 위험해지기까지 한다. 물론 sprintf라는 좀 더 간단한 방법도 있고 앞쪽에 삽입하는 함수를 따로 만들 수도 있지만 비효율적이고 불편하기는 마찬가지다. 그러나 이 문제를 C++ 객체와 연산자 오버로딩을 사용하면 정말 간단해진다.
str=s3+s2+s1+str;
단, 한 줄로 끝난다. 단순히 코드의 길이만 짧아지는 것이 아니라 문제를 푸는 과정이 단순해지는 것이다. 물론 이 코드의 내부에서는 C의 코드와 똑같은 동작을 하겠지만 사용자에게는 이런 상세한 과정이 숨겨지므로 몰라도 상관없다. 이 연산이 제대로 수행되기 위해서는 operator +가 연쇄적인 연산을 잘 지원할 수 있도록 해야 하며 교환 법칙도 만족해야 하고 객체가 버퍼를 지능적으로 관리할 필요도 있다. 물론 이렇게 만들기 위해서는 덧셈 연산자뿐만 아니라 생성자, 파괴자, 대입 연산자 등도 규칙에 맞게 잘 작성해야 한다. 하지만 객체를 작성하는 개발자 혼자서 한 번만 이런 모든 부담을 감수하면 이 객체를 사용하는 수천, 수만명의 사용자는 아주 행복하게 이 객체를 잘 활용할 수 있을 것이다.
28-3.오버로딩의 예
여기까지 주로 + 연산자만을 대상으로 연산자를 오버로딩하는 기본적인 방법에 대해 알아보았다. 뎃셈 연산자가 가장 기본적이고 연산자의 일반적인 특징을 대변하는 대표적 연산자이기 때문이다. 이 절에서는 개별 연산자별로 오버로딩 실습을 해 볼 것이되 기본 규칙외에도 각 연산자별로 고유하게 적용되는 규칙과 주의 사항들이 많이 존재한다.
모든 연산자에 일관되게 적용되는 규칙은 없고 연산자의 동작과 의미에 따라 오버로딩하는 방법이 다르다. 대개의 경우 상식과 일치하므로 어렵지는 않지만 연산자의 수가 많기 때문에 한꺼번에 다 공부하기는 쉽지 않다. 이 절의 내용은 처음부터 다 이해하려고 하는 것보다는 대충 통독만 해 두고 해당 연산자를 오버로딩할 필요가 있을 때 다시 상세하게 공부하는 것이 더 좋다.
28-3-가.관계 연산자
관계 연산자는 동일한 타입의 두 객체에 대해 상등 및 대소를 비교한다. 클래스별로 비교 방법이 틀리므로 편리한 비교를 위해서는 관계 연산자를 오버로딩하는 것이 좋다. 다음 예제는 Time 객체의 관계 연산자를 오버로딩한 것이되 특별한 주의 사항이나 새로운 규칙은 없다. 같은 타입의 객체끼리 비교하는 것이므로 모두 멤버 연산자 함수로 정의했다. 물론 전역 함수로도 얼마든지 만들 수 있다.
예 제 : TimeRelation
|
#include <Turboc.h>
class Time
{
private:
int hour,min,sec;
public:
Time() { }
Time(int h, int m, int s) { hour=h; min=m; sec=s; }
void OutTime() {
printf("%d:%d:%d\n",hour,min,sec);
}
bool operator ==(const Time &T) const {
return (hour == T.hour && min == T.min && sec == T.sec);
}
bool operator !=(const Time &T) const {
return !(*this == T);
}
bool operator >(const Time &T) const {
if (hour > T.hour) return 1;
if (hour < T.hour) return 0;
if (min > T.min) return 1;
if (min < T.min) return 0;
if (sec > T.sec) return 1;
return 0;
}
bool operator >=(const Time &T) const {
return (*this == T || *this > T);
}
bool operator <(const Time &T) const {
return !(*this >= T);
}
bool operator <=(const Time &T) const {
return !(*this > T);
}
};
void main()
{
Time A(1,1,1);
Time B(1,1,1);
if (A == B) {
puts("A와 B는 같다.");
} else {
puts("A와 B는 다르다.");
}
}
먼저 두 객체가 같은지를 점검하는 == 연산자를 보자. 두 객체가 완전히 같으려면 시분초의 요소가 모두 일치해야 한다. 그래서 좌우변 객체의 hour, min, sec 멤버를 모두 비교한 결과를 && 로 묶어 세 요소가 모두 일치하면 같은 것으로 판단하고 셋 중 하나라도 틀리면 다른 것으로 판단하도록 했다. 이 연산자가 정의되면 if (A == B)라는 연산식으로 두 객체의 상등 비교를 할 수 있다.
관계 연산자는 진위적인 연산을 하므로 리턴 타입은 bool형이 가장 적합하다. 그러나 반드시 bool만 가능한 것은 아니다. BOOL형일 수도 있고 int형도 얼마든지 가능한데 문자열 관련 타입의 경우 strcmp 같은 표준 함수와 보조를 맞추고 싶다면 int형을 리턴하는 것이 오히려 더 편리할 수도 있다. 연산자 하나만 놓고 본다면 당연히 bool형이어야겠지만 크다, 작다, 같다의 세 가지 상태 중 하나를 리턴하려면 int 타입이 더 어울린다.
두 객체가 다른지를 점검하는 != 연산자는 직접 코드를 작성할 필요없이 == 연산자를 호출한 결과를 반대로 뒤집어서 다시 리턴하면 된다. 다르다는 상태는 같지 않다는 상태와 의미가 동일하기 때문에 두 함수의 본체를 각각 따로 만들 필요가 없다. 호출 객체인 *this와 우변 객체인 T에 대해 ==로 연산하면 이미 재정의된 operator ==이 호출될 것이고 그 결과에 ! 연산을 적용하여 리턴했다.
좌변이 우변보다 더 큰지를 점검하는 > 연산은 나름대로 조금 복잡하다. 시분초로 구성되는 Time 객체에서 무엇보다 가장 큰 단위인 시간이 우선적으로 비교되어야 한다. 시간이 더 크면 분초의 대소에는 상관없이 이 객체가 더 큰 것으로 쉽게 판단할 수 있다. 그래서 일단 hour 멤버를 비교해 보고 대소를 판단한다. 만약 두 조건(hour > T.hour, hour < T.hour)이 모두 만족하지 않을 경우는 다음 차례로 분을 비교하고 분까지 일치한다면 초를 비교하여 대소를 판가름한다. 최종적으로 초까지 비교해 보고 호출객체의 초가 우변 객체보다 크지 않다면 이 경우는 작거나 같은 경우이므로 전체 연산의 결과는 거짓이 될 것이다. if문이 너무 많아 보기 싫다면 다음과 같이 짧게 쓸 수도 있다.
int operator >(const Time &T) const {
return (hour*3600+min*60+sec > T.hour*3600+T.min*60+T.sec);
}
호출 객체와 우변 객체의 시간을 절대초로 바꾼 후 비교 결과를 바로 리턴하면 된다. 3차원의 값을 1차원으로 바꾼 후 비교하는 것이다. 생성되는 기계어 코드나 속도는 비슷하지만 사람이 생각하기에는 이 방법이 더 쉬워 보인다. 같다와 크다를 비교하는 연산자가 완성되면 나머지 부등 비교 연산자들은 따로 코드를 작성할 필요가 없으며 이미 만들어진 연산자를 호출한 결과만 조합하면 된다. 남은 세 부등 연산은 논리적으로 다음처럼 같다와 크다, 그리고 아니다의 조합으로 바꿀 수 있다.
연산
|
대체 연산
|
크거나 같다
|
같다 또는 크다
|
작다
|
크거나 같다가 아니다
|
작거나 같다
|
크다가 아니다
|
이렇게 바꿔진 조합을 적절한 조건문의 코드로 옮기기만 하면 된다. 주의할 것은 작다의 반대 조건이 크다가 아니라 크거나 같다라는 점이다. 엄밀하게 논리를 따지지 않는 자연어에서와 수학에서의 대소 반대 조건이 다르므로 헷갈리지 말자.
bool operator >=(const Time &T) const {
return에 const 빼고 객체가 아니기 때문
28-3-나.증감 연산자
++연산자는 피연산자를 1증가시키는 단항 연산자이다. 비슷한 종류의 -- 감소 연산자도 있는데 두 연산자는 증감 방향만 다를 뿐 오버로딩하는 방법은 동일하므로 ++ 연산자에 대해서만 예제를 만들어 보도록 하자. 다음 예제는 Time 객체에 ++ 연산자를 중복 정의한다.
예 제 : TimePlusPlus
|
#include <Turboc.h>
class Time
{
private:
int hour,min,sec;
public:
Time() { }
Time(int h, int m, int s) { hour=h; min=m; sec=s; }
void OutTime() {
printf("%d:%d:%d\n",hour,min,sec);
}
Time &operator ++() {
sec++;
min += sec/60;
sec %= 60;
hour += min/60;
min %= 60;
return *this;
}
const Time operator ++(int dummy) {
Time R = *this;
++*this;
return R;
}
};
void main()
{
Time A(1,1,1);
Time B;
B=++A;
A.OutTime();
B.OutTime();
B=A++;
A.OutTime();
B.OutTime();
}
증가 연산자는 값을 1 증가시키는데 구체적인 의미는 객체에 따라 조금씩 다르게 정의될 것이다. 복소수 객체는 실수부만 1.0 증가시키는 것이 합리적이고 Position 객체는 (x,y) 좌표를 오른쪽 아래로 한칸 이동시킬 수도 있고 x만 증가시키는 것으로 정의할 수도 있다. Person 객체의 경우는 적절한 증가 대상이 없어 ++ 연산자와는 잘 어울리지 않는데 굳이 하자면 나이 정도를 증가시킬 수는 있다.
시간인 경우는 초를 증가시키는 것으로 정의하는 것이 가장 합리적이며 그래서 예제의 operator ++ 연산자는 sec 멤버만 1증가시키는 형식으로 시간 객체에 대해 ++ 연산을 정의했다. 필요하다면 분이나 시를 증가시킬 수도 있다. ++ 연산자는 피연산자를 하나만 취하는 단항 연산자이며 예제의 operator ++은 멤버 연산자 함수로 정의되었으므로 이 함수를 호출하는 객체 자신이 피연산자가 된다. 따라서 이 함수는 별도의 인수를 가질 필요가 없다. 만약 ++ 연산자를 전역 함수로 정의한다면 변경할 대상 객체 하나만을 인수로 전달받으면 된다. 물론 객체의 멤버를 자유롭게 읽기 위해서는 프렌드 지정을 해야 한다.
Time &operator ++(Time &T) {
T.sec++;
T.min += T.sec/60;
T.sec %= 60;
T.hour += T.min/60;
T.min %= 60;
return T;
}
++ 연산자는 호출한 객체(전역 함수인 경우 인수로 전달된 객체, 어쨌든 피연산자)를 직접 변경하기 때문에 상수성을 가지지 않는다. 그래서 operator ++() 함수 다음에 const라는 지정이 없으며 전역 함수로 정의할 경우도 인수의 타입은 Time &여야지 const Time &가 되어서는 안된다. 이항 연산자들은 보통 피연산자를 읽기만 하는데 비해 ++, -- 단항 연산자는 피연산자를 직접 변경하는 점이 조금 다르다.
연산자 함수의 리턴 타입은 Time &로 되어 있는데 ++ 연산자가 단순히 객체를 1 증가시키기만 한다면 리턴값이 없는 void 형으로 정의할 수도 있다. 그러나 이렇게 되면 ++A로 A를 1 증가시킬 수는 있지만 B=++A 연산문으로 증가된 결과를 다른 객체에 대입할 수는 없다. C의 모든 연산문은 리턴값을 가지며 그래서 수식내에서 연산문을 사용할 수 있다. 이 요구 조건을 만족시키기 위해 ++ 연산자도 값을 증가시킨 후 증가된 객체 그 자체(*this)를 리턴해야 한다.
잘 알고 있겠지만 증감 연산자는 다른 연산자와는 달리 전위형(prefix)과 후위형(postfix) 두 가지 형식으로 쓸 수 있으며 수식내에서 사용될 때는 ++연산자의 위치에 따라 효과가 조금 다르다. 객체에 대해서도 마찬가지 규칙이 적용되어야 하는데 문제는 전위형이나 후위형이나 둘 다 사용되는 위치만 다를 뿐이지 연산자 함수의 이름은 operator ++로 동일하다는 것이다. 게다가 취하는 인수의 개수까지 같으므로 전위형의 ++ 연산자와 후위형의 ++ 연산자를 이름이나 인수 목록으로 구분할 수 없다.
그래서 C++ 표준위원들은 이 두 형식의 증가 연산자를 구분하기 위해 좀 어색하기는 하지만 더미 인수를 쓰는 방법을 쓰기로 결정했다. 전위형의 ++ 연산자 함수는 증가되는 객체 외에는 인수를 취하지 않으며 후위형의 ++ 연산자는 연산 대상인 객체 외에도 정수형의 더미 인수를 하나 더 취하기로 결정한 것이다. 당장 사용하지 않는 인수이지만 이 인수가 있음으로써 오버로딩이 성립되고 두 형식의 ++연산자 함수를 컴파일러가 구분할 수 있다.
컴파일러는 표준위원들이 정한 규칙대로 ++A 형태의 식을 컴파일할 때는 operator ++() 멤버 함수를 호출하고 A++ 형태의 식을 컴파일할 때는 operator ++(int) 멤버 함수를 찾는다. 아무리 표준이라 하더라도 이 부분은 다소 깔끔하지 못해 보이는데 이는 일종의 약속이기 때문에 우리는 이대로 외우고 규칙대로 ++ 연산자를 오버로딩해야 한다. 그래서 예제에서는 operator ++() 전위형 증가 멤버 연산자 함수와 operator ++(int dummy) 후위형 증가 멤버 연산자 함수를 같이 정의하고 있다. 후위형의 인수 목록에서 dummy는 어차피 자리만 차지하고 사용되지 않는 인수이므로 이름은 생략해도 상관없으며 int 타입만 남겨 둬도 된다.
전위형과 후위형은 효과가 다르기 때문에 본체와 리턴 타입에 있어 차이가 있다. 전위형의 증가 연산자는 객체의 sec을 1증가시키고 올림처리한 후 증가된 객체의 레퍼런스(Time &)를 리턴한다. 후위형인 경우는 일단 값을 먼저 평가한 후 증가시켜야 하므로 증가시키기 전의 객체를 지역변수 R에 백업해 놓고 값을 증가시킨 후 R을 리턴한다. 지역변수의 레퍼런스를 리턴할 수는 없으므로 후위형의 리턴타입은 Time이다. 값을 증가시키는 코드는 전위형의 ++ 연산자에 이미 작성되어 있으므로 ++*this를 호출하면 된다. 실행 결과는 다음과 같다.
1:1:2
1:1:2
1:1:3
1:1:2
전위형일 때(B=++A)는 증가된 값이 리턴되므로 A와 B가 모두 증가된 값을 가지지만 후위형으로 사용할 때(B=A++)는 증가되기 전의 A값이 리턴되므로 A만 1증가하고 B는 증가하기 전의 값을 대입받는다. 정수형의 후위 증감식과 효과가 동일하다. 만약 후위형의 증가 연산자를 정의하지 않고 A++ 후위 증가식을 사용하면 전위형을 대신 사용한다는 경고가 발생한다. 반대의 경우도 마찬가지인데 수식내에서 증가 연산자를 사용한다면 이 경고는 절대 무시할 수 없다. 사용자가 기대하는 효과가 다르기 때문이다. 전위형을 정의했으면 후위형도 반드시 정의해야 한다.
다음은 두 형식의 리턴 타입에 대해 생각해 보자. 전위형은 Time &를 리턴하고 후위형은 const Time을 리턴하는데 전위형이 레퍼런스를 리턴해야 하는 이유는 ++++A 같은 식이 가능해야 하기 때문이다. ++A가 먼저 평가되어 A가 1증가하고 다시 ++A가 실행되어 A를 한 번 더 증가시키기 위해 이 연산자의 리턴 타입이 레퍼런스여야 한다. 만약 전위형의 operator ++이 Time을 리턴한다면 ++++A 연산식이 어떻게 평가될 것인가 생각해 보자. 첫 번째 호출은 A에 대한 호출이므로 A가 1증가하지만 두 번째 이후부터는 리턴된 *this의 사본에 대한 호출이기 때문에 A는 한 번밖에 증가하지 않을 것이다.
후위형의 경우는 값을 먼저 평가한 후 증가해야 하므로 객체 자체를 리턴할 수 없으며 값만 리턴할 수 있다. 따라서 Time &를 리턴해서는 안되며 이렇게 할 경우 지역변수의 레퍼런스를 리턴한다는 경고가 발생할 것이다. 뿐만 아니라 리턴된 임시 객체를 변경하는 것은 의미가 없으며 A++++은 금지되어야 한다. 만약 후위형의 ++이 상수가 아닌 Time을 리턴할 경우 A++++은 적법한 문장이 되지만 실제로는 1밖에 증가하지 않아 오동작을 하는 것처럼 보일 것이다. 그러므로 아예 애초부터 꿈도 꾸지 못하도록 상수 객체를 리턴해야 한다.
전위형은 단순한 산술식 뿐인데 비해 후위형은 임시 객체 생성, 초기화, 전위형 ++ 호출 등 여러 가지 추가 처리가 필요하다. 그래서 증가시키는 동작만으로 본다면 전위형이 더 빠르고 효율적이며 객체를 단독으로 증가시킬 때는 가급적이면 전위형의 ++연산자를 쓰는 것이 유리하다. 수식내에서라면 물론 두 형태의 효과가 다르므로 적합한 형식을 사용해야 한다. 기본형의 경우도 물론 전위형이 더 유리하나 컴파일러는 단독으로 사용되는 증가 연산자는 전위형으로 바꿔서 호출하므로 i++이나 ++i나 동일하다. 단 기본형이라 하더라도 수식내에서 사용될 때는 이런 최적화를 하지 않는다.
증가 연산자는 두 가지 형식이 있어 오버로딩하기가 조금 까다로운데 다음 도표의 지침대로만 원형을 작성하면 된다. T형 클래스에 대해 멤버, 전역인 경우와 전위, 후위형인 경우 각각에 대해 ++ 연산자의 원형을 정리하였다. 외울 필요는 없고(사실 잘 외워지지도 않는다) 필요할 때마다 이 도표를 참조하도록 하자.
멤버 연산자 함수
|
전역 연산자 함수
| |
전위형
|
T &T::operator ++()
|
T &operator ++(T &t)
|
후위형
|
T T::operator ++(int)
|
T operator ++(T &t, int)
|
28-3-다.대입 연산자
대입 연산자는 자신과 같은 타입의 다른 객체를 대입받을 때 사용하는 연산자이다. 객체 자체와 직접적인 연관이 있기 때문에 클래스의 멤버 함수로만 정의할 수 있으며 전역 함수로는 정의할 수 없다. 정적 함수로도 만들 수 없고 반드시 일반 멤버 함수로 만들어야 한다. 다음 예제는 앞장에서 만들었던 Person2예제에 디폴트 생성자를 추가하고 main 함수의 테스트 코드를 약간 수정한 것이다. Person 클래스는 생성자에서 동적으로 버퍼를 할당한다는 점에서 Time이나 Complex 클래스와는 다르며 이 버퍼를 주의깊게 다루어야 할 필요가 있다.
예 제 : Person3
|
#include <Turboc.h>
class Person
{
private:
char *Name;
int Age;
public:
Person() {
Name=new char[1];
Name[0]=NULL;
Age=0;
}
Person(const char *aName, int aAge) {
Name=new char[strlen(aName)+1];
strcpy(Name,aName);
Age=aAge;
}
Person(const Person &Other) {
Name=new char[strlen(Other.Name)+1];
strcpy(Name,Other.Name);
Age=Other.Age;
}
~Person() {
delete [] Name;
}
void OutPerson() {
printf("이름 : %s 나이 : %d\n",Name,Age);
}
};
void main()
{
Person Boy("강감찬",22);
Person Young("을지문덕",25);
Young=Boy;
Young.OutPerson();
}
Person2 예제에서는 Young 객체를 선언할 때 Person Young=Boy; 형식으로 선언하면서 동시에 초기화를 했었다. 이때는 복사 생성자가 호출되는데 Person2예제에 복사 생성자가 작성되어 있으므로 이 코드는 이상없이 잘 동작한다. 그러나 일단 선언한 후 대입을 받게 되면 문제가 달라진다. 이 예제를 실행해 보면 프로그램이 종료될 때 다운되는 것을 확인할 수 있다.
선언과 동시에 다른 객체로 초기화하면 이때 복사 생성자가 호출되고 복사 생성자는 새로 생성되는 객체를 위해 별도의 버퍼를 준비하므로 두 객체가 버퍼를 따로 가져 아무런 문제가 없다. 그러나 실행중에 이미 사용중인 객체를 다른 객체로 대입할 때는 초기화 단계가 아니므로 복사 생성자는 호출되지 않는다. 다음 두 경우를 잘 구분하자.
대입은 ① 이미 생성된 객체에 적용된다. ② 실행중에 언제든지 여러 번 대입될 수 있다는 점에서 초기화와는 다르다. 실행중에 객체끼리 대입 연산을 하면 어떤 일이 벌어지는지 보자.
깊은 복사를 하는 대입
대입 연산자를 별도로 정의하지 않을 경우 컴파일러는 디폴트 대입 연산자를 만드는데 이 연산자는 디폴트 복사 생성자와 마찬가지로 단순한 멤버별 대입만 한다. 우변 객체의 모든 멤버 내용을 좌변 객체의 대응되는 멤버로 그대로 대입함으로써 얕은 복사만 하는 셈이다. 결국 Young의 Name 멤버는 Boy의 Name 멤버가 가리키는 버퍼의 주소를 그대로 가지게 될 것이다. 이때의 메모리 상황을 그림으로 그려 보자.
두 객체 모두 "강감찬"을 가리키고 있으며 main 함수가 종료될 때 각각의 파괴자가 호출되는데 먼저 파괴되는 객체가 Name 버퍼를 정리할 것이고 나중에 파괴되는 객체가 이 버퍼를 이중으로 정리하려고 하므로 무효해진 메모리를 해제하는 오류를 범하는 것이다. 결국 이 문제는 복사 생성자를 정의하지 않았을 때의 문제와 동일하며 생성과 동시에 초기화할 때처럼 대입을 받을 때도 깊은 복사를 하도록 해야 한다.
뿐만 아니라 생성할 때와는 달리 대입 연산은 실행중에 언제든지 여러 번 일어날 수 있기 때문에 객체가 사용중이던 메모리를 해제하지 않으면 다시는 이 메모리에 접근할 수 없는 문제도 있다. 위 그림에서 Young이 Boy를 대입받은 후 "을지문덕"은 더 이상 읽지도 쓰지도 못하며 해제할 방법조차 없다. 동적으로 할당한 메모리는 포인터가 진입점인데 이 진입점을 잃어버린 것이다. 이런 문제들을 해결하려면 = 연산자를 오버로딩하여 대입할 때도 깊은 복사를 하도록 해야 한다. Person 클래스에 다음 멤버 연산자 함수를 추가해 보자.
class Person
{
....
Person &operator =(const Person &Other) {
if (this != &Other) {
delete [] Name;
Name=new char[strlen(Other.Name)+1];
strcpy(Name,Other.Name);
Age=Other.Age;
}
return *this;
}
};
복사 생성자의 코드와 유사한 코드가 반복되는데 대입되는 Other의 Name 길이+1만큼 버퍼를 새로 할당한 후 내용을 복사했다. Age는 단순한 정수형 변수이므로 그냥 대입하기만 하면 된다. 복사 생성자와 마찬가지 방법으로 깊은 복사를 하되 대입 동작은 실행중에 여러 번 그것도 임의의 순간에 발생할 수 있기 때문에 좀 더 신경써야 할 것들이 많다.
우선 Name 멤버를 할당하기 전에 이전에 사용하던 메모리를 먼저 해제해야 한다. 복사 생성의 경우 Name은 새로 만들어지는 중이므로 할당되어 있지 않지만 대입은 사용중인 객체에 대해 일어나는 연산이므로 Name이 이미 할당되어 있을 것이다. 다른 객체를 대입받는다는 것은 이전의 내용을 버린다는 뜻이므로 이미 할당된 메모리를 해제할 필요가 있는데 이 처리를 하지 않으면 대입할 때마다 이전에 사용하던 메모리가 누수될 것이다. 그래서 new 연산자로 Name을 할당하는 코드 앞에 delete [] Name이 필요하다. 이때 Name이 이미 할당되어 있는지는 점검할 필요가 없는데 디폴트 생성자가 1바이트를 할당하고 있으므로 Name은 항상 동적으로 할당되어 있기 때문이다.
그리고 대입 요청을 받았을 때 대입 대상이 자기 자신이 아닌지도 꼭 점검해야 하는데 A=A 같은 대입문도 일단은 가능해야 하기 때문이다. 이 문장은 자기가 자신의 값을 대입받는 사실상의 NULL문장이지만 고의든 실수든 아니면 코드의 일관성을 위해서건 틀린 문법은 아니므로 지원하는 것이 옳다. 자기 자신이 대입될 때는 아무 것도 하지 않고 자신을 리턴하기만 하면 된다. 만약 이 조건문을 빼 버리면 delete [] Name에 의해 자신의 버퍼를 먼저 정리해 버리고 정리된 버퍼의 내용을 다시 복사하려고 들기 때문에 객체의 내용이 제대로 유지되지 않을 것이다.
대입 후 리턴되는 값
대입 연산자의 리턴 타입이 Person &인 이유는 A=B=C식의 연쇄적 대입이 가능해야 하기 때문이다. 대입만이 목적이라면 void형으로 선언해도 상관없겠지만 기본 타입에서 가능한 모든 연산이 객체에서도 가능해야 하므로 가급적 똑같이 동작하도록 만들어야 한다. 대입 연산자가 대입된 결과값을 리턴하기 때문에 연쇄적인 대입이 가능하다.
이때 리턴되는 객체가 상수일 필요는 없는데 대입 후 리턴되는 객체를 바로 사용할 수도 있고 변경할 수도 있다. (Young=Boy).OutPerson(); 식으로 대입받은 좌변 객체에 대해 멤버 함수를 호출할 수 있다. 설사 이 멤버 함수가 객체의 상태를 변경하는 비상수 함수라도 말이다. 기본 타입도 대입 연산자에 의해 리턴되는 것은 좌변값인데 다음 테스트 코드를 통해 확인해 보자.
int i=1,j=2;
(i=j)=3;
printf("%d,%d\n",i,j);
i=j 대입문에 의해 i에 2가 대입되고 i 자체가 리턴된다. 이때 리턴되는 레퍼런스는 좌변값이므로 바로 3을 대입할 수 있다. 출력되는 결과는 3,2가 된다. 실제로 이런 식은 잘 쓰이지도 않고 실용성도 없지만 어쨌든 클래스는 기본 타입과 같아야 하므로 기본 타입들이 하는 짓은 다 할 수 있어야 한다.
올바른 디폴트 생성자
Person3 예제는 디폴트 생성자를 정의하고 있으므로 Person Young; 선언문으로 일단 객체를 먼저 만들어 놓고 다른 객체의 값을 대입받아도 상관없다. 디폴트 생성자는 받아들이는 인수가 없으므로 멤버들을 NULL, 0, FALSE로 초기화하여 쓰레기를 치우는 것이 통상적인 임무이지만 동적 할당을 하는 클래스의 경우 포인터를 NULL로 초기화해서는 안된다. 왜 그런지 다음 테스트 코드를 실행해 보자.
Person() { Name=NULL;Age=0; }
Person Boy;
Person Young=Boy;
디폴트 생성자가 쓰레기를 치우고 있으므로 인수없이 객체를 생성할 수 있다. 그러나 이렇게 만들어진 객체를 사용할 때 여기저기서 문제가 생긴다. 위 테스트 코드는 복사 생성자를 호출하는데 복사 생성자의 본체에서 strlen 함수로 Other.Name의 길이를 구하고 있다. 0번지는 허가되지 않은 영역이므로 이 번지를 읽기만 해도 당장 다운되어 버린다. 복사 생성자가 쓰레기만 치운 객체를 전달받아도 죽지 않으려면 예외 처리 코드가 더 작성되어야 한다.
Person(const Person &Other) {
if (Other.Name == NULL) {
Name=NULL;
} else {
Name=new char[strlen(Other.Name)+1];
strcpy(Name,Other.Name);
}
Age=Other.Age;
}
초기식의 객체가 NULL 포인터를 가리키면 새로 선언되는 객체도 같이 NULL포인터를 가지도록 해야 한다. 복사 생성자뿐만 아니라 대입 연산자, Name을 참조하는 모든 멤버 함수에서 Name이 NULL인 경우를 일일이 예외 처리해야 하는 것이다. 이렇게 하는 것이 귀찮고 비효율적이기 때문에 디폴트 생성자가 포인터를 초기화할 때는 비록 1바이트라도 할당하여 Name이 NULL이 되지 않도록 하는 것이 좋다. 비록 1바이트에 빈 문자열밖에 들어 있지 않지만 이 메모리도 동적으로 할당한 것이므로 읽을 수 있다.
Person3의 디폴트 생성자가 할당하는 1바이트는 자리만 지키는 플레이스 홀더(PlaceHolder) 역할을 한다. 아무 짝에도 쓸모없는 것 같지만 Name이 반드시 동적 할당된 메모리임을 보장하여 이 버퍼를 참조하는 모든 코드를 정규화시키는 효과가 있다. 모든 멤버 함수는 Name의 길이가 얼마이든지 무조건 할당되어 있다는 가정하에 Name을 안심하고 액세스할 수 있다.
동적 할당 클래스의 조건
이 예제에서 보다시피 초기화와 대입은 여러 모로 다르다는 것을 알 수 있다. 초기화는 객체를 위한 메모리를 할당할 때 이 공간을 어떻게 채울 것인가를 지정하며 일회적인데 비해 대입은 실행중에 같은 타입인 다른 객체의 사본을 작성하며 회수에 제한이 없다. 대입이 초기화보다는 훨씬 더 복잡하고 비용도 많이 든다. 그래서 컴파일러는 복사 생성자와 대입 연산자를 구분해서 호출하며 따라서 우리는 둘 다 만들어야 한다. class A=B; 선언문을 디폴트 생성자로 A를 먼저 만든 후 B를 대입하는 것으로 처리할 경우 속도가 훨씬 더 늦어질 것이다. 실제로 구형 컴파일러는 이런 식으로 초기화를 구현했었다.
Time이나 Complex 클래스는 복사 생성자가 없어도 선언할 때 다른 객체로 초기화할 수 있으며 대입 연산자를 굳이 정의하지 않아도 객체끼리 안심하고 대입할 수 있다. 왜냐하면 값만을 가지는 클래스는 컴파일러가 만들어 주는 디폴트 복사 생성자, 디폴트 대입 연산자만으로도 충분히 잘 동작하기 때문이다. 이에 비해 Person 클래스는 동적으로 할당하는 메모리가 있기 때문에 여러 모로 관리해야 할 것들이 많은데 최소한 다음과 같은 함수들이 있어야 한다.
함수
|
설명
|
생성자
|
생성될 때 메모리를 할당한다.
|
파괴자
|
사용하던 메모리를 반납한다.
|
복사 생성자
|
초기화될 때 별도의 메모리를 할당한다.
|
대입 연산자
|
사용하던 메모리를 해제하고 대입받는 객체에 맞게 다시 할당한다.
|
이 중 하나라도 빠지거나 생략되면 Person 클래스는 제대로 동작하지 않는다. 생성자는 초기화라는 중요한 임무를 가지므로 꼭 동적 할당을 하지 않더라도 대부분의 클래스에 필수적이다. 나머지 셋은 생성자에서 동적 할당이나 그와 유사한 효과의 동작을 할 때 꼭 필요한데 셋 중 하나가 필요하다면 나머지 둘도 마찬가지로 필요하다. 그래서 이 셋은 같이 뭉쳐서 다니는 특징이 있으며 흔히 삼총사라고 부른다.
Person3 예제의 Person 클래스는 비로소 완벽해졌으며 선언과 동시에 초기화, 실행중 대입 등이 가능해져 기본 타입과 동등한 자격을 가지게 되었다. 그러나 상속을 하지 않을 경우에만 완벽하며 상속할 경우 파괴자가 가상 함수여야 한다는 조건이 하나 더 추가된다. 이 예에서 동적으로 할당되는 메모리란 클래스 동작에 꼭 필요한 어떤 자원의 비유에 해당한다. 예를 들어 하드웨어 장치를 열어야 하거나 네트워크 접속, DB 연결, 권한 획득 등이 필요한 클래스는 모두 비슷한 법칙이 적용된다. 아무튼 멤버를 그대로 복사해서는 똑같은 객체를 만들 수 없는 모든 클래스에는 이런 함수들이 필요하다.
복합 대입 연산자
이번에는 대입 연산자와 유사한 복합 대입 연산자를 오버로딩해 보자. 복합 대입 연산자는 대입과 비슷한 동작을 하기는 하지만 아예 다른 연산자이므로 필요할 경우 따로 정의해야 한다. 예를 들어 Time 클래스에 operator + 연산자를 오버로딩했다고 해서 operator += 까지 같이 정의되는 것은 아니다. 다음은 += 복합 대입 연산자의 오버로딩 예이다.
예 제 : OpPlusEqual
|
#include <Turboc.h>
class Time
{
private:
int hour,min,sec;
public:
Time() { }
Time(int h, int m, int s) { hour=h; min=m; sec=s; }
void OutTime() {
printf("%d:%d:%d\n",hour,min,sec);
}
Time &operator +=(int s) {
sec += s;
min += sec/60;
sec %= 60;
hour += min/60;
min %= 60;
return *this;
}
};
void main()
{
Time A(1,1,1);
A+=62;
A.OutTime();
}
+ 연산자와 다른 점은 호출한 객체를 직접 변경시키기 때문에 const가 아니라는 점, 그리고 자기 자신이 피연산자이므로 임시 객체를 필요로 하지 않는다는 점 정도이다. A+=62 연산문에 의해 A가 가진 시간에 62초를 더한 값이 A에 다시 대입된다. 사용자는 + 연산이 가능하면 +=연산도 가능하다고 기대하므로 가급적이면 두 연산자를 같이 제공하는 것이 좋다. 이 경우 +=을 먼저 정의해 놓고 + 연산자는 이 함수를 호출하는 것이 효율적이다.
Time operator +(int s) {
Time R=*this;
R+=s;
return R;
}
+=에 정수를 더하는 연산이 먼저 정의되어 있으므로 +는 임시 객체에 += 연산한 결과를 값으로 리턴하기만 하면 된다. 뿐만 아니라 덧셈의 규칙이 변경되더라도 +=의 코드만 수정하면 되므로 코드를 유지하기도 훨씬 더 쉽다.
복사 생성 및 대입 금지
클래스는 일종의 타입이므로 기본 타입과 완전히 동일해질 수 있는 모든 문법이 제공된다. 선언, 초기화, 복사, 대입, 연산 등등 int가 할 수 있는 모든 동작을 다 할 수 있다. 그러나 경우에 따라서는 이런 것이 어울리지 않거나 그래서는 안되는 클래스들도 있다. 예를 들자면 하드웨어를 직접적으로 제어하거나 유일한 자원을 관리하는 객체를 들 수 있는데 하나만 가지고도 충분히 원하는 동작을 모두 할 수 있으므로 굳이 둘을 만들 필요가 없다.
이런 예는 멀리서 찾을 것도 없이 표준 입출력 스트림 객체인 cin, cout을 보면 된다. 이 객체 한쌍으로 화면에 원하는 모든 출력을 할 수 있고 키보드로 입력을 받을 수 있는데 cin, cout이 두 개씩 있을 필요가 없지 않은가? 어떤 경우에는 동일한 타입의 객체가 두 개 있을 경우 혼선이 빚어지기도 하고 서로 간섭하여 오동작하거나 데드락에 걸리는 부작용도 있다. 이런 클래스들은 허가되지 않는 연산을 적절히 막아야 하는데 금지할 필요가 있는 대표적인 연산이 복사 생성과 대입이다.
복사 생성과 대입을 못하는 클래스를 만드는 방법은 생각보다 쉽다. 복사 생성자와 대입 연산자를 선언하되 둘 다 private 영역에 두는 것이다. 아예 정의하지 않으면 컴파일러가 디폴트를 만드므로 반드시 private영역에 직접 선언해야 한다. 어차피 호출되지 않을 함수들이므로 본체의 내용은 작성하지 않아도 상관없다. Person 클래스는 복사, 대입이 모두 가능한 경우이긴 하지만 금지해야 한다고 가정하고 위 예제를 대상으로 이 동작들을 금지시켜 보자.
class Person
{
private:
char *Name;
int Age;
Person(const Person &Other);
Person &operator =(const Person &Other);
....
이렇게 해 놓으면 Person Young=Boy; 같은 선언문이나 Girl=Boy; 같은 대입문이 실행될 때 컴파일러가 복사 생성자나 대입 연산자를 호출하려고 할 것이다. 객체를 선언하는 곳은 객체의 외부이므로 private 멤버를 호출할 수 없으며 컴파일 중에 이 동작이 허가되지 않는다는 것을 알 수 있다. 실행중에 문제를 일으키는 것보다 컴파일할 때 이 동작은 금지되었음을 확실히 알리는 것이 바람직하다. 좀 더 적극적으로 에러 내용을 상세하게 알리고 싶을 때는 이 둘을 public 영역에 두되 assert문을 작성해 놓는 방법을 쓸 수 있다.
두 함수를 private 영역에 둘 때 본체 내용은 아예 작성하지 않는 것이 좋다. 왜냐하면 외부에서 이 함수를 호출하는 것은 컴파일러가 컴파일 중에 막아 주지만 클래스 내부의 멤버 함수나 프렌드 함수에서는 여전히 이 함수를 호출할 수 있기 때문이다. 함수를 선언만 해 놓고 본체를 정의하지 않더라도 이 함수가 호출되기 전에는 링커가 본체를 찾지 않으므로 아무 이상이 없다. 만약 정의되지도 않는 함수를 호출하려고 하면 컴파일은 무사히 되지만 링크할 때 에러로 처리되므로 이 동작이 불가능하다는 것을 알 수 있다. 외부에서 불가능한 동작을 시도하면 컴파일러가 막아주고 내부에서 엉뚱한 짓을 하려면 링커가 막아준다. C++의 객체는 이런 식으로 실수든 고의든 허가되지 않는 위험한 연산을 스스로 방어하도록 작성되어야 한다.
복사 생성자, 대입 연산자 작성 규칙은 나름대로 복잡해서 이해는 되더라도 실무에서 직접 작성하기는 쉽지가 않다. 개념적인 이해는 꼭 해 두고 실제 코드를 작성할 때는 Person3 예제에서 코드를 복사한 후 원하는 부분만 수정하는 것이 편리하다. Person3 예제의 복사 생성자, 대입 연산자는 모든 상황에 대해 잘 작동하도록 만든 모범 답안이다.
28-3-라.<< 연산자
C++의 표준 스트림 출력 객체인 cout은 << 연산자를 오버로딩하여 이 연산자의 우변을 표준 출력(모니터)으로 내보내는 기능을 제공한다. << 연산자 다음의 피연산자가 정수든 실수든 포인터든 거의 가리지 않고 출력되는데 이렇게 되는 이유는 cout 객체의 소속 클래스인 ostream에 다음과 같은 여러 원형의 << 멤버 연산자 함수가 오버로딩되어 있기 때문이다.
ostream& operator<<(const char *);
ostream& operator<<(char);
ostream& operator<<(short);
ostream& operator<<(int);
ostream& operator<<(long);
ostream& operator<<(float);
ostream& operator<<(double);
....
C++ 컴파일러는 << 다음의 피연산자 타입을 보고 적절한 << 멤버 연산자 함수를 호출하므로 거의 대부분의 기본 타입을 문제없이 출력할 수 있는 것이다. char *는 그대로 화면으로 전송할 것이고 int나 double은 문자열로 바꾼 후 전송할 것이다. 그렇다면 다음 문장은 과연 어떻게 처리될까?
Time A(1,2,3);
cout << A;
Time형의 객체 A를 << 연산자를 사용하여 cout객체로 보내면 A의 내용이 제대로 출력될까? 이것은 불가능하다. 왜냐하면 cout은 Time 클래스에 대해서 아는 바가 없으며 operator <<(Time) 함수는 정의되어 있지 않기 때문이다. 그렇다면 cout으로 Time 객체를 출력하려면 Time을 피연산자로 받아들이는 연산자 함수를 하나 더 오버로딩하면 될 것이다. 이미 오버로딩되어 있는 << 연산자를 cout이 Time객체를 인식하도록 추가로 오버로딩하는 것이다.
이 경우 operator << 연산자 함수를 cout의 소속 클래스인 ostream의 멤버 함수로 추가하거나 아니면 전역 함수로 만들어야 하는데 멤버로 정의하는 것은 불가능하다. 왜냐하면 ostream은 C++ 표준 라이브러리에 이미 컴파일되어 있어 int나 double같은 기본형 클래스와 거의 같은 수준의 타입이기 때문이다. 표준 라이브러리 함수를 뜯어 고칠 수는 없고 그래서 이 경우는 반드시 프렌드 전역 연산자 함수로 정의해야 한다.
예 제 : coutTime
|
#include <Turboc.h>
//#include <iostream.h>
#include <iostream>
using namespace std;
class Time
{
friend ostream &operator <<(ostream &c, const Time &T);
friend ostream &operator <<(ostream &c, const Time *pT);
private:
int hour,min,sec;
public:
Time() { }
Time(int h, int m, int s) { hour=h; min=m; sec=s; }
void OutTime() {
printf("%d:%d:%d\n",hour,min,sec);
}
};
ostream &operator <<(ostream &c, const Time &T)
{
c << T.hour << "시" << T.min << "분" << T.sec << "초";
return c;
}
ostream &operator <<(ostream &c, const Time *pT)
{
c << *pT;
return c;
}
void main()
{
Time A(1,1,1);
Time *p;
p=new Time(2,2,2);
cout << "현재 시간은 " << A << "입니다." << endl;
cout << "현재 시간은 " << p << "입니다." << endl;
delete p;
}
만약 사용하고 있는 컴파일러에서 이 소스가 컴파일되지 않는다면 iostream 대신 iostream.h 헤더 파일을 인클루드하고 네임스페이스 선언을 삭제하면 된다. VC60 이상의 최신 컴파일러에서는 별 무리없이 컴파일되는데 단, Visual C++ 6.0의 경우 서비스팩을 설치하지 않으면 이 예제가 제대로 컴파일되지 않는 버그가 있으므로 반드시 최신 서비스팩을 설치한 후 컴파일하도록 하자. 실행 결과는 다음과 같다.
현재 시간은 1시1분1초입니다
현재 시간은 2시2분2초입니다
ostream 객체와 Time형 객체 또는 포인터를 인수로 취하는 ostream << 전역 연산자를 두 벌 정의하고 이 연산자 함수를 Time의 프렌드로 지정했다. 출력을 위해 자신의 모든 멤버를 읽을 수 있도록 권한을 주어야 한다. 컴파일러는 cout << A연산문을 만났을 때 ostream 클래스의 멤버 연산자 함수 <<를 검색해 보고 Time을 인수로 취하는 함수가 있는지 조사한 후 멤버 중에 그런 함수가 없으면 전역 함수를 찾는다. 결국 cout << A연산문은 operator <<(cout, A) 전역 연산자 함수 호출문으로 해석되어 cout으로 Time 객체의 시분초 멤버를 순서대로 출력할 것이다.
Time형 포인터도 별도의 타입이므로 이 타입에 대해서도 << 연산자를 따로 정의해야 한다. 물론 본체는 직접 작성할 필요없이 operator <<(Time)의 것을 잠시 빌리기만 하면 된다. ostream 클래스의 모든 << 연산자와 마찬가지로 사용자가 직접 만든 << 연산자 함수도 반드시 ostream형의 레퍼런스를 리턴해야 cout << A << p << endl; 같은 연쇄적 출력이 가능하다.
28-3-라.<< 연산자
C++의 표준 스트림 출력 객체인 cout은 << 연산자를 오버로딩하여 이 연산자의 우변을 표준 출력(모니터)으로 내보내는 기능을 제공한다. << 연산자 다음의 피연산자가 정수든 실수든 포인터든 거의 가리지 않고 출력되는데 이렇게 되는 이유는 cout 객체의 소속 클래스인 ostream에 다음과 같은 여러 원형의 << 멤버 연산자 함수가 오버로딩되어 있기 때문이다.
ostream& operator<<(const char *);
ostream& operator<<(char);
ostream& operator<<(short);
ostream& operator<<(int);
ostream& operator<<(long);
ostream& operator<<(float);
ostream& operator<<(double);
....
C++ 컴파일러는 << 다음의 피연산자 타입을 보고 적절한 << 멤버 연산자 함수를 호출하므로 거의 대부분의 기본 타입을 문제없이 출력할 수 있는 것이다. char *는 그대로 화면으로 전송할 것이고 int나 double은 문자열로 바꾼 후 전송할 것이다. 그렇다면 다음 문장은 과연 어떻게 처리될까?
Time A(1,2,3);
cout << A;
Time형의 객체 A를 << 연산자를 사용하여 cout객체로 보내면 A의 내용이 제대로 출력될까? 이것은 불가능하다. 왜냐하면 cout은 Time 클래스에 대해서 아는 바가 없으며 operator <<(Time) 함수는 정의되어 있지 않기 때문이다. 그렇다면 cout으로 Time 객체를 출력하려면 Time을 피연산자로 받아들이는 연산자 함수를 하나 더 오버로딩하면 될 것이다. 이미 오버로딩되어 있는 << 연산자를 cout이 Time객체를 인식하도록 추가로 오버로딩하는 것이다.
이 경우 operator << 연산자 함수를 cout의 소속 클래스인 ostream의 멤버 함수로 추가하거나 아니면 전역 함수로 만들어야 하는데 멤버로 정의하는 것은 불가능하다. 왜냐하면 ostream은 C++ 표준 라이브러리에 이미 컴파일되어 있어 int나 double같은 기본형 클래스와 거의 같은 수준의 타입이기 때문이다. 표준 라이브러리 함수를 뜯어 고칠 수는 없고 그래서 이 경우는 반드시 프렌드 전역 연산자 함수로 정의해야 한다.
예 제 : coutTime
|
#include <Turboc.h>
//#include <iostream.h>
#include <iostream>
using namespace std;
class Time
{
friend ostream &operator <<(ostream &c, const Time &T);
friend ostream &operator <<(ostream &c, const Time *pT);
private:
int hour,min,sec;
public:
Time() { }
Time(int h, int m, int s) { hour=h; min=m; sec=s; }
void OutTime() {
printf("%d:%d:%d\n",hour,min,sec);
}
};
ostream &operator <<(ostream &c, const Time &T)
{
c << T.hour << "시" << T.min << "분" << T.sec << "초";
return c;
}
ostream &operator <<(ostream &c, const Time *pT)
{
c << *pT;
return c;
}
void main()
{
Time A(1,1,1);
Time *p;
p=new Time(2,2,2);
cout << "현재 시간은 " << A << "입니다." << endl;
cout << "현재 시간은 " << p << "입니다." << endl;
delete p;
}
만약 사용하고 있는 컴파일러에서 이 소스가 컴파일되지 않는다면 iostream 대신 iostream.h 헤더 파일을 인클루드하고 네임스페이스 선언을 삭제하면 된다. VC60 이상의 최신 컴파일러에서는 별 무리없이 컴파일되는데 단, Visual C++ 6.0의 경우 서비스팩을 설치하지 않으면 이 예제가 제대로 컴파일되지 않는 버그가 있으므로 반드시 최신 서비스팩을 설치한 후 컴파일하도록 하자. 실행 결과는 다음과 같다.
현재 시간은 1시1분1초입니다
현재 시간은 2시2분2초입니다
ostream 객체와 Time형 객체 또는 포인터를 인수로 취하는 ostream << 전역 연산자를 두 벌 정의하고 이 연산자 함수를 Time의 프렌드로 지정했다. 출력을 위해 자신의 모든 멤버를 읽을 수 있도록 권한을 주어야 한다. 컴파일러는 cout << A연산문을 만났을 때 ostream 클래스의 멤버 연산자 함수 <<를 검색해 보고 Time을 인수로 취하는 함수가 있는지 조사한 후 멤버 중에 그런 함수가 없으면 전역 함수를 찾는다. 결국 cout << A연산문은 operator <<(cout, A) 전역 연산자 함수 호출문으로 해석되어 cout으로 Time 객체의 시분초 멤버를 순서대로 출력할 것이다.
Time형 포인터도 별도의 타입이므로 이 타입에 대해서도 << 연산자를 따로 정의해야 한다. 물론 본체는 직접 작성할 필요없이 operator <<(Time)의 것을 잠시 빌리기만 하면 된다. ostream 클래스의 모든 << 연산자와 마찬가지로 사용자가 직접 만든 << 연산자 함수도 반드시 ostream형의 레퍼런스를 리턴해야 cout << A << p << endl; 같은 연쇄적 출력이 가능하다.
28-3-마.[ ] 연산자
[ ] 연산자는 배열에서 첨자 번호로부터 요소를 찾는다. 반드시 멤버 함수로만 정의할 수 있으며 전역 함수로는 정의할 수 없다. 여러 가지 자료의 집합을 다루는 클래스에서 이 연산자를 오버로딩하여 원하는대로 기능을 부여할 수 있다. 앞장에서 만들었던 동적 배열 클래스인 DArray 클래스에 [ ] 연산자 함수를 추가해 보자. DArray 예제에 다음 멤버 연산자 함수만 추가하면 된다.
class DArray
{
....
ELETYPE &operator [](int idx) {
return ar[idx];
}
};
배열 첨자를 인수로 전달하면 인수가 지정하는 순서의 배열 요소에 대한 레퍼런스를 리턴하도록 했다. 이 연산자가 정의되면 배열 요소를 간편하게 읽고 쓸 수 있다.
printf("%d\n",ar[3]);;
ar[3]=100;
ar[3]으로 네 번째 요소를 읽을 수 있을 뿐만 아니라 레퍼런스를 리턴하므로 ar[3]을 대입식의 좌변에 놓는 것도 가능하다. 이 연산자에 의해 첨자로부터 배열 요소를 읽고 쓸 수 있으므로 GetAt, SetAt 멤버 함수는 이제 삭제해도 상관없다. 이름을 가지는 멤버 함수보다 [ ] 연산자가 훨씬 더 직관적이다. [ ] 연산자가 다른 연산자들과 다른 특이한 점이라면 대입식의 좌변과 우변에 모두 쓸 수 있다는 점이다. 그래서 상수 객체에 대해서도 쓸 수 있는 const 버전의 [ ] 연산자도 중복 정의해야 한다. 상수 [ ] 연산자를 만들지 않았을 때 어떻게 되는지 테스트해 보자.
const DArray car;
car[0]=3; // 에러
printf("%d\0",car[0]); // 이것도 에러
car는 상수 객체로 선언되었으므로 값을 변경할 수 없다. 그러므로 car[0]에 어떤 값을 대입하는 문장은 당연히 에러이며 컴파일되지 않는다. 그러나 마지막 문장은 car[0]를 읽기만 했는데도 불구하고 역시 에러로 처리되는데 이는 합리적이지 못하다. [ ] 연산자가 좌변에 쓰일 때는 값을 변경하지 못하므로 당연히 에러이지만 우변에 쓰일 때는 읽기만 하므로 허용되어야 한다. 그러나 컴파일러는 함수의 선언문에 const 지정이 없으면 객체의 내용을 바꿀 수도 있다고 생각하기 때문에 사용되는 위치에 상관없이 상수 객체에 대해서는 이 연산자를 사용 금지시킨다. 만약 상수 객체에 대해 읽기만 하는 [ ] 연산자를 따로 정의하고 싶다면 다음 연산자 함수를 하나 더 오버로딩해야 한다.
const ELETYPE &operator [](int idx) const {
return ar[idx];
}
상수 함수와 비상수 함수를 각각 제공하면 컴파일러는 객체의 상수성을 보고 적당한 함수를 호출할 것이다. 그런데 위 예의 car 객체는 아무 짝에도 쓸모없는 객체처럼 보인다. 동적 배열이란 값을 저장하는 것이 본연의 임무인데 이 객체를 상수로 선언하면 요소를 추가하거나 삭제할 수 없으며 오로지 읽을 수만 있다. 추가가 안되는데 읽는 기능이 가능한 것은 아무 의미가 없지 않은가? 과연 그렇기는 하다. 그러나 그래도 상수 객체는 여전히 필요한데 함수의 인수로 전달받을 때 형식 인수가 상수성을 가져야 할 필요가 있기 때문이다.
func(const DArray *pAr) { ... }
이런 함수 내부에서 pAr이 가리키는 대상체를 상수 취급하고 싶을 때 상수 객체가 사용되며 이 객체의 값을 읽기 위해 상수성을 가지는 [ ] 연산자도 필요한 것이다.
만약 [ ] 연산자를 오버로딩하는 객체의 배열을 만든다면 이 배열에서 객체를 선택하는 [ ] 연산자와 오버로딩된 [ ] 연산자는 어떻게 구분할 수 있을까? 예를 들어 동적 배열 객체의 배열을 크기 3으로 선언하는 다음 코드를 보자.
DArray ar[3];
ar[2][1]=5;
이 경우 ar[2][1]의 앞쪽 [2]와 뒤쪽의 [1]은 각각 어떤 의미를 가질까? 언뜻 보기에는 컴파일러가 무척 헷갈려할 것 같지만 차근히 생각해 보면 대상 타입으로부터 정확한 연산자의 의미를 어렵지 않게 구분할 수 있다. 앞쪽의 [2]는 전체 배열 ar에 대해 쓰여졌으므로 이 연산자는 본래의 배열 첨자 연산자이며 ar배열에서 2번째 요소를 선택한다. 뒤쪽의 [1]은 ar[2]에 대해 쓰여졌으며 ar[2]는 DArray 타입의 객체이므로 이 연산자는 오버로딩된 연산자이다.
[ ] 연산자는 원래 배열 요소중 하나를 구하는 동작을 하지만 오버로딩되면 완전히 다른 의미를 부여할 수도 있으며 배열과 상관없는 클래스에도 적용할 수 있다. 어쨌든 입력값으로부터 객체의 정보 중 하나를 리턴하는 형태의 동작은 모두 정의할 수 있다. 예를 들어 Time객체의 각 요소를 구하는 용도로도 쓸 수 있는데 다음이 그 예이다.
예 제 : TimeIndex
|
#include <Turboc.h>
class Time
{
private:
int hour,min,sec;
public:
Time() { }
Time(int h, int m, int s) { hour=h; min=m; sec=s; }
void OutTime() {
printf("%d:%d:%d\n",hour,min,sec);
}
int &operator [](int what) {
switch (what) {
case 0:
return hour;
case 1:
return min;
default:
case 2:
return sec;
}
}
const int &operator [](int what) const {
switch (what) {
case 0:
return hour;
case 1:
return min;
default:
case 2:
return sec;
}
}
};
void main()
{
Time A(1,1,1);
const Time B(7,7,7);
A[0]=12;
printf("현재 %d시입니다.\n",A[0]);
//B[0]=8;
printf("현재 %d시입니다.\n",B[0]);
}
Time 객체를 구성하는 hour, min, sec 멤버는 배열이 아니지만 [ ] 연산자를 오버로딩하여 [0]이면 시, [1]이면 분, [2]면 초를 리턴하도록 했다. 외부에서는 마치 이 객체를 구성하는 시분초 멤버가 배열에 속한 요소인 것처럼 사용하는 것이 가능하다.
똑같은 [ ] 연산자가 두 벌 정의되어 있는데 하나는 비상수 버전이고 하나는 상수 버전이다. [ ] 연산자가 레퍼런스를 리턴하므로 이 연산자로 객체의 값을 읽을 수 있을 뿐만 아니라 A[0]=12 처럼 첨자 연산자로 객체 내용을 변경할 수 있다. 그래서 이 연산자는 const가 아니어야 하는데 이렇게 될 경우 상수 객체에 대해서는 [ ] 연산자를 사용할 수 없다는 제약이 생긴다. 설사 상수 객체에 대해 [ ] 연산자로 읽기만 한다 하더라도 컴파일러는 [ ] 연산자 함수가 const 속성을 가지고 있지 않으므로 호출을 허가하지 않을 것이다. 그래서 [ ] 연산자는 항상 상수 버전, 비상수 버전 두 벌이 필요하다.
C/C++ 언어의 [ ] 연산자는 포인터 연산을 하도록 정의되어 있으므로 피연산자 중 하나는 반드시 포인터이고 나머지 하나는 반드시 정수여야 한다. 그러나 오버로딩되면 어디까지나 함수에 불과하므로 임의의 타입을 인수로 전달받을 수 있다. 예를 학생들의 목록을 가지는 StuList 클래스가 있을 때 [ ] 연산자의 인수로 학생의 이름(const char *)을 주면 학번(int)을 리턴하는 int StuList::operator[](const char *) 함수를 정의할 수도 있다.
예 제 : StuList
|
#include <Turboc.h>
class StuList
{
private:
struct Student {
char Name[10];
int StNum;
} S[30];
public:
StuList() {
strcpy(S[0].Name,"이승만");S[0].StNum=1;
strcpy(S[1].Name,"박정희");S[1].StNum=3;
strcpy(S[2].Name,"전두환");S[2].StNum=6;
strcpy(S[3].Name,"노태우");S[3].StNum=9;
strcpy(S[4].Name,"김영삼");S[4].StNum=15;
strcpy(S[5].Name,"김대중");S[5].StNum=17;
strcpy(S[6].Name,"노무현");S[6].StNum=20;
strcpy(S[7].Name,"??????");S[7].StNum=100;
}
int operator[](const char *Name) {
for (int i=0;;i++) {
if (strcmp(S[i].Name,Name)==0) return S[i].StNum;
if (S[i].Name[0]=='?') return -1;
}
}
};
void main()
{
StuList SL;
printf("김영삼 학생의 학번은 %d번입니다.\n",SL["김영삼"]);
}
편의상 생성자에서 학생 목록을 작성했는데 실제 예에서는 데이터베이스에서 학생 목록을 대량으로 읽어들일 것이다. 이 클래스가 가진 정보 중 문자열을 인수로 주고 원하는 정수를 찾는 용도로 [ ] 연산자를 활용했다. [ ] 연산자를 일종의 검색 연산자로 의미를 변경하여 활용하는 것이다.
28-3-바.멤버 참조 연산자
클래스나 구조체의 멤버를 참조하는 연산자에는 . 과 -> 두 가지가 있다. 이중 . 연산자는 클래스를 프로그래밍하는 너무 기본적인 연산자이므로 오버로딩할 수 없으며 객체의 포인터로부터 멤버를 읽는 ->연산자는 오버로딩 대상이다. 이 연산자는 다른 연산자와는 다른 독특한 오버로딩 규칙이 적용되는데 원래 이항 연산자이지만 오버로딩하면 단항 연산자가 되며 전역 함수로는 정의할 수 없고 클래스의 멤버 함수로만 정의할 수 있다. 멤버 함수이면서 단항이므로 결국 인수를 취하지 않는다.
이 연산자의 리턴 타입은 클래스나 구조체의 포인터로 고정되어 있다. 보통 클래스에 포함된 다른 클래스 객체나 구조체의 번지를 리턴하여 포함된 객체의 멤버를 읽는 용도로 사용된다. 이 연산자를 오버로딩하면 포함 객체의 멤버를 마치 자신의 멤버처럼 액세스할 수 있다. 다음은 간단한 예제이다.
예 제 : MemberAccessOp
|
#include <Turboc.h>
struct Author {
char Name[32];
char Tel[24];
int Age;
};
class Book
{
private:
char Title[32];
Author Writer;
public:
Book(const char *aTitle,const char *aName,int aAge) {
strcpy(Title,aTitle);
strcpy(Writer.Name,aName);
Writer.Age=aAge;
}
Author *operator->() { return &Writer; }
const char *GetTitle() { return Title; }
};
void main()
{
Book Hyc("혼자 연구하는 C/C++","김상형",25);
printf("제목:%s, 저자:%s, 저자 나이:%d세\n",Hyc.GetTitle(),Hyc->Name,Hyc->Age);
}
Book 클래스안에 저자의 신상을 표현하는 Author 객체 Writer가 멤버로 포함되어 있고 이 멤버의 번지를 리턴하는 -> 연산자가 정의되어 있다. 외부에서 Book 객체의 Writer 멤버에 접근하려면 . 연산자를 두 번 연거푸 사용해야 하지만 ->를 쓰면 Writer의 멤버를 마치 Book 객체의 멤버인 것처럼 바로 액세스할 수 있다. 실행 결과는 다음과 같다.
제목:혼자 연구하는 C/C++, 저자:김상형, 저자 나이:25세
Hyc->Name으로 Writer 포함 객체의 멤버를 읽었는데 보다시피 Name이 Hyc객체의 멤버인 것처럼 사용되고 있다. 원래 -> 연산자의 좌변에는 포인터만 올 수 있지만 오버로딩되면 객체가 와도 상관없다. 어차피 오버로딩된 -> 연산자가 포인터를 리턴한다. 이 표현식은 컴파일러에 의해 다음과 같이 해석된다.
Hyc.operator->()->Name
-> 연산자가 &Writer를 리턴하므로 이 포인터로부터 Writer의 멤버를 바로 액세스할 수 있다. 포함 객체의 멤버를 읽기 위해 Hyc.Writer.Name 이런 식으로 . 연산자를 두 번 사용하는 것은 허가되지 않는데 Writer가 프라이비트 액세스 속성을 가지고 있기 때문이다. -> 연산자는 숨겨진 멤버의 포인터를 읽어 줌과 동시에 이 멤버에 속한 멤버를 바로 액세스할 수 있도록 중계하는 역할을 한다.
-> 연산자는 보통 스마트 포인터라 불리는 포인터를 흉내내는 클래스를 만들기 위해 사용되며 포인터의 유효성 점검이나 사용 카운트 유지 기능을 구현한다. 어떤 객체를 래핑하는 클래스를 만들 때 래핑한 객체가 래핑된 객체인 것처럼 동작해야 하므로 -> 연산자로 래핑된 객체의 멤버를 바로 액세스할 수 있어야 하는 것이다.
28-3-사.() 연산자
()도 함수를 호출하는 일종의 연산자이다. 다른 연산자와는 달리 항의 개수가 정해져 있지 않다는 것이 특징인데 호출하는 함수에 따라 이항일 수도 있고 단항일 수도 있고 세 개 이상의 인수를 취할 수도 있다. 그래서 오버로딩할 때도 인수의 개수를 원하는대로 취할 수 있고 인수의 개수나 타입이 다르면 얼마든지 오버로딩 가능하며 인수에 디폴트값을 지정할 수도 있다. 다음 클래스는 전달된 인수들의 합을 구하는 () 연산자를 두 개 정의한다.
예 제 : FunCallOp
|
#include <Turboc.h>
class Sum
{
public:
int operator()(int a,int b,int c,int d) {
return a+b+c+d;
}
double operator()(double a,double b) {
return a+b;
}
};
void main()
{
Sum S;
printf("1+2+3+4=%d\n",S(1,2,3,4));
printf("1.2+3.4=%f\n",S(1.2,3.4));
}
멤버 변수는 가지지 않으며 정수 4개의 합을 구하는 (), 실수 2개의 합을 구하는 () 연산자를 정의했다. main에서 Sum 타입의 객체 S를 선언하고 이 객체로부터 ()연산자를 호출한다. S(...) 문장은 생성자를 호출하는 것이 아니라 이미 만들어진 객체에 대해 호출하는 것이므로 연산자 함수 호출문이다. ()연산자는 객체와 직접적인 연관이 없을 수도 있으므로 만들어진 객체가 없어도 임시 객체로부터 호출하기도 한다. 다음과 같이 수정해도 잘 동작한다.
printf("1+2+3+4=%d\n",Sum()(1,2,3,4));
printf("1.2+3.4=%f\n",Sum()(1.2,3.4));
Sum()는 Sum 클래스의 디폴트 생성자를 호출하는 문장이며 이 호출로부터 이름이 없는 임시 객체가 하나 생성되고 이 객체의 멤버 함수 ()를 호출했다. 물론 이 임시 객체는 함수가 종료될 때 자동으로 파괴될 것이다. () 괄호가 두 개나 있어 다소 헷갈린다. 이 연산자의 좌변은 항상 호출 객체이므로 전역 함수로는 정의할 수 없으며 반드시 멤버 함수로만 정의해야 한다.
다음은 좀 더 실용적인 예제를 보자. ()연산자는 피연산자를 원하는만큼 줄 수 있다는 것이 장점인데 원하는 값을 찾기 위해 여러 개의 입력이 필요한 경우에 유용하다. 다음 예제는 성적을 관리하는 클래스를 정의하는데 성적값 하나를 알기 위해서는 학년, 학급, 학생, 과목 4가지나 되는 인수가 필요하다. C언어에 4항 연산자는 없으므로 이런 동작은 괄호 연산자로만 정의할 수 있다.
예 제 : ScoreManager
|
#include <Turboc.h>
class ScoreManager
{
private:
// 성적을 저장하는 여러 가지 멤버 변수들
int ar[3][5][10][4];
public:
ScoreManager() { memset(ar,0,sizeof(ar)); }
int &operator()(int Grade,int Class,int StNum,const char *Subj) {
return ar[Grade][Class][StNum][0];
}
const int &operator()(int Grade,int Class,int StNum,const char *Subj) const {
return ar[Grade][Class][StNum][0];
}
};
void main()
{
ScoreManager SM;
printf("1학년 2반 3번 학생의 국어 성적 = %d\n",SM(1,2,3,"국어"));
SM(2,3,4,"산수")=99;
}
ScoreManager 클래스에는 실제 성적을 저장하는 자료 구조와 이 성적을 처리하는 수많은 멤버 함수들이 정의되어 있을 것이다. () 연산자는 정수형의 학년, 학급, 출석 번호와 문자열로 된 과목 이름을 인수로 전달받아 내부 자료로부터 원하는 성적을 조사해서 출력한다. 예제는 간결성을 위해 실제 성적을 관리하지도 않고 과목 문자열로부터 첨자를 찾지도 았았지만 () 연산자의 동작을 살펴보기에는 충분할 것이다. 성적은 좌우변에 모두 사용할 수 있으므로 상수 버전과 비상수 버전이 모두 필요하다.
클래스는 정보와 동작을 동시에 가질 수 있으므로 세상의 모든 사물을 다 흉내낼 수 있다. 포인터를 래핑할 수도 있고 함수를 래핑할 수도 있는데 함수를 그대로 흉내내는 클래스를 정의하고 싶을 때 이 연산자를 재정의한다. () 연산자를 정의하는 클래스를 함수 객체(Functor)라고 하는데 C++ 표준 라이브러리에서 일반화된 알고리즘의 동작에 변화를 주기 위해 흔히 사용된다.
28-3-아.new, delete
메모리를 동적으로 할당하고 객체를 초기화하는 new, delete도 연산자의 일종이므로 오버로딩할 수 있다. 객체를 힙에 할당하는 new 연산자는 두 가지 동작을 하는데 우선 운영체제의 힙 관리 함수를 호출하여 요청한만큼 메모리를 할당하고 이 할당된 메모리에 대해 객체의 생성자를 호출하여 초기화한다. new가 생성자를 호출하는 것은 언어의 고유한 기능이므로 사용자가 생성자 호출을 금지한다거나 할 수 없지만 객체를 위한 메모리를 할당하는 방식은 원하는대로 변경할 수 있다. 즉 new 연산자 자체는 오버로딩 대상이 아니지만 이 함수가 내부적으로 호출하는 operator new는 오버로딩 대상이다.
new연산자는 메모리 할당을 위해 operator new를 호출하는데 이 연산자를 오버로딩하면 객체를 위한 메모리를 직접 할당 또는 지정할 수 있다. new와 마찬가지로 delete 함수도 두 가지 동작을 하는데 파괴자를 먼저 호출하여 객체를 정리하고 다음으로 operator delete를 호출하여 객체가 사용하던 메모리를 해제한다. operator new를 오버로딩해서 할당 방식을 바꾸었다면 당연히 operator delete도 오버로딩해서 해제하는 방식도 할당 동작에 맞게 바꿔야 한다. 예를 들어 대량의 메모리를 효율적으로 관리하기 위해 가상 메모리를 직접 다루고 싶다거나 미리 할당해 놓은 메모리 풀을 조금씩 돌려 가며 사용하고 싶을 때가 이런 경우에 해당한다.
Win32의 가상 메모리는 예약과 확정이라는 두 단계의 메모리 할당 방식이 있고 각각의 메모리 페이지에 대해 읽기, 쓰기 권한을 지정할 수 있어 할당 속도가 빠르고 안전성이 높아 직접 관리할 경우 힙을 쓰는 것보다 더 효율적이다. 특히 객체의 크기가 클 때 효과적이다. 또한 할당, 해제가 아주 빈번하다면 충분한 크기의 메모리 큐를 만들고 응용 프로그램이 메모리를 회전시키는 방법도 쓸 수 있다. 힙 할당은 매 할당분마다 얼마만큼의 메모리가 할당되었는지를 기억하는 헤더를 작성하는데 이 헤더에 의한 메모리 낭비가 심하며 또한 잦은 할당 해제에 의해 단편화 문제가 발생하는데 할당 방식을 바꿈으로써 이런 문제들을 적극적으로 해결할 수 있다.
물론 이런 경우는 어디까지나 예에 불과하며 현실적인 실용성은 그리 높지 않은 편인데 물리적인 메모리양이 충분해졌을 뿐만 아니라 운영체제의 메모리 관리 능력이 뛰어나기 때문에 이렇게까지 메모리를 직접 관리해야 할 필요는 많이 감소되었다. 하지만 메모리란 아무리 많아도 아껴 써야 하는 것이므로 아직도 대용량의 많은 메모리를 다루는 프로그램은 이 기법이 꼭 필요하다. 다음 예제를 통해 전역 new, delete 연산자를 오버로딩해 보자.
예 제 : newOverload
|
#include <Turboc.h>
void *operator new(size_t t)
{
return malloc(t);
}
void operator delete(void *p)
{
free(p);
}
void main()
{
int *pi=new int;
*pi=1234;
printf("%d\n",*pi);
delete pi;
}
operator new 는 size_t형 인수와 그 외 할당에 필요한 인수를 전달받아 메모리를 할당하며 그 결과 새로 할당한 메모리의 번지를 void *타입으로 리턴한다. 단순히 메모리만 할당할 뿐이므로 아직 이 메모리의 타입은 알 수 없으며 그래서 void *를 리턴할 수밖에 없다. 할당의 방식은 마음대로 선택할 수 있고 할당 방식을 지정하는 추가 인수도 얼마든지 받을 수 있되 단 첫 번째 인수는 반드시 할당 크기를 지정하는 size_t여야 한다. operator delete 는 void *를 인수로 받아 이 메모리를 해제하며 리턴값은 없다.
예제에서는 new, delete를 각각 오버로딩해 놓고 main에서 정수형 변수 하나를 저장할만큼인 4바이트를 할당해 보았다. 오버로딩 가능하다는 것만 확인하기 위해 malloc, free를 사용했는데 미리 할당한 메모리의 조각을 떼 주거나 다른 메모리 할당 함수를 사용하는 것도 가능하다. new, delete 함수에 중단점을 설정해 놓고 테스트해보면 할당, 해제할 때 이 함수가 호출되는 것을 확인할 수 있다. 이 예제는 VC 6.0에서는 컴파일되지 않으며 VC 7.0 이상, Dev-C++에서는 잘 컴파일된다.
객체의 배열을 할당 및 해제하는 new [], delete [] 도 물론 오버로딩할 수 있다. 이 예제처럼 new, delete를 전역 연산자로 오버로딩할 수도 있고 특정 클래스의 멤버 함수로 오버로딩하여 특정 클래스에 대해서만 할당 방식을 변경할 수도 있다. 문법의 복잡성에 비해 실용성은 다소 떨어지므로 이 방법에 대한 상세한 설명은 생략하기로 한다. 이런 메모리 할당 기법을 정확하게 구사하기 위해서는 연산자 오버로딩 자체에 대한 이해보다는 메모리 구조나 관리 기법에 대한 이해가 더 많이 필요하다.
28-4.문자열 클래스
28-4-가.Str 클래스
연산자 오버로딩의 종합 실습편으로 문자열을 관리하는 Str 클래스를 작성해 보자. C는 문자열을 기본 타입으로 제공하지 않고 문자형 배열로 표현하기 때문에 대입, 연결, 비교, 추가 등의 모든 연산을 함수로만 해야 한다. 기본 타입과 논리적으로 같은 연산을 하는데도 불구하고 연산자를 쓸 수 없어 무척 불편할 뿐만 아니라 배열의 경계를 넘어서는 위험성을 항상 가지고 있다.
그래서 C++에서는 보통 문자열을 클래스로 작성하는데 이 클래스는 문자열 표현에 필요한 모든 멤버를 포함하고 있으며 문자열의 길이에 따라 배열을 자동으로 늘리는 편리한 기능까지 가지고 있다. 또한 다양한 연산자를 문자열에 대해 직접 사용할 수 있도록 하여 기본 타입과 똑같은 방법으로 문자열을 다룰 수 있다.
여기서 만들어 볼 문자열 클래스는 어디까지나 실습용으로서 의미가 있을 뿐이지 실제 사용을 목적으로 하기에는 성능상으로 보나 제공하는 기능으로 보나 많이 부족하다. 우리가 직접 이런 클래스를 만들지 않더라도 더 잘 만들어진 문자열 클래스들이 얼마든지 있는데 STL은 string을, MFC는 CString을 각각 제공하고 있다. 실무에서는 이런 잘 만들어진 검증된 클래스를 쓰되 이런 클래스들의 내부가 어떠할지를 이 실습을 통해 연구해 보도록 하자.
실습용으로 만들 클래스의 이름은 Str이라는 짧은 이름으로 정했다. 다음 소스에는 Str 클래스의 선언과 멤버 함수, 연산자 함수의 모든 코드가 포함되어 있으며 main에는 이 클래스가 잘 동작하는지를 테스트하는 코드도 포함되어 있다. 원칙대로 하자면 헤더 파일과 구현 파일을 나누어야 하지만 실습의 편의성을 위해 한 모듈에 모든 소스를 작성했다.
예 제 : Str
|
#include <Turboc.h>
#include <iostream>
using namespace std;
class Str
{
friend ostream &operator <<(ostream &c, const Str &S);
friend const Str operator +(const char *ptr,Str &s);
friend bool operator ==(const char *ptr,Str &s);
friend bool operator !=(const char *ptr,Str &s);
friend bool operator >(const char *ptr,Str &s);
friend bool operator <(const char *ptr,Str &s);
friend bool operator >=(const char *ptr,Str &s);
friend bool operator <=(const char *ptr,Str &s);
protected:
char *buf;
int size;
public:
Str();
Str(const char *ptr);
Str(const Str &Other);
explicit Str(int num);
virtual ~Str();
int length() const { return strlen(buf); }
Str &operator =(const Str &Other);
Str &operator +=(Str &Other);
Str &operator +=(const char *ptr);
char &operator [](int idx) { return buf[idx]; }
const char &operator [](int idx) const { return buf[idx]; }
operator const char *() { return (const char *)buf; }
operator int() { return atoi(buf); }
const Str operator +(Str &Other) const;
const Str operator +(const char *ptr) const { return *this+Str(ptr); }
bool operator ==(Str &Other) { return strcmp(buf,Other.buf)==0; }
bool operator ==(const char *ptr) { return strcmp(buf,ptr)==0; }
bool operator !=(Str &Other) { return strcmp(buf,Other.buf)!=0; }
bool operator !=(const char *ptr) { return strcmp(buf,ptr)!=0; }
bool operator >(Str &Other) { return strcmp(buf,Other.buf)>0; }
bool operator >(const char *ptr) { return strcmp(buf,ptr)>0; }
bool operator <(Str &Other) { return strcmp(buf,Other.buf)<0; }
bool operator <(const char *ptr) { return strcmp(buf,ptr)<0; }
bool operator >=(Str &Other) { return strcmp(buf,Other.buf)>=0; }
bool operator >=(const char *ptr) { return strcmp(buf,ptr)>=0; }
bool operator <=(Str &Other) { return strcmp(buf,Other.buf)<=0; }
bool operator <=(const char *ptr) { return strcmp(buf,ptr)<=0; }
void Format(const char *fmt,...);
};
// 디폴트 생성자
Str::Str()
{
size=1;
buf=new char[size];
buf[0]=0;
}
// 문자열로부터 생성하기
Str::Str(const char *ptr)
{
size=strlen(ptr)+1;
buf=new char[size];
strcpy(buf,ptr);
}
// 복사 생성자
Str::Str(const Str &Other)
{
size=Other.length()+1;
buf=new char[size];
strcpy(buf,Other.buf);
}
// 정수형 변환 생성자
Str::Str(int num)
{
char temp[128];
itoa(num,temp,10);
size=strlen(temp)+1;
buf=new char[size];
strcpy(buf,temp);
}
// 파괴자
Str::~Str()
{
delete [] buf;
}
// 대입 연산자
Str &Str::operator =(const Str &Other)
{
if (this != &Other) {
size=Other.length()+1;
delete [] buf;
buf=new char[size];
strcpy(buf,Other.buf);
}
return *this;
}
// 복합 연결 연산자
Str &Str::operator +=(Str &Other)
{
char *old;
old=buf;
size+=Other.length();
buf=new char[size];
strcpy(buf,old);
strcat(buf,Other.buf);
delete [] old;
return *this;
}
Str &Str::operator +=(const char *ptr)
{
return *this+=Str(ptr);
}
// 연결 연산자
const Str Str::operator +(Str &Other) const
{
Str T;
delete [] T.buf;
T.size=length()+Other.length()+1;
T.buf=new char[T.size];
strcpy(T.buf,buf);
strcat(T.buf,(const char *)Other);
return T;
}
// 출력 연산자
ostream &operator <<(ostream &c, const Str &S)
{
c << S.buf;
return c;
}
// 더하기 및 관계 연산자
const Str operator +(const char *ptr,Str &s) { return Str(ptr)+s;}
bool operator ==(const char *ptr,Str &s) { return strcmp(ptr,s.buf)==0;}
bool operator !=(const char *ptr,Str &s) { return strcmp(ptr,s.buf)!=0;}
bool operator >(const char *ptr,Str &s) { return strcmp(ptr,s.buf)>0;}
bool operator <(const char *ptr,Str &s) { return strcmp(ptr,s.buf)<0;}
bool operator >=(const char *ptr,Str &s) { return strcmp(ptr,s.buf)>=0;}
bool operator <=(const char *ptr,Str &s) { return strcmp(ptr,s.buf)<=0;}
// 서식 조립 함수
void Str::Format(const char *fmt,...)
{
char temp[1024];
va_list marker;
va_start(marker, fmt);
vsprintf(temp,fmt,marker);
*this=Str(temp);
}
void main()
{
Str s="125";
int k;
k=(int)s+123;
Str s1("문자열"); // 문자열로 생성자
Str s2(s1); // 복사 생성자
Str s3; // 디폴트 생성자
s3=s1; // 대입 연산자
// 출력 연산자
cout << "s1=" << s1 << ",s2=" << s2 << ",s3=" << s3 << endl;
cout << "길이=" << s1 << endl;
// 정수형 변환 생성자와 변환 연산자
Str s4(1234);
cout << "s4=" << s4 << endl;
int num=int(s4)+1;
cout << "num=" << num << endl;
// 문자열 연결 테스트
Str s5="First";
Str s6="Second";
cout << s5+s6 << endl;
cout << s6+"Third" << endl;
cout << "Zero"+s5 << endl;
cout << "s1은 "+s1+"이고 s5는 "+s5+"이다." << endl;
s5+=s6;
cout << "s5와 s6을 연결하면 " << s5 << "이다." << endl;
s5+="Concatination";
cout << "s5에 문자열을 덧붙이면 " << s5 << "이다." << endl;
// 비교 연산자 테스트
if (s1 == s2) {
cout << "두 문자열은 같다." << endl;
} else {
cout << "두 문자열은 다르다." << endl;
}
// char *형과의 연산 테스트
Str s7;
s7="상수 문자열";
cout << s7 << endl;
char str[128];
strcpy(str,s7);
cout << str << endl;
// 첨자 연산자 테스트
Str s8("Index");
cout << "s8[2]=" << s8[2] << endl;
s8[2]='k';
cout << "s8[2]=" << s8[2] << endl;
// 서식 조립 테스트
Str sf;
int i=9876;
double d=1.234567;
sf.Format("서식 조립 가능하다. 정수=%d, 실수=%.2f",i,d);
cout << sf << endl;
}
실행 결과는 다음과 같다. 나름대로 기능이 많기 때문에 이것 저것 테스트해 볼 게 많은 편인데 모두 정확하게 잘 동작한다.
s1=문자열,s2=문자열,s3=문자열
길이=문자열
s4=1234
num=1235
FirstSecond
SecondThird
ZeroFirst
s1은 문자열이고 s5는 First이다.
s5와 s6을 연결하면 FirstSecond이다.
s5에 문자열을 덧붙이면 FirstSecondConcatination이다.
두 문자열은 같다.
상수 문자열
상수 문자열
s8[2]=d
s8[2]=k
서식 조립 가능하다. 정수=9876, 실수=1.23
main 함수의 주석에 적힌대로 Str 클래스가 가진(얼마 되진 않지만) 모든 기능을 순서대로 불러 보고 제대로 동작하는지 테스트해 보았다.
28-4-나.메모리 관리
Str 클래스의 멤버 변수는 단 두 개밖에 없다. 동적인 길이를 가지는 문자열을 표현해야 하므로 일단 문자형 포인터가 필요한데 buf멤버가 이런 역할을 한다. 생성자에서 문자열의 길이만큼 메모리를 동적으로 할당해서 buf에 그 번지를 대입할 것이며 추가, 연결할 때는 버퍼를 자동으로 늘린다. size는 할당된 메모리양을 기억하되 사실 이 변수는 꼭 필요하지 않다. 문자열의 길이에 널 종료 문자분을 더하면 버퍼 길이는 언제나 구할 수 있되 여유분을 더 할당한다거나 할 때는 길이에 대한 정보가 필요해지므로 미리 포함시켜 둔 것이다.
혹시라도 이 클래스로부터 상속을 받고자 할 때를 대비해서 이 두 변수는 protected 액세스 속성으로 지정했다. 외부에서는 이 멤버 변수를 직접 조작할 수 없도록 숨겨지지만 상속된 클래스에서는 이 두 변수를 자유롭게 액세스할 수 있다. 두 변수는 물론 생성자에서 초기화되는데 Str은 초기화 방법에 따라 다양한 생성자를 제공한다.
인수를 취하지 않는 디폴트 생성자는 1바이트만 할당하고 빈 문자열로 초기화한다. 객체를 만든 후 곧바로 출력할 수도 있기 때문에 buf를 NULL로 초기화한다거나 해서는 안되며 빈 문자열이라도 가지고 있어야 한다. 빈 문자열이라도 있지 않으면 출력할 때마다 메모리가 할당되어 있는지 점검해야 하므로 불편해진다. 가장 자주 사용하는 생성자는 const char *형 인수를 받아들여 문자열 상수로부터 객체를 초기화하는 생성자이다. 이 생성자는 문자열의 길이에 널 종료 문자만큼 더해 buf를 할당하고 ptr의 문자열을 buf에 복사한다.
동적으로 할당되는 버퍼를 사용하므로 복사 생성자를 반드시 정의해야 한다. 그렇지 않으면 객체끼리 대입할 때 디폴트 복사 생성자가 얕은 복사를 하므로 같은 버퍼를 두 객체가 가리키게 될 것이다. 복사 생성자는 인수로 전달된 Other 객체와 같은 길이의 메모리를 할당하고 그 내용을 자신에게 복사한다. 이 외에 정수형 변환 생성자가 정의되어 있는데 충분한 길이의 임시 버퍼에 정수를 문자열로 변환해 넣은 다음에 이 문자열로부터 객체를 생성했다.
모든 생성자가 초기 문자열의 길이만큼 동적 할당을 하므로 파괴자는 반드시 이 메모리를 해제해야 한다. 또한 대입 연산자도 정의해야 하는데 대입은 실행중에 언제든지 일어날 수 있으므로 메모리를 무조건 할당하는 것이 아니라 쓰던 메모리를 먼저 반납하고 새로 할당해야 한다. 생성자 외에 문자열의 길이를 변경하는 =, +=, Format 함수에서도 버퍼의 길이를 동적으로 관리한다.
28-4-다.타입 변환
cout으로 문자열을 출력하기 위해 << 연산자를 정의하여 프렌드로 등록했다. buf문자열을 cout으로 보내고 연쇄적으로 출력할 수 있도록 지침대로 스트림 객체의 레퍼런스를 리턴한다. 문자열의 길이를 조사하는 length 함수는 간단하므로 인라인으로 정의했는데 strlen 함수로 buf의 길이만 조사하면 된다. 이 클래스의 경우 return size-1로 더 간단하게 길이를 조사할 수 있는 방법이 있기는 하지만 차후의 확장성을 고려하여 직접 길이를 조사하도록 했다. size 멤버는 할당된 메모리의 양을 기억하는 역할을 하므로 차후에는 문자열 길이와 상관없는 값을 가질 수도 있다.
Str 클래스는 문자열이나 정수로부터 객체를 생성하는 생성자를 제공하므로 char *나 int 타입으로부터 초기화할 수 있다. 그렇다면 반대로 Str 객체를 문자형 포인터나 정수형으로 변환하는 변환 함수도 당연히 제공해야 할 것이다. 특히 Str을 문자형 포인터로 변환하는 기능은 꼭 필요한데 지금까지 만들어진 많은 함수들이 문자형 포인터를 요구하고 있기 때문이다. Str이 가진 문자열로부터 이런 함수를 호출하려면 Str을 문자형 포인터로 바꾸는 변환 함수가 있어야 한다.
Str 객체는 const char *와 int 두 개의 변환 함수를 제공하는데 이 두 연산자에 의해 Str이 잠시 정수형이 되거나 문자형 포인터가 될 수 있다. 그래서 다음 코드는 모두 정상적으로 컴파일되고 잘 실행된다.
Str s="125";
int k;
k=(int)s+123;
Str t="String";
char *p=strchr((const char *)t,'r');
Str 객체에 정수 형태의 문자열이 들어 있다면 (int) 캐스트 연산자로 정수를 추출할 수 있다. 이 변환 연산자는 atoi 함수로 문자열을 정수로 바꿔서 리턴한다. const char * 연산자는 Str 객체의 buf 멤버 주소를 리턴하는데 여기에는 객체가 표현하는 문자열이 들어 있으므로 이 포인터만 알면 문자열을 바로 사용할 수 있다. 단, 객체 외부에서 이 버퍼를 함부로 조작해서는 안되므로 반드시 상수 지시 포인터를 리턴해야 하며 타입이 맞는 위치에만 쓸 수 있다.
[ ] 연산자는 배열상의 한 요소에 대한 레퍼런스를 리턴하는데 Str 객체를 마치 문자형 배열과 같은 방법으로 사용할 수 있다. 레퍼런스를 리턴하므로 이 연산자로 요소를 읽는 것은 물론이고 직접 변경하는 것도 가능하다. 단, 상수 객체일 경우는 상수 버전의 [ ] 연산자 함수로 읽는 것만 가능하며 요소값을 변경하지는 못한다. [ ] 연산자의 본체는 buf에 첨자 연산을 한 결과를 바로 리턴하도록 되어 있다. 그래서 문자형 배열에서와 마찬가지로 첨자 범위를 점검하지 못하는 한계를 가지고 있는데 이 문제를 해결하려면 if문으로 배열의 범위를 점검하기만 하면 된다.
댓글 없음:
댓글 쓰기