본문 바로가기

프로그래밍 언어들/C++

CHAPTER11 - 연산자 오버로딩 2

1. 반드시 해야 하는 대입 연산자의 오버로딩

이번에 설명하는 대입 연산자의 오버로딩은 그 성격이 복사 생성자와 매우 유사하다. 따라서 복사 생성자에 대한 이해를

바탕으로 대입 연산자를 이해하기 바란다.


1.1 객체간 대입연산의 비밀: 디폴트 대입 연산자

다음은 이전에 설명한 복사 생성자의 대표적인 특성이다.

- 정의하지 않으면 디폴트 복사 생성자가 삽입된다.

- 디폴트 복사 생성자는 멤버 대 멤버의 복사(얕은 복사)를 진행한다.

- 생성자 내에서 동적 할당을 한다면, 그리고 싶은 복사가 필요하다면 직접 정의해야 한다.


그리고 다음은 이어서 설명할 대입 연산자의 대표적인 특성이다.

- 정의하지 않으면 '디폴트 대입 연산자'가 삽입된다.

- 디폴트 대입 연산자는 멤버 대 멤버의 복사(얕은 복사)를 진행한다.

- 연산자 내에서 동적 할당을 한다면, 그리고 깊은 복사가 필요하다면 직접 정의해야 한다.


둘은 정말 유사하지만, 호출되는 시점에는 차이가 있다. 복사 생성자가 호출되는 대표적인 시점은 다음과 같다.

    Point pos1(5, 10);
    Point pos2  = pos1;


여기서 중요한 사실은 새로 생성하는 객체 pos2의 초기화에 기존에 생성된 객체 pos1이 사용되었다는 점이다.

다음은 대입 연산자가 호출되는 대표적인 상황이다.

    Point pos1(5, 10);
    Point pos2(10, 15);

    pos1    = pos2;


여기서 중요한 사실은, '두 객체가 이미 생성 및 초기화가 진행된 객체'이다. 즉, 기존에 생성된 두 객체간의

대입연산 시에는 대입 연산자가 호출된다.


1.2 디폴트 대입 연산자의 문제점

디폴트 대입 연산자의 문제점은, 디폴트 복사 생성자에서 보인 문제와 유사하고, 그 해결책도 매우 유사하다.

즉, 디폴트 대입 연산자는 멤버 대 멤버를 단순히 복사만 하므로, 하나의 메모리 공간을 두 객체가 동시에

참조하는 상황이 벌어지게 되며, 이로 인해 다음의 두 가지 문제가 발생하게 된다.

- 원래 가르키던 메모리 공간의 주소 값을 잃게 된다.

- 얕은 복사로 인해, 객체 소멸과정에서 지워진 공간을 중복 소멸하는 문제가 발생한다.

즉, 위와 같은 문제를 해결하기 위해 다음의 형태로 직접 대입 연산자를 정의해야 한다.

- 깊은 복사를 진행하도록 정의한다.

- 메모리 누수가 발생하지 않도록, 깊은 복사에 앞서 메모리 해제의 과정을 거친다.


1.3 상속 구조에서의 대입 연산자 호출

대입 연산자는 생성자가 아니다. 유도 클래스의 생성자에는 아무런 명시를 하지 않아도 기초 클래스의 생성자가 호출되지만,

'유도 클래스의 대입 연산자에는 아무런 명시를 하지 않으면, 기초 클래스의 대입 연산자가 호출되지 않는다.'

class A
{
public:
    A& operator=(const A& ref)
    {
        cout << "called A& operator=" << endl;
       
        return *this;
    }
};
class B : public A
{
public:
    /*B& operator=(const B& ref)
    {
        cout << "called B& operator=" << endl;

        return *this;
    }*/

};
int main(void)
{
    B obj1;
    B obj2;

    obj1 = obj2;

    return 0;
}
called A& operator=


위의 실행결과는, 유도 클래스에 삽입된 디폴트 대입 연산자가 기초 클래스의 대입 연산자까지 호출한다는 사실을

증명하고 있다. 그렇다면 이번에는 예제의 주석을 해제하고 다시 컴파일 및 실행해보자.

called B& operator=


위의 결과를 통해서, '유도 클래스의 대입 연산자 정의에서, 명시적으로 기초 클래스의 대입 연산자 호출문을

삽입하지 않으면, 기초 클래스의 대입 연산자가 호출되지 않는다.' 는 것을 알 수 있다.

따라서 기초 클래스의 멤버변수는 멤버 대 멤버의 복사 대상에서 제외된다.

따라서, 유도 클래스의 대입 연산자는 다음과 같이 정의되어야 한다.

public:
    B& operator=(const B& ref)
    {
        cout << "called B& operator=" << endl;
        A::operator=(ref);
        return *this;
    }



1.4 성능 향상에 도움을 주는 이니셜라이저

class A
{
private:
    int num;
public:
    A(int n = 0): num(n)
    {
        cout << "called A(int)" << endl;
    }
    A(const A& ref): num(ref.num)
    {
        cout << "called A(const A&)" << endl;
    }
    A& operator=(const A& ref)
    {
        cout << "called A& operator=" << endl;

        return *this;
    }
};
class B
{
private:
    A mem;
public:
    B(const A& ref) : mem(ref) {}
};
class C
{
private:
    A mem;
public:
    C(const A& ref)
    {
        mem = ref;
    }
};
int main(void)
{
    A obj1(10);
    B obj2(obj1);
    C obj3(obj1);

    return 0;
}
called A(int)
called A(const A&)
called A(int)
called A& operator=


실행결과를 보면, B 객체의 생성과정에서는 복사 생성자만 호출되었는데, C 객체의 생성 과정에서는

생성자와 대입 연산자까지 호출되었다.

"이니셜라이저를 이용하면 선언과 동시에 초기화가 이뤄지는 형태로 바이너리 코드가 생성된다."
그러나, 이니셜라이저를 이용하지 않으면, 대입 연산을 통해 초기화가 진행되므로 생성자와 대입 연산자가

각각 한 번씩 호출된 것이다.


2. 배열의 인덱스와 연산자 오버로딩

이번에는 배열요소에 접근할 때 사용하는 [] 연산자를 오버로딩 하고자 한다. 이는 다른 연산자들과 달리

피연산자가 연산자 기호 안으로 들어가기 때문에 다소 어색하거나 생소할 수 있다.


2.1 배열보다 나은 배열 클래스

배열 클라스라는 것은 '배열의 역할을 하는 클래스'를 뜻한다. 이에 앞서 배열요소의 접근에

사용되는 [] 연산자의 오버로딩에 대해서 조금 정리할 필요가 있다. 다음 문장을 보자.

=> arrObject[2];

이는 다음과 같이 해석이 된다.

- 객체 arrObject의 멤버함수 호출로 이어진다.

- 연산자가 [] 이므로 멤버함수의 이름은 'operator []' 이다.

- 함수호출 시 전달되는 인자의 값은 정수 2이다.

그렇다면 함수의 이름이 'operator[]'이고, 인자로 전달되는 값이 정수 2이니,

다음의 형태로 정의된 멤버함수의 호출이라는 사실 정도는 파악이 된다.

=> int operator[] (int idx) {}

따라서 다음과 같이 해석된다.

=> arrObject.operator[](2);

class BoundCheckIntArray
{
private:
    int *arr;
    int arrlen;
public:
    BoundCheckIntArray(int len) : arrlen(len)
    {
        arr = new int[len];
    }
    int& operator[](int idx)
    {
        if(idx < 0 || idx >= arrlen)
        {
            cout << "Array index out of bound exception" << endl;
            exit(1);
        }
        return arr[idx];
    }
    ~BoundCheckIntArray(void)
    {
        delete []arr;
    }
};
int main(void)
{
    BoundCheckIntArray arr(5);
    for(int i = 0 ; i < 5 ; i ++)
    {
        arr[i]  = (i+1) * 11;
    }
    for(int i = 0 ; i < 6 ; i ++)
    {
        cout << arr[i] << endl;
    }

    return 0;
}
11
22
33
44
55
Array index out of bound exception


위 유형의 클래스 정의를 통해서 배열접근의 안정성을 보장받을 수 있다.

또한 복사 생성자와 대입 연산자를 'private'으로 선언해서, 복사 또는 대입을 원천적으로 막을 수 있다.

저장소에 저장된 데이터는 '유일성'이 보장되어야 하기 때문에, 대부분의 경우 저장소의 복사는 불필요하거나

잘못된 일로 간주된다. 따라서 복사와 대입을 원천적으로 막는 것이 좋은 선택이 되기도 한다.


2.2 const 함수를 이용한 오버로딩의 활용

#include <iostream>
#include <cstring>
using namespace std;

class BoundCheckIntArray
{
private:
    int *arr;
    int arrlen;
public:
    BoundCheckIntArray(int len) : arrlen(len)
    {
        arr = new int[len];
    }
    int& operator[](int idx)
    {
        if(idx < 0 || idx >= arrlen)
        {
            cout << "Array index out of bound exception" << endl;
            exit(1);
        }
        return arr[idx];
    }
    //int& operator[](int idx) const
    //{
    //  if(idx < 0 || idx >= arrlen)
    //  {
    //      cout << "Array index out of bound exception" << endl;
    //      exit(1);
    //  }
    //  return arr[idx];
    //}
    int getarrlen(void) const { return arrlen; }
    ~BoundCheckIntArray(void)
    {
        delete []arr;
    }
};

void showalldata(const BoundCheckIntArray& ref)
{
    int len = ref.getarrlen();

    for(int idx = 0 ; idx < len ; idx ++)
    {
        cout << ref[idx] << endl;
    }
}

int main(void)
{
    BoundCheckIntArray arr(5);
    for(int i = 0 ; i < 5 ; i ++)
    {
        arr[i]  = (i+1) * 11;
    }

    showalldata(arr);

    return 0;
}


위의 예제는 객체의 배열에 저장된 모든 요소를 출력하는 'showalldata()' 함수가 추가 되었다.
그리고 이 함수의 매개 변수는 'const BoundCheckIntArray& ref' 라고 선언되었다.

함수 내에서 배열에 저장된 데이터를 변경하지 못하도록 하기 위함인데, 이는 매우 좋은 선언이다.

그러나 문제는 이 함수 내에서 'ref[idx]', 즉 'ref.operator[](idx)' 부분에서 컴파일 에러가 발생한다.

이는 const 함수가 아니기 때문이다. 방법으로는 위의 함수를 const 선언을 추가하면 되긴 하지만,

그렇게하면 배열을 멤버로 선언하는 경우에는 저장 자체가 불가능해진다.

따라서 위의 예제의 주석 부분처럼, const 선언을 통해 함수를 오버로딩 한다.

"const의 선언유무도 함수 오버로딩의 조건에 해당합니다."


2.3 객체의 저장을 위한 배열 클래스의 정의

- 객체를 저장하는 배열 기반의 클래스

- 객체의 주소 값을 저장하는 배열 기반의 클래스

즉, 저장의 대상이 객체이냐, 아니면 객체의 주소 값이냐에 차이가 있는 것이다.

첫 번째 방식은 객체의 저장이 객체간의 대입연산을 기반으로 한다. 따라서 두 번째 방식을

보다 많이 사용한다.

class BoundCheckIntArray
{
private:
    Point *arr;
    // ............. //
};
int main(void)
{
    BoundCheckIntArray arr(3);
   
    arr
[0] = Point(3, 4);
    arr[1] = Point(5, 6);
    arr[2] = Point(7, 8);

    return 0;
}
typedef Point * POINT_PTR
class BoundCheckIntArray
{
private:
    POINT_PTR *arr;
    // ............. //
};
int main(void)
{
    BoundCheckIntArray arr(3);
   
    arr[0] = new Point(3, 4);
    arr[1] = new Point(5, 6);
    arr[2] = new Point(7, 8);

    delete arr[0];
    delete arr[1];
    delete arr[2];

    return 0;
}


위의 예제는 각각 첫 번째와 두 번째 방식을 보여주고 있다. 두 번째 방식과 같이 객체의 생성과 소멸을 위해

'new'와 'delete' 연산 때문에 더 신경 쓸 것이 많아 보이지만, 깊은 복사냐 얕은 복사냐 하는 문제를 신경 쓰지 않아도

되기 때문에 이 방법이 더 많이 사용된다.


3. 그 이외의 연산자 오버로딩
new와 delete도 연산자이기 때문에 오버로딩이 가능하다. 또한 포인터 연산자를 오버로딩 하면서

개념적으로 어렵다고 이야기하는 '스마트 포인터(smart pointer)'와 '펑터(functor)'에 대해서도 간단히 설명하겠다.


3.1 new 연산자 오버로딩에 대한 상세한 이해

기본적으로 제공되는 new 연산자가 하는 일은 다음과 같다.

(1) 메모리 공간의 할당

(2) 생성자의 호출

(3) 할당하고자 하는 자료형에 맞게 반환된 주소 값의 형 변환

그렇다면 new 연산자의 오버로딩과 관련해서 C++ 컴파일러의 허용 범위를 보자.

"객체의 생성과정을 다소 복잡한 과정이니, new 연산자를 오버로딩 할 때는 메모리 공간의 할당만 책임을 져"

즉, 우리는 위에서 말한 new 연산자가 진행하는 세 가지 작업 중에서 (1)에 해당하는 작업만 오버로딩 할 수 있다.

new 연산자는 다음과 같이 오버로딩 하도록 이미 약속이 되어있다.

=> void* operator new (size_t size) {}

컴파일러는 Point 클래스를 대상으로 new 연산자가 오버로딩 된 상태에서 다음 문장을 만나면,

=> Point *ptr = new Point(3, 4);

먼저 필요한 메모리 공간을 계산하고, operator new 함수를 호출하면서, '계산된 크기의 값'을 인자로 전달한다.

중요한 것은 바이트 단위로 계산되어 전달된다는 점이다.

따라서 대략 다음과 같은 형태로 operator new 함수를 정의해야 한다.

void* operator new(size_t size)
{
    void* adr = new char[size];
    return adr;
}


이렇게 operator new 함수가 할당한 메모리 공간의 주소 값을 반환하면, 컴파일러는 생성자를 호출해서

메모리 공간을 대상으로 초기화를 진행한다. 그리고 마지막으로 완성된 객체의 주소 값을

Point 클래스의 포인터 형으로 형 변환해서 반환을 한다.

정리하면 new 연산자가 반환하는 값은 operator new 함수가 반환하는 값이 아니다.

operator new 함수가 반환하는 값은, 컴파일러에 의해 적절히 형 변환이 된 값이다.

또한 생성자의 호출정보는 operator new 함수와 아무런 상관이 없다. 생성자의 호출은 컴파일러의 몫이기 때문에

이 정보는 컴파일러에 의해서 참조될 뿐이다.


3.2 delete 연산자 오버로딩에 대한 상세한 이해와 예제

앞서 설명한 내용을 잘 이히했다면, delete 오버로딩에 대해서는 쉽게 이해할 수 있다.

void operator delete(void *adr)
{
    delete []adr;
}


참고로 사용하는 컴파일러에서 void 포인터 형 대상의 delete 연산을 허용하지 않는다면,

다음과 같이 작성하면 된다.

=> delete []((char*)adr);


#include <iostream>
#include <cstring>
using namespace std;

class Point
{
private:
    int xpos, ypos;
public:
    Point(const int& x, const int& y) : xpos(x), ypos(y) {}

    void* operator new(size_t size)
    {
        cout << "operator new : " << size << endl;
        void *adr   = new char[size];
        return adr;
    }
    void operator delete(void *adr)
    {
        cout << "operator delete() " << endl;
        delete []adr;
        // delete []((char*)adr);
    }
};

int main(void)
{
    Point *pos = new Point(5, 5);

    delete pos;
    return 0;
}
operator new : 8
operator delete()


실행 결과를 통해서 오버로딩 한 함수가 호출되는 것은 확인을 하였다.

객체가 생성된 상태가 아닌데 어떻게 멤버함수의 호출이 가능했을 지 생각해보자.

"operator new와 operator delete 함수가 static으로 선언된 함수이다."

즉, 객체생성의 과정에서 호출이 가능했던 것이다.


3.3 operator new & operator new[]

앞서 설명한 내용을 잘 이해했다면, 쉽게 이해할 수 있다.

new 연산자는 다음 두 가지의 형태로 오버로딩이 가능하다.

void* operator new(size_t size) {}
void* operator new[](size_t size) {}


첫 번째는 앞서 설명한 내용이고, 두 번째는 new 연산자를 이용한 배열 할당 시 호출되는 함수이다.

배열할당 시 호출되는 함수라는 사실을 제외하고는 두 함수는 차이가 없다.

마찬가지로 delete 연산자도 다음 두 가지 형태로 오버로딩이 가능하다.

void operator delete(void *adr){}
void operator delete[](void *adr){}


직접 new와 delete 연산자를 오버로딩 할 수 있어야만 의미가 있는 것은 아니다. 이 두 연산자들이

오버로딩이 가능하고, 또 어떻게 가능한지 이해하는 것 만으로 큰 의미가 있다.


3.4 포인터 연산자 오버로딩

포인터를 기반으로 하는 연산자를 모두 포인터 연산자라 한다. 대표적으로 다음 두 가지가 있다.

->  포인터가 가르키는 객체의 멤버에 접근

*    포인터가 가르키는 객체에 접근

이 두 연산자의 오버로딩은 일반적인 오버로딩과 크게 차이가 없다.

다만 둘 다 피 연산자가 하나인 단항 연산자의 형태로 오버로딩 된다는 특징만 기억하면 된다.

class Number
{
private:
    int num;
public:
    explicit Number(const int& n) : num(n) {}
    void showdata(void) { cout << num << endl; }

    Number* operator->(void)
    {
        return this;
    }
    Number& operator*(void)
    {
        return *this;
    }
};

int main(void)
{
    Number num(20);
    num.showdata();

    (*num).showdata();
    num->showdata();
    return 0;
}
20
20
20


위의 예제에서 다음 문장은,

(*num).showdata();

다음과 같이 해석된다.

(num.operator*()).showdata();

그러나, 다음 문장에

num->showdata();

일반적인 해석의 방법을 적용하면 다음과 같이 된다.

num.operator->() showdata();

즉, 문법적으로 성립하지 않는다. 따라서 '->' 연산자를 하나 더 추가하여, 다음과 같이 해석을 진행한다.

num.operator->() -> showdata();


3.5 스마트 포인터(Smart Pointer)

자기 스스로 하는 일이 아무 것도 없는 포인터와 달리, 스마트 포인터는 '자기 스스로 하는 일이 존재한다.'

사실 스마트 포인터는 객체이다. 포인터의 역할을 하는 객체를 뜻하는 것이다.


class SmartPtr
{
private:
    Point* posptr;
public:
    SmartPtr(Point* ptr) : posptr(ptr) {}
    Point& operator*(void) const
    {
        return *posptr;
    }
    Point* operator->(void) const
    {
        return postptr;
    }
    ~SmartPtr(void)
    {
        delete posptr;
    }
};


위의 예제를 보면, Point 객체를 가르키는 클래스를 정의 하였다. 가장 중요한 사실은,

Point 객체의 소멸을 위한 delete 연산이 자동으로 이뤄졌다는 사실이다. 그리고 이것이 바로 스마트 포인터의 똑똑함이다.

스마트 포인터는 전문 개발자들이 개발한 이후에도 오랜 시간 실무에 사용하면서 다듬어 가는 클래스이다.

보통은 개인적으로 구현해서 사용하는 경우는 드물며, 라이브러리의 일부로 포함되어 있는

스마트 포인터를 활용하는 경우가 대부분이다.


3.6 () 연산자의 오버로딩과 펑터(Functor)

함수의 호출에 사용되는, 인자의 전달에 사용되는 ()도 연산자이다. 객체의 이름이 addr이고

이 객체에 ()연산자가 멤버함수로 오버로딩 되어 있는 상태라면, 다음의 문장은 어떻게 해석이 되겠는가?

=> adder(2, 4);

우선 연산자가 ()이니 멤버함수의 이름은 operator()이다. 따라서 다음과 같이 해석된다.

adder.operator()(2, 4);

class Point
{
private:
    int xpos, ypos;
public:
    Point(const int& x, const int& y) : xpos(x), ypos(y) {}
    Point operator+(const Point& pos) const
    {
        return Point(xpos + pos.xpos, ypos + pos.ypos);
    }
    friend ostream& operator<<(ostream& os, const Point& pos);
};
ostream& operator<<(ostream& os, const Point& pos)
{
    os<<'['<<pos.xpos<<", "<<pos.ypos<<']'<<endl;
    return os;
}

class Adder
{
public:
    int operator()(const int& n, const int& m)
    {
        return n + m;
    }
    Point operator()(const Point& p1, const Point& p2)
    {
        return p1 + p2;
    }
};
int main(void)
{
    Adder adder;
    cout << adder(10, 20) << endl;
    cout << adder(Point(2,2), Point(4,4));

    return 0;
}
30
[6, 6]


위 예제에서 정의한 Adder 클래스와 같은 함수처럼 동작하는 클래스를 가르켜 '펑터(Functor)'라고 한다.

그리고 '함수 오브젝트(Function Object)'라고도 불린다.


3.7 펑터의 위력

펑터는 함수 또는 객체의 동작방식에 유연함을 제공할 때 주로 사용된다.

class SortRule
{
public:
    virtual bool operator()(int, int) const = 0;
};
class Ascending : public SortRule
{
public:
    bool operator()(int n, int m) const
    {
        if(n > m)
            return true;
        else
            return false;
    }
};
class Descending : public SortRule
{
public:
    bool operator()(int n, int m) const
    {
        if(n < m)
            return true;
        else
            return false;
    }
};
class DataStorage
{
public:
    void Sortdata(const SortRule& functor)
    {
        if(functor(x, y))
        {
        }
    }
};
int main(void)
{
    DataStorage ds;

    ds.Sortdata(Descending());
    ds.Sortdata(Ascending());
    return 0;
}


위 예제는 많은 부분을 생략하고, 핵심 부분만 옮긴 코드이다.

'void Sortdata()' 함수를 주목해보자. 매개변수 형이 SortRule의 참조형이므로,

SortRule 클래스를 상속하는 클래스들의 객체는 인자로 전달 가능하다. 그리고 SortRule의 operator() 함수는

virtual로 선언되었으니, 유도클래스의 operator() 함수가 대신 호출된다.

때문에 펑터로 무엇이 전달되느냐에 따라서 정렬의 기준이 바뀌게 된다.


3.8 임시객체로의 자동 형 변환과 형 변환 연산자(Conversion Operator)

두 객체의 자료형이 일치할 때에만 대입연산이 가능하다. 그렇다면 객체와 정수간의 대입연산도 가능할까?

class Number
{
private:
    int num;
public:
    Number(int n = 0) : num(n)
    {
        cout << "Number(int n = 0)" << endl;
    }
    Number& operator=(const Number& ref)
    {
        cout << "operator=()" << endl;
        num = ref.num;

        return *this;
    }
};
int main(void)
{
    Number num;
    num = 30;

    return 0;
}
Number(int n = 0)
Number(int n = 0)
operator=()


코드만 놓고 보면 안될 것 같지만, 이 역시 컴파일 및 실행에 무리가 없다.

실행 결과를 보면 어떻게 대입연산이 이뤄졌는지 판단이 될 것이다. 위의 예제에서 객체와 정수간의 대입은 아래와 같다.

=> num = Number(30);

=> num.operator=(Number(30));

즉, 임시객체의 생성을 통해서 대입연산이 진행된다.

기본 자료형 데이터를 객체로 형 변환하는 것은 적절한 생성자의 정의를 통해서 얼마든지 가능하다.

그렇다면 반대로 '객체를 기본 자료형 데이터로 형 변환' 하는 것도 가능할까? 물론 가능하다.


int main(void)
{
    Number num1;
    num1    = 30;

    Number num2 = num1 + 20;

    return 0;
}


위 예제의 'Number num2 = num1 + 20'의 연산이 가능하게 하려면, Number 클래스가 + 연산자를 오버로딩 하고 있거나,

num1이 int형 데이터로 변환되어야 한다. 전자의 경우는 이미 알고 있으니, 여기서는 int형 데이터로 변환 해보자.

    operator int(void)
    {
        return num;
    }


위와 같이 형 변환 연산자는 반환형을 명시하지 않는다. 하지만 'return'문에 의한 반환은 얼마든지 가능하다.

그리고 오버로딩 된 연산자의 이름이 operator + 이면, + 연산자가 등장했을 때 호출되는 것과 유사하게

operator int는 다음의 의미로 해석하면 된다.

"int형으로 형 변환해야 하는 상황에서 호출되는 함수이다."
그래서 다음 문장의 실행 과정에서,

=> Number num2 = num1 + 20;

num1 객체의 operator int 함수가 호출되어, 반환되는 정수 값과 20의 덧셈연산이 진행되어,

이 연산의 결과로 num2의 객체가 생성된 것이다.


출처 : 윤성우 열혈 C++ 프로그래밍