안녕하세요.

 

이번 글에서는 chapter 14를 정리해보려고 합니다!

 

 

 

 

 

Chapter 14.1 예외처리의 기본

 

 

이번 챕터에서는 예외처리의 기본적인 내용에 대해서 다룬다.

 

 

예제 코드 1

#include <iostream>
#include <fstream>
#include <string>
using namespace std;

int findFirstChar(const char* string, char ch)
{
    for (std::size_t index = 0; index < strlen(string); ++index)
        if (string[index] == ch)
            return index;

    return -1;
}

double divide(int x, int y, bool& success)
{
    if (y == 0)
    {
        success = false;
        return 0.0;
    }

    success = true;
    return static_cast<double>(x) / y;
}

int main()
{
	bool success;
    double result = divide(5, 3, success);

    if (!success)
    {
        std::cerr << "Error" << std::endl;
    }
    else
    {
        cout << "Result is " << result << endl;
    }

    std::ifstream input_file("temp.txt");
    if (!input_file)
        std::cerr << "Cannot open file" << std::endl;
        
    return 0;
}

 

전통적인 C++ 방식의 예외처리를 보여주는 코드이다.

 

findFirstChar 함수를 보면, for문을 이용해서 string의 각 index를 확인하며 ch와 같은지를 확인한 후 같으면 index를 return 해주고, 아니면 -1을 return 해준다.

 

즉, 매칭되는 문자열이 있으면 -1이 아닌 0 이상의 index가 return되고, 매칭되는 문자열이 없다면 -1이 return 되는 logic이다.

 

divide 함수를 보면, x를 y로 나눠주는데, y가 0인 경우는 success에 false를 대입하고 0.0을 return 한다.

 

y이 0이 아니라면 실제 x / y 값을 return 해주는 방식이다.

 

 

그럼 왜 전통적으로는 이런 방식으로 코딩을 했는가? 라고 한다면,

 

1) 퍼포먼스 때문에,

 

2) 다른 대체할 수 있는 문법이 마땅치 않았기 때문이다.

 

 

예외처리 챕터에서 배울 내용인 throw, catch, try 방식은 이러한 코드를 대체하려고 하는 것은 아니다.

 

예를 들어, 게임에서 갑자기 유저가 예측할 수 없는 반응을 하더라도 게임 서버에서는 이를 처리해야 하기 때문에 이럴 때 사용하는 것이다.

 

 

 

예제 코드 2

#include <iostream>
#include <fstream>
#include <string>
using namespace std;

int main()
{
    try
    {
        //throw - 1.0;
        throw "My Error Message!";

        // 딱 맞는 타입이 없는 경우 캐스팅을 하지 않고 runtime error를 내버린다 라는걸 기억해야 함.
    }
    catch (int x)
    {
        cout << "Catch integer " << x << endl;
    }
    catch (double x)
    {
        cout << "Catch double " << x << endl;
    }
    catch (const char* error_message)
    {
        cout << "Char * " << error_message << endl;
    }
    catch (std::string error_message)
    {
        cout << error_message << endl;
    }

    return 0;
}

 

 

 

이번 예제 코드에서 알아야할 내용은,

 

 

1) try, throw, catch의 구동 로직

2) 자료형 관련 이슈 이다.

 

 

우선, 예외처리를 적용해 주려면 try로 묶어줘야 하고 try로 묶여 있는 부분은 실제 코드가 실행되는 영역이다.

 

throw는 문제가 발생하는 경우 어떤 에러를 던져주는 역할을 한다.

 

catch는 throw가 던져준 에러를 받는 역할을 한다.

 

예를 들어서, input으로 받은 값이 특정 값보다 작은 경우 에러를 내고 싶다고 한다면 if (x > 1) throw "Error";와 같은 방식으로 설계할 수 있는 것이다.

 

 

위 예제 코드를 보면, catch 부분에서 굉장히 다양한 자료형을 받고 있는 것을 볼 수 있다.

 

try throw catch 방식의 예외처리는 throw 쪽에서 던진 에러의 자료형과 딱 맞는 타입이 catch에서 없는 경우 런타임 에러가 발생하고 예외처리가 작동하지 않는다.

 

위 예제 코드에서 보면 "My Error Message!"라는 에러를 throw 해주었는데, 이는 const char이기 때문에 const char를 argument으로 받는 부분에서 catch가 발생한다.

 

만약 throw -1.0; 으로 에러를 던져주면, catch에서 double을 argument로 받는 부분에서 에러를 캐치하게 된다.

 

이처럼 try throw catch 방식의 예외처리는 반드시 throw에서 던져주는 에러의 자료형과 catch에서 argument로 받을 때의 자료형이 매칭되어야만 사용이 가능하다.

 

 

Chapter 14.2 예외처리와 스택 되감기

 

 

이번 챕터에서는 여러 함수들이 서로를 호출하는 방식으로 stack을 쌓을 때 예외처리가 발생하는 경우에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>

// Stack unwinding.

using namespace std;



// exception specifier (int로 throw 할 수 있다.)
// throw(...) : 예외를 던질 가능성이 있는 함수다.
// throw() : 예외를 던지지 않는 함수다. 라는 의미.
void last() throw (int)
{
    cout << "last " << endl;
    cout << "Throws exception" << endl;

    throw - 1;
    //throw 'a';

    cout << "End last" << endl;
}

void third()
{
    cout << "Third" << endl;

    last();

    cout << "End third" << endl;
}

void second()
{
    cout << "Second" << endl;

    try
    {
        third();
    }
    catch (double)
    {
        cerr << "Second caught double exception" << endl;
    }

    cout << "End second" << endl;
}




void first()
{
    cout << "First" << endl;

    try
    {
        second();
    }
    catch (int)
    //catch (double)
    {
        cerr << "First caught int exception" << endl;
    }

    cout << "End first" << endl;
}



int main()
{
    cout << "Start " << endl;

    try
    {
        first();
    }
    catch (int)
    {
        cerr << "main caught int exception" << endl;
    }
    catch (...) // catch-all handler ellipsis
    {
        cerr << "main caught ellipses exception" << endl;
    }


    cout << "End main" << endl;
}

 

 

main문을 살펴보면,

 

Start를 cout 해주고, try에서 first() 함수를 실행해 준다.

 

first() 함수로 들어가 보면, First를 출력해 주고, try에서 second() 함수를 실행해 준다.

 

second()에서는 Second를 출력해 주고, third()를 실행해 준다.

 

third()에서는 Third를 출력해 주고, last()를 실행해 준다.

 

last()에서는 last와 Throws exception을 출력해 주고 throw -1;를 실행한다.

 

throw를 했으니, 이제 catch를 통해서 이를 잡아줘야 한다.

 

함수 스택이 차곡차곡 쌓여있기 때문에 뒤로 한 단계씩 돌아가야 하는데, 우선 third()에는 catch가 없고, second()에는 catch가 있지만 double을 받기 때문에 throw -1을 catch 할 수 없다.

 

더 돌아가면 first()가 있고, 여기에는 catch (int)가 있어서 잡아줄 수 있다.

 

그래서 출력 결과를 보면 First caught int exception이 출력된 것을 볼 수 있고, End first와 End main이 실행되었다.

 

이처럼 함수 스택을 뒤로 뒤로 돌아가는 것을 스택 언와인딩이라고 한다.

 

 

예제 코드 2

#include <iostream>

// Stack unwinding.

using namespace std;



// exception specifier (int로 throw 할 수 있다.)
// throw(...) : 예외를 던질 가능성이 있는 함수다.
// throw() : 예외를 던지지 않는 함수다. 라는 의미.
void last() throw (int)
{
    cout << "last " << endl;
    cout << "Throws exception" << endl;

    throw - 1;
    //throw 'a';

    cout << "End last" << endl;
}

void third()
{
    cout << "Third" << endl;

    last();

    cout << "End third" << endl;
}

void second()
{
    cout << "Second" << endl;

    try
    {
        third();
    }
    catch (double)
    {
        cerr << "Second caught double exception" << endl;
    }

    cout << "End second" << endl;
}




void first()
{
    cout << "First" << endl;

    try
    {
        second();
    }
    //catch (int)
    catch (double)
    {
        cerr << "First caught int exception" << endl;
    }

    cout << "End first" << endl;
}



int main()
{
    cout << "Start " << endl;

    try
    {
        first();
    }
    catch (int)
    {
        cerr << "main caught int exception" << endl;
    }
    catch (...) // catch-all handler ellipsis
    {
        cerr << "main caught ellipses exception" << endl;
    }


    cout << "End main" << endl;
}

 

 

 

이번 코드에서는 first() 함수에서 catch (int)를 catch (double)로 만들었다.

 

예제 코드 1번에서는 throw -1;를 first() 함수에 있는 catch (int)를 통해서 받았는데, 받을 수 없도록 만든 것이다.

 

그럼 first()의 이전 스택인 main() 함수로 돌아가게 되고, main()에 있는 catch (...)에서 -1을 받을 수 있게 된다.

 

이전 챕터에서 다루었듯이 catch는 특정 자료형을 고정해서 받는 경우 자료형이 무조건 일치해야만 받을 수 있었는데, ellipsis를 사용하는 경우 어떤 자료형이든 받을 수 있다.

 

 

Chapter 14.3 예외 클래스와 상속

 

 

이번 챕터에서는 예외 클래스와 상속에 대해서 다룬다.

 

 

예제 코드 1

#include <iostream>

using namespace std;

class MyArray
{
private:
    int m_data[5];

public:
    // 클래스의 멤버 함수에서도 exception을 throw 할 수 있다.
    int& operator[] (const int& index)
    {
        if (index < 0 || index >= 5) throw - 1;
        return m_data[index];
    }
};

void doSomething()
{
    MyArray my_array;

    try
    {
        my_array[100];
    }
    catch (const int& x)
    {
        cerr << "Exception " << x << endl;
    }
}

int main()
{
    doSomething();
}

 

 

 

main 문을 살펴보면 doSomething();만 돌아가는 걸 알 수 있는데, my_array의 100번째 인덱스에 있는 element를 접근하는 코드를 동작시킨다.

 

MyArray class를 살펴보면, 연산자 오버로딩이 적용되어 있는데 index가 5를 넘는 경우 thorw -1;를 하도록 되어 있다.

 

이때 doSomething()에 있는 catch에서 -1을 잡게 되고, Exception이 출력되는 구조이다.

 

 

예제 코드 2

#include <iostream>

using namespace std;

class Exception
{
public:
    void report()
    {
        cerr << "Exception report" << endl;
    }
};


class ArrayException : public Exception
{
public:
    void report()
    {
        cerr << "Array Exception " << endl;
    }
};

class MyArray
{
private:
    int m_data[5];

public:
    // 클래스의 멤버 함수에서도 exception을 throw 할 수 있다.
    int& operator[] (const int& index)
    {
        //if (index < 0 || index >= 5) throw - 1;
        if (index < 0 || index >= 5) throw ArrayException();

        return m_data[index];
    }
};


void doSomething()
{
    MyArray my_array;

    try
    {
        my_array[100];
    }
    catch (const int& x)
    {
        cerr << "Exception " << x << endl;
    }
    catch (ArrayException& e)
    {
        cout << "doSomething()" << endl;
        e.report();
    }
    catch (Exception& e)
    {
        cout << "doSomething()" << endl;
        e.report();
    }
}


int main()
{
    
    try
    {
        doSomething();
    }
    catch (ArrayException& e)
    {
        cout << "main()" << endl;
        e.report();
    }
    catch (Exception& e)
    {
        cout << "main()" << endl;
        e.report();
    }
    
}

 

 

 

이번에는 MyArray에서 throw 할 때 -1을 던지지 않고, ArrayException() class의 임시 객체를 던지는 경우이다.

 

던진 임시 객체는 doSomething()에서 ArrayException 객체를 argument로 받는 catch에서 받게 된다.

 

주의해야 할 점은 doSomething()에서 catch의 순서가 만약 Exception이 ArrayException보다 먼저 있는 경우엔 Exception이 catch 한다는 점이다.

 

이는 다형성으로 인해, Exception이 catch 하는 부분에서도 ArrayException을 잡을 수 있기 때문이다.

 

 

예제 코드 3

#include <iostream>

using namespace std;

class Exception
{
public:
    void report()
    {
        cerr << "Exception report" << endl;
    }
};


class ArrayException : public Exception
{
public:
    void report()
    {
        cerr << "Array Exception " << endl;
    }
};

class MyArray
{
private:
    int m_data[5];

public:
    // 클래스의 멤버 함수에서도 exception을 throw 할 수 있다.
    int& operator[] (const int& index)
    {
        //if (index < 0 || index >= 5) throw - 1;
        if (index < 0 || index >= 5) throw ArrayException();

        return m_data[index];
    }
};


void doSomething()
{
    MyArray my_array;

    try
    {
        my_array[100];
    }
    catch (const int& x)
    {
        cerr << "Exception " << x << endl;
    }
    catch (ArrayException& e)
    {
        cout << "doSomething()" << endl;
        e.report();
        throw e; // re-throw
    }
    catch (Exception& e)
    {
        cout << "doSomething()" << endl;
        e.report();
    }
}


int main()
{
    
    try
    {
        doSomething();
    }
    catch (ArrayException& e)
    {
        cout << "main()" << endl;
        e.report();
    }
    catch (Exception& e)
    {
        cout << "main()" << endl;
        e.report();
    }
    
}

 

 

 

이전 예제와 다른 게 있다면, doSomething()의 ArrayException을 catch 하는 부분에서 throw e;를 추가했다.

 

이는 re-throw라는 기능으로, 해당 catch문에서 받은 ArrayException을 다시 던지게 된다.

 

에러가 발생했기 때문에, main문에 있는 ArrayException을 잡는 catch문으로 가게 된다.

 

 

 

예제 코드 4

#include <iostream>

using namespace std;

class Exception
{
public:
    void report()
    {
        cerr << "Exception report" << endl;
    }
};


class ArrayException : public Exception
{
public:
    void report()
    {
        cerr << "Array Exception " << endl;
    }
};

class MyArray
{
private:
    int m_data[5];

public:
    // 클래스의 멤버 함수에서도 exception을 throw 할 수 있다.
    int& operator[] (const int& index)
    {
        //if (index < 0 || index >= 5) throw - 1;
        if (index < 0 || index >= 5) throw ArrayException();

        return m_data[index];
    }
};


void doSomething()
{
    MyArray my_array;

    try
    {
        my_array[100];
    }
    catch (const int& x)
    {
        cerr << "Exception " << x << endl;
    }
    catch (Exception& e)
    {
        cout << "doSomething()" << endl;
        e.report();
        throw; // 그냥 튕겨보내는 느낌.
    }
}


int main()
{
    
    try
    {
        doSomething();
    }
    catch (ArrayException& e)
    {
        cout << "main()" << endl;
        e.report();
    }
    catch (Exception& e)
    {
        cout << "main()" << endl;
        e.report();
    }
    
}

 

 

 

이번 코드에서 다른 점은, doSomething() 함수에서 ArrayException을 받은 게 아니라 Exception에서 catch를 하도록 했다.

 

따라서, doSomething()에서는 Exception 객체로 받은 상황이다. 이는 이전 예제 코드에서 언급했듯이, 다형성으로 인해 자식 클래스의 객체가 부모 클래스의 객체로도 잡을 수 있다.

 

그런데, throw e;를 하는 것과 다르게 그냥 throw;만 해주게 되면 catch문에서 받은 임시 객체를 바로 튕겨 보내는 역할을 해준다.

 

그래서 main문에서는 Exception으로 catch 하는 게 아니라 실제 객체의 클래스인 ArrayException으로 catch를 하게 된다.

 

 

 

Chapter 14.4 exception 소개

 

 

이번 챕터에서는 std library에 포함된 std::exception에 대해서 다룬다.

 

 

예제 코드 1

#include <iostream>
#include <exception>
#include <string>

int main()
{
    try
    {
        
        std::string s;
        s.resize(-1);
    }
    catch (std::exception& e)
    {
        std::cout << typeid(e).name() << std::endl; // exception 클래스의 자식들 중에서 std length error가 날라온 것이다.
        std::cerr << e.what() << std::endl; // string too long?
    }
}

 

 

 

이전에 try, throw, catch 구조에서 catch에는 throw를 했을 때 받으려고 하는 자료형이 정확히 매칭될 때만 catch가 실행된다고 얘기했었다.

 

이번 코드에서는 어떤 특정 exception을 정의하는 것이 아니라, 예외가 발생했을 때 모든 exception을 catch 할 수 있도록 std::exception을 받도록 만들었다.

 

typeid를 통해서 실제로 어떤 에러가 발생했는지 확인할 수 있으며, e.what()을 이용하면 설명도 확인할 수 있다.

 

 

e.what()을 찍어보았을 때 string too long이라는 에러 문구를 확인할 수 있는데, 이는 다음과 같다.

 

s.resize() 함수의 인자는 unsigned(부호 없는) 타입이다. 여기에 signed int -1을 전달하게 되면, 암시적으로 부호 없는 타입으로 변환되면서, 해당 타입이 표현할 수 있는 최댓값인 2^32 - 1이 된다.

 

그런데 std::string 객체가 가질 수 있는 최대 길이가 2^31-1이다.

 

따라서 std::string이 가질 수 있는 최대 길이를 넘어선 요청이 들어온 것이기 때문에, string이 너무 길다는 에러가 발생한 것이다.

 

 

예제 코드 2

#include <iostream>
#include <exception>
#include <string>

int main()
{
    try
    {
        
        std::string s;
        s.resize(-1);
    }
    
    catch (std::length_error& e)
    {
        std::cerr << "Length error" << std::endl;
        std::cerr << e.what() << std::endl;
    }
    
    catch (std::exception& e)
    {
        std::cout << typeid(e).name() << std::endl; // exception 클래스의 자식들 중에서 std length error가 날라온 것이다.
        std::cerr << e.what() << std::endl; // string too long?
    }
}

 

 

 

이번에는 catch에서 std::length_error를 받도록 만들어두었다. 

 

이전 챕터에서도 언급했듯이, try catch문은 순차적으로 catch 문의 조건에 걸리는지를 확인한다.

 

따라서, 지금과 같은 코드에서는 catch (std::length_error)에 먼저 걸리게 되면서, Length error가 출력된 것을 확인할 수 있다.

 

이처럼 catch 하고 싶은 에러를 명시적으로 적어줘서 잡게 만드는 것도 가능하다. 

 

 

 

예제 코드 3

#include <iostream>
#include <exception>
#include <string>

int main()
{
    try
    {        
        throw std::runtime_error("Bad thing happend");
    }
    
    catch (std::length_error& e)
    {
        std::cerr << "Length error" << std::endl;
        std::cerr << e.what() << std::endl;
    }
    
    catch (std::exception& e)
    {
        std::cout << typeid(e).name() << std::endl; // exception 클래스의 자식들 중에서 std length error가 날라온 것이다.
        std::cerr << e.what() << std::endl; // string too long?
    }
}

 

 

 

이번엔 특정 에러를 throw로 해서 던진 상황이다. 

 

 

std::runtime_error를 발생시켰고, Bad thing happend라는 메시지를 담았다.

 

그랬더니 std::exception을 잡는 catch문에서 해당 에러를 잡아서 출력한 모습이다.

 

이처럼 throw 문에서 특정 에러를 발생시키게 만들 수 있고, catch 쪽에서 std::exception로 잡을 수 있다.

 

 

 

예제 코드 4

#include <iostream>
#include <exception>
#include <string>

class CustomException : public std::exception 
{
public:
    // noexcept: 적어도 이 함수 안에서는 예외를 던지지 않는다 라는 의미.
    const char* what() const noexcept override
    {
        return "Custom exception";
    }
};


int main()
{
    try
    {
        throw CustomException();
    }
    
    catch (std::length_error& e)
    {
        std::cerr << "Length error" << std::endl;
        std::cerr << e.what() << std::endl;
    }
    
    catch (std::exception& e)
    {
        std::cout << typeid(e).name() << std::endl; // exception 클래스의 자식들 중에서 std length error가 날라온 것이다.
        std::cerr << e.what() << std::endl; // string too long?
    }
}

 

 

 

이번 코드에서는 std::exception을 상속해서 만든 CustomException 클래스에 대한 내용이다.

 

std::exception에서 e.what()을 통해 에러 메세지를 출력한 것처럼, custom exception class에서도 해당 기능을 할 수 있도록 만들려면 클래스 내에서 멤버 함수로 what() 함수를 오버라이드 해서 구현해주어야 한다. 

 

그리고 what()에다가 noexcept라는 구문이 들어가는데, 이는 적어도 이 함수 안에서는 예외가 발생하지 않는다는 의미라고 한다.

 

 

 

 

Chapter 14.5 함수 try

 

 

이번 챕터에서는 함수 try(Function try)에 대해서 다룬다.

 

 

예제 코드 1

#include <iostream>

void doSomething()
// Function try
try
{
    throw - 1;
}
catch (...)
{
    std::cout << "Catch in doSomething()" << std::endl;
}


int main()
{
    try
    {
        doSomething();
    }
    catch (...)
    {
        std::cout << "Catch in main()" << std::endl;
    }
}

 

 

 

이번 예제 코드에서는 일반 함수에서 function try를 사용하는 경우이다.

 

함수에서 function try를 사용하는 경우는 단순히 인덴트만 바뀌는데, 우리가 원래 알던 대로 작동한다고 한다.

 

try에서 throw -1;를 하고 있어서, doSomething() 안에 있는 catch 쪽에서 이를 잡아주는 모습이다.

 

 

 

예제 코드 2

#include <iostream>

class A
{
private:
    int m_x;
public:
    A(int x) : m_x(x)
    {
        if (x <= 0)
            throw 1;
    }
};

class B : public A
{
public:
    
    B(int x)
        : A(x)
    {
    }
};

int main()
{
    try
    {
        B b(0);
    }
    catch (...)
    {
        std::cout << "Catch in main()" << std::endl;
    }
}

 

 

 

이번 예제에서는 class B의 인스턴스 b를 만들어주고, 생성자에서 0을 넣어서 만들어준 모습이다.

 

그런데 class B의 부모 클래스인 class A를 보면, x가 0보다 작거나 같을 때는 에러를 throw 하도록 되어 있다.

 

따라서 b를 만드는 과정에서 에러가 발생하게 되고, 발생한 에러는 main문에 있는 catch에서 잡아주게 된다.

 

 

 

예제 코드 3

#include <iostream>

class A
{
private:
    int m_x;
public:
    A(int x) : m_x(x)
    {
        if (x <= 0)
            throw 1;
    }
};

class B : public A
{
public:
    
    B(int x) try : A(x)
    {

    }
    catch (...)
    {
        std::cout << "Catch in B constructor" << std::endl;
        // throw;
    }
    
};

void doSomething()
// Function try
try
{
    throw - 1;
}
catch (...)
{
    std::cout << "Catch in doSomething()" << std::endl;
}


int main()
{
    try
    {
        B b(0);
    }
    catch (...)
    {
        std::cout << "Catch in main()" << std::endl;
    }
}

 

 

 

이번 예제 코드에서는, class B의 member initializer list에 try를 추가한 경우이다.

 

부모 클래스인 A의 생성자가 실행되는 과정에서 throw 1;로 인해 예외가 발생하는데, 이에 대한 예외를 class B의 생성자 내에서 catch 할 수 있게 된다.

 

근데 특이한 것은, B의 생성자 내에서 catch를 하고 나서 끝나는 것이 아니라, 추가로 main 문에서도 예외가 catch 된다.

 

즉, 이전 강의들에서 다루었던 re-throw 기능이 포함되어 있는 것이다.

 

 

 

Chapter 14.6 예외처리의 위험성과 단점

 

 

이번 챕터에서는 예외처리의 위험성과 단점에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>
#include <memory>

int main()
{
    try
    {
        int* i = new int[1000000];
        unique_ptr<int> up_i(i); // 영역을 벗어나면 유니크 포인터가 메모리를 지워줌.

        throw "error";
        
        //delete[] i;
    }
    catch (...)
    {
        cout << "Catch" << endl;
    }
}

 

 

위 main문처럼, 만약 동적 할당으로 변수를 선언했는데 이를 delete 하지 못하고 error를 throw 하는 경우, delete를 처리하지 못해 메모리 누수가 발생할 수 있다.

 

이럴 때 대응할 수 있도록 사용하는 것이 바로 스마트포인터로, 영역을 벗어나는 경우 메모리를 지워주는 기능을 한다.

 

스마트포인터는 추후 강의에서 자세하게 다룬다고 한다.

 

 

예제 코드 2

#include <iostream>
#include <memory>

using namespace std;

class A
{
public:
    ~A()
    {
        // Destructor에서는 예외를 못 던지게 되어 있음.
        // 보통 소멸자는 메모리에 있는 것을 지우고 날려보내는 것이라서 예외를 던질 수 있는 상태가 아니라고 봄.
        throw "error";
    }
};

int main()
{
    // 가급적 바깥쪽에서 사용할 것. 반복문에서는 사용 지양.
    // 모든 오류를 다 예외처리로 잡으려고 하지 말 것.
    // 정상적으로 작동해야 하는 것은 작동하도록 if문 같은 걸로 걸러내는게 좋음.
    // 사용자 입력의 경우 사용자한테 정상적인 범위의 입력을 다시 입력하도록 유도하느게 정상적.
    // 네트워크 서버 돌릴 때, 분산 처리나 병렬 처리 작업을 할 때, 하드웨어 관련해서 작업할 때, IO 작업할 때 등등 예외가 발생할 수 있는 상황에서 예외처리를 활용한다.
    try
    {
        A a;
    }
    catch (...)
    {
        cout << "Catch" << endl;
    }
}

 

 

 

클래스 사용 시, 소멸자에서는 예외를 못 던지도록 되어 있다고 한다.

 

보통 소멸자는 메모리에 있는 것을 지우고 날려 보내는 기능을 하기 때문에, 예외를 던질 수 있는 상태가 아니라고 본다고 한다.

 

그래서 소멸자에서 throw를 선언하는 경우 Warning이 발생한다.

 

 

 

예외 처리 초반 강의에서도 언급했지만, 예외 처리는 시간이 오래 소요되기 때문에 반복문에서 사용하기보다는 main문 바깥쪽에서 사용해서 반복 실행 되는 구간에서의 사용을 지양하는 것이 원칙이다.

 

그리고 모든 오류를 다 예외처리로 잡으려고 하면 안 되며, 진짜 예외적인 케이스들(네트워크 서버, 분산 처리 및 병렬 처리 시, 하드웨어 관련 작업, 입출력 작업할 때 등 예외가 발생할 수 있는 상황)에서만 예외 처리를 활용하는 것이 좋다고 한다.

 

 

 

 

 

여기까지 예외처리에 대해서 다룬 Chapter 14를 모두 정리해 보았다.

 

 

 

 

 

 

 

Chapter 13.1 함수 템플릿

 

 

이번 챕터에서는 함수 템플릿이라는 기능에 대해서 알아본다.

 

예제 코드 1

main.cpp

#include <iostream>
#include "Cents.h"


// typename 자리에 class가 들어올 수도 있다.
template<typename T>
T getMax(T x, T y)
{
    return (x > y) ? x : y;
}

/*
int getMax(int x, int y)
{
    return (x > y) ? x : y;
}

double getMax(double x, double y)
{
    return (x > y) ? x : y;
}

float getMax(float x, float y)
{
    return (x > y) ? x : y;
}
*/

int main()
{
    // T 자리에 뭐가 들어가야할지 컴파일러가 찾아내고, 내부적으로 만들어줌.
    // 컴파일을 하고 실행할 때 Function overloading처럼 바꿔치기해서 사용할 수 있게 해줌.
    std::cout << getMax(1, 2) << std::endl;
    std::cout << getMax(3.14, 1.592) << std::endl;
    std::cout << getMax(1.0f, 2.4f) << std::endl;
    std::cout << getMax('a', 'd') << std::endl;

    std::cout << getMax(Cents(5), Cents(9)) << std::endl; // User Defined Data type에 대해서도 사용할 수 있다.

    // 특정한 데이터 타입에 대해서 실제로 만드는 것을 instantiation 이라고 부름.
    // getMax(~, ~)가 인스턴스.
}

 

 

Cents.h

#pragma once

#include <iostream>

class Cents
{
private:
	int m_cents;

public:
	Cents(int cents)
		: m_cents(cents)
	{

	}

	friend std::ostream& operator << (std::ostream& out, const Cents& cents)
	{
		out << cents.m_cents << " cents ";
		return out;
	}

	friend bool operator > (const Cents& c1, const Cents& c2)
	{
		return (c1.m_cents > c2.m_cents);
	}
};

 

 

 

이번 예제는 '함수 템플릿'이라는 기능을 소개하는 코드이다.

 

main.cpp에서 주석 처리를 해둔 부분을 보면, getMax라고 하는 함수의 return type과 parameter type이 다를 때 계속해서 다른 함수를 별도로 만들어야 하는 것을 확인할 수 있다.

 

그런데 템플릿이라는 기능을 활용하면, 이를 하나의 틀처럼 만들어서 사용할 수 있다.

 

template<typename T>를 적어주고, T를 기반으로 함수를 만들어주면 된다. 단, typename 대신 class가 오기도 한다고 하니 주의하자.

 

 

함수 템플릿이라는 기능은, 실제 함수를 사용할 때 인수에 어떤 값을 주느냐를 컴파일러가 찾아주고 그에 맞춰서 자료형을 결정해 준다.

 

그래서 getMax에 int를 사용할 수도 있고, float, double, char, user defined data type까지 모두 사용할 수 있다.

 

그리고 getMax(1, 2)이라고 함수를 호출하는 경우 컴파일러가 getMax(int, int)인 함수를 생성하는 것을 인스턴스화(instantiation)이라고 부른다.

 

 

C++에서 지원하는 int, double, float 등 기본 타입은 상관없지만, 만약 user defined data type을 사용하는 경우는 주의해야 될 점들이 있다.

 

코드 상에서 << 연산자를 이용해서 std::cout을 하는 상황이기 때문에, 해당 사용자 정의 자료형에다가 연산자 오버로딩을 진행해 줘야만 <<를 사용하여 std::cout를 할 수 있다는 점이다.

 

그리고, getMax라는 함수는 x와 y 중 어떤 것이 큰 값인지를 판단해서 return 해주는데, 사용자 정의 자료형에서 두 인스턴스 간 대소비교가 가능해야 하기 때문에 > operator에 대해서도 연산자 오버로딩을 만들어주어야만 템플릿 함수를 사용했을 때 문제가 발생하지 않는다.

 

 

 

 

Chapter 13.2 클래스 템플릿

 

 

이번 챕터에서는 클래스에 템플릿을 적용하는 경우에 대해서 알아본다.

 

 

예제 코드 1

MyArray.h

#pragma once
#include <assert.h>
#include <iostream>


template <typename T>
class MyArray
{
private:
	int m_length;
	T* m_data;

public:
	MyArray()
	{
		m_length = 0;
		m_data = nullptr;
	}

	MyArray(int length)
	{
		m_data = new T[length];
		m_length = length;
	}

	~MyArray()
	{
		reset();
	}

	void reset()
	{
		delete[] m_data;
		m_data = nullptr;
		m_length = 0;
	}

	T& operator[](int index)
	{
		assert(index >= 0 && index < m_length);
		return m_data[index];
	}

	int getLength()
	{
		return m_length;
	}

	void print();
};

 

 

my_array.cpp

#include "MyArray.h"

template<typename T>
void MyArray<T>::print()
{
	for (int i = 0; i < m_length; ++i)
	{
		std::cout << m_data[i] << " ";
	}
	std::cout << std::endl;
}

// explicit instantiation
// template void MyArray<double>::print();

//template class MyArray<double>;

 

 

 

main.cpp

#include <iostream>
#include "MyArray.h"
// 헤더 파일에 있는 내용들이 전부 cpp로 복사해서 붙여진 것처럼 작동함.

int main()
{
    MyArray<double> my_array(10);

    for (int i = 0; i < my_array.getLength(); ++i)
    {
        my_array[i] = i * 0.5;
    }

    my_array.print();
}

 

 

 

MyArray라는 class type으로 my_array라는 인스턴스를 만든 상황이고, print() 함수를 사용하는 상황이다.

 

MyArray class에 대한 정보는 MyArray.h 파일에 적혀있다.

 

함수 템플릿과 동일하게, class의 앞 줄에 template <typename T>를 적어두면 클래스에 대해서도 템플릿을 적용할 수 있다.

 

여기서 살펴보아야 할 점은, MyArray.h에 있는 MyArray class 내 print()라는 함수의 body가 my_array.cpp에 별도로 정의되어 있다는 점이다.

 

이렇게 설계된 경우, 링킹 에러가 발생하게 된다.

 

 

 

이는 C++가 실행파일을 만들 때 흐름을 살펴보아야 하는데,

 

프로젝트 내 cpp 파일을 컴파일해서 코드를 기계어로 바꾸어주게 되는데, 이때. obj 파일로 컴파일이 된다. 그리고 .obj 파일들끼리 연결을 해주면서 최종적으로 .exe 파일이 완성되는데 연결이 되는 과정이 바로 링킹이다.

 

 

main.cpp에서, my_array.print()를 실행하기 위해서는 MyArray<double>::print()에 대한 주소가 필요한데, my_array.cpp에는 print() 함수에 대한 청사진만 있지 아직 인스턴스화가 되지 않아 실제 함수 주소가 있지는 않다.

 

따라서, 이에 대한 해결 방법은 두 가지가 있다.

 

1) print() 함수의 body를 MyArray.h (헤더파일)에서 정의한다

-> main.cpp에서 MyArray.h를 include 하는 구조이기 때문에, main.cpp에서 MyArray class에 대한 모든 코드를 복사해서 가지고 있게 되어 링킹 에러가 발생하지 않는다.

 

2) my_array.cpp에서 명시적 인스턴스화(Explicit instantiation)을 해준다.

-> explicit instantiation을 해주면, 특정 타입의 실제 인스턴스를 강제로 생성해서 .obj 파일에 넣어주는 역할을 한다.

 

즉, MyArray<double>의 실제 인스턴스를 강제로 생성해 my_array.cpp의 .obj 파일에 넣어주게 되고, 링킹 에러가 발생하지 않는다.

 

explicit instantiation은 두 가지 방법이 있는데,

 

template void MyArray<double>::print();로 함수에 대한 인스턴스화를 해주거나, 혹은 template class MyArray<double>로 클래스에 대한 인스턴스화를 해주면 된다.

 

 

 

Chapter 13.3 자료형이 아닌 템플릿 매개변수

 

 

이번 챕터에서는 템플릿을 사용할 때 자료형이 아닌 매개변수를 사용하는 경우에 대해서 알아본다.

 

 

예제 코드 1

MyArray.h

#pragma once
#include <assert.h>
#include <iostream>

// T_SIZE가 템플릿에 파라미터가 들어가는 자리.
// 컴파일 타임에 T_SIZE가 정해져 있어야 함.
// 템플릿의 기본적인 원리: 사람이 코딩을 안 해줘도 컴파일을 다시 따로따로 해주는 것.

template<typename T, unsigned int T_SIZE>
class MyArray
{
private:
	//int m_length;
	T* m_data;

public:
	MyArray()
	{
		m_data = new T[T_SIZE];
	}
	~MyArray()
	{
		delete[] m_data;
	}
	
	T& operator[](int index)
	{
		assert(index >= 0 && index < T_SIZE);
		return m_data[index];
	}

	int getLength()
	{
		return T_SIZE;
	}

	void print()
	{
		for (int i = 0; i < T_SIZE; ++i)
		{
			std::cout << m_data[i] << " ";
		}
		std::cout << std::endl;
	}
};

 

 

main.cpp

#include <iostream>
#include "MyArray.h"
// 자료형이 아닌 템플릿 매개변수 사용?

int main()
{
    MyArray<double, 100> my_array;

    for (int i = 0; i < my_array.getLength(); ++i)
    {
        my_array[i] = i + 65;
    }

    my_array.print();
}

 

 

 

 

이번 예제에서는 자료형 뿐만 아니라 다른 매개변수도 템플릿에서 사용하는 예제를 알아본다.

 

이전 예제에서는 typename T라는 이름으로 자료형만 템플릿에 적용했었다.

 

그런데 이번 예제에서는 매개변수로 int형을 받아서, MyArray의 m_data의 크기를 결정하게 되는 방식이다.

 

이러한 방식을 사용하는 경우, 컴파일 타임에 배열의 크기를 상수로 결정하기 때문에 안정성을 확보할 수 있다.

 

 

 

 

Chapter 13.4 함수 템플릿 특수화

 

 

이번 챕터에서는 함수에 템플릿을 적용할 때 특수화를 적용하는 경우에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>
using namespace std;

template<typename T>
T getMax(T x, T y)
{
    return (x > y) ? x : y;
}

// Specialization
template<>
char getMax(char x, char y)
{
    cout << "Warning : comparing chars" << endl;

    return (x > y) ? x : y;
}

int main()
{
    
    //cout << getMax<double>(1.0, 2.0) << endl; // 
    cout << getMax<double>(1, 2) << endl; // 1, 2는 정수이지만, <double>로 적어주면 강제로 instantiation을 double 타입으로 강제화.

    // char 타입일 때는 경고를 띄워주고 싶다.
    // 근데 if문을 사용할 수는 없다. 컴파일 타임에 해야 되기 때문에.
    cout << getMax('a', 'b') << endl;
}

 

 

 

이번 예제에서는 함수에 템플릿을 적용할 때 특수화(Speicalization)를 적용하는 경우에 대해서 알아본다.

 

템플릿이라는 기능은 어떤 자료형이 오든지 대응할 수 있도록 만드는 것이 핵심인데, 만약 어떤 특정 자료형에 대해서는 다르게 작동하도록 만들고 싶다면? 특수화를 적용하면 된다.

 

특수화 적용 시 자료형을 고정하기 때문에 template<typename T>가 아니라 자료형을 빼고 template<>으로 작성하게 된다.

 

main문에서 getMax('a', 'b')을 적용하면, Warning: 구문이 뜨는 것을 확인할 수 있다.

 

그리고 template 사용 시 인스턴스화를 특정 자료형으로 강제하고 싶다면, 함수 사용 시 <자료형>을 적용해 주면 된다.

 

getMax<double>을 적용해 주는 경우, 인수로 int를 주더라도 실제로는 double로 적용된다.

 

 

 

예제 코드 2

Storage.h

#pragma once

#include <iostream>

template <class T>
class Storage
{
private:
	T m_value;

public:
	Storage(T value)
	{
		m_value = value;
	}

	~Storage()
	{

	}

	void print()
	{
		std::cout << m_value << std::endl;
	}

};


template<>
void Storage<double>::print()
{
	std::cout << "Double Type ";
	std::cout << std::scientific << m_value << "\n";
}

 

 

 

main.cpp

#include <iostream>
#include "Storage.h"
using namespace std;

int main()
{
    Storage<int> nValue(5);
    Storage<double> dValue(6.7);

    nValue.print();
    dValue.print();
}

 

 

 

이번에는 class 내 멤버 함수에 대해서 특수화를 적용하는 경우이다.

 

Storage.h의 소스코드를 살펴보면, print()라는 함수에 대해서 Storage <double>인 경우에 특수화를 적용한 것을 볼 수 있다.

 

double인 경우 scientific으로 출력하도록 해둔 상태이다.

 

 

 

Chapter 13.5 클래스 템플릿 특수화

 

 

이번 챕터에서는 클래스에 대해서 템플릿 특수화를 적용하는 예시에 대해서 다루어본다.

 

 

예제 코드 1

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

template<typename T>
class A
{
public:
    A(const T& input)
    {

    }
    void doSomething()
    {
        cout << typeid(T).name() << endl;
    }

    void test()
    {
    }

};


template<>
class A<char>
{
public:
    A(const char& input)
    {

    }
    void doSomething()
    {
        cout << "Char type specialization" << endl;
    }
};

int main()
{
    // C++ 17 이상에서는 <int> 부분 빼도 가능.
    A<int> a_int(1);
    A<double> a_double(3.14);
    A<char> a_char('a');

    a_int.doSomething();
    a_double.doSomething();
    a_char.doSomething();

    a_int.test();
    a_double.test();
    // a_char.test(); // 아예 다른 클래스 구현이라서, 상속 아님. test 없음.
}

 

 

 

이번 예제코드에서는 클래스 템플릿 특수화에 대해서 알아본다.

 

클래스 템플릿 특수화를 적용하려면, 이전에 함수 특수화처럼 template <>을 맨 앞에다가 추가해 주고, class A<char>와 같이 특정 자료형을 클래스 이름 옆에다가 기입해 주면 된다.

 

main문을 살펴보면, a_int, a_double, a_char에 대해서 doSomething()을 실행했을 때 Char type만 다르게 나오는 것을 확인할 수 있다.

 

그리고 클래스 템플릿 특수화는 상속이 아니기 때문에, class A<char>에서 test라는 멤버 함수를 작성하지 않았다면 사용이 불가능하다.

 

object를 만들 때 A<int> a_int(1);와 같은 방식으로 만들어주었는데, C++ 17 이상부터는 자료형을 명시하지 않더라도 빌드가 가능하다. 다만 그 이하 버전에서는 에러가 나는 것을 볼 수 있다.

 

 

예제 코드 2

Storage8.h

#pragma once

template <class T>
class Storage8
{
private:
	T m_array[8];

public:
	void set(int index, const T& value)
	{
		m_array[index] = value;
	}

	const T& get(int index)
	{
		return m_array[index];
	}
};

template <>
class Storage8<bool>
{
private:
	unsigned char m_data;

public:
	Storage8() : m_data(0)
	{

	}

	void set(int index, bool value)
	{
		unsigned char mask = 1 << index;

		if (value)
			m_data |= mask;
		else
			m_data &= ~mask;
	}

	bool get(int index)
	{
		unsigned char mask = 1 << index;
		return (m_data & mask) != 0;
	}
};

 

 

main.cpp

#include <iostream>
#include <array>
#include "Storage8.h"
using namespace std;

int main()
{
    Storage8<int> intStorage;

    for (int count = 0; count < 8; ++count)
    {
        intStorage.set(count, count);
    }

    for (int count = 0; count < 8; ++count)
    {
        std::cout << intStorage.get(count) << '\n';
    }

    cout << "Sizeof Storage8<int> " << sizeof(Storage8<int>) << endl;

    Storage8<bool> boolStorage;
    for (int count = 0; count < 8; ++count)
    {
        boolStorage.set(count, 0);
    }

    for (int count = 0; count < 8; ++count)
    {
        std::cout << boolStorage.get(count) << '\n';
    }

    cout << "Sizeof Storage8<bool> " << sizeof(Storage8<bool>) << endl;

}

 

 

 

이번에는 클래스 템플릿 특수화를 잘 사용한 케이스에 대해서 알아본다.

 

int형 변수를 저장하는 array를 가진 Storage8<int>의 경우, 4 byte인 int를 8개 포함하기 때문에 sizeof를 했을 때 32 byte가 나오는 것을 확인할 수 있다.

 

그런데, Storage8<bool>의 경우, 데이터를 unsigned char에 저장하는 방식을 사용하고 있다.

 

char type은 1 byte를 차지하기 때문에, 8개의 데이터를 표현하지만 1 byte로 표현할 수 있는 것이다. 이는 비트마스크를 활용하여 표현하기 때문이다.

 

만약, bool type에 대해서 비트마스크를 적용하지 않고 기존 Storage8을 적용하게 되면 m_array는 bool type의 데이터를 8개 포함하는 array가 되기 때문에 8 byte가 된다.

 

이렇게, 클래스 템플릿 특수화를 잘 이용한다면 같은 데이터를 표현하는데도 더 적은 양의 메모리만 사용해서 표현이 가능하다.

 

 

 

Chapter 13.6 템플릿을 부분적으로 특수화하기

 

 

이번 챕터에서는 템플릿을 부분적으로 특수화하는 방법에 대해서 다룬다.

 

 

예제 코드 1

#include <iostream>
using namespace std;

template <class T, int size>
class StaticArray
{
private:
    T m_array[size];

public:
    T* getArray() { return m_array; }

    T& operator[](int index)
    {
        return m_array[index];
    }
};

template <typename T, int size>
void print(StaticArray<T, size>& array)
{
    for (int count = 0; count < size; ++count)
    {
        std::cout << array[count] << ' ';

    }
    std::cout << endl;
}



template <int size>
void print(StaticArray<char, size>& array)
{
    for (int count = 0; count < size; ++count)
    {
        std::cout << array[count];

    }
    std::cout << endl;
}


int main()
{
    StaticArray<int, 4> int4;
    int4[0] = 1;
    int4[1] = 2;
    int4[2] = 3;
    int4[3] = 4;

    print(int4);

    StaticArray<char, 14> char14;
    char14[0] = 'H';
    char14[1] = 'e';
    strcpy_s(char14.getArray(), 14, "Hello, World");

    print(char14);
}

 

 

 

class T와 int size를 지정해서 만드는 template class인 StaticArray가 있고, StaticArray를 입력받아서 print하는 함수인 void print가 있다.

 

이 예제 코드에서 진행하려는 내용은, StaticArray<T, size>를 받을 때는 print 시 ' '를 추가해서 한 칸씩 띄어쓰기를 하고, 만약 char type의 StaticArray 인스턴스가 들어오는 경우에는 띄어쓰기를 하지 않으려고 한다.

 

즉 print 함수의 특수화를 진행하려는 것이다.

 

이런 경우, template에 typename T는 빼야 하고, print에 입력받을 내용을 적을 때 StaticArray<char, size>로 타입을 명시해서 적어주어야 한다.

 

 

 

예제 코드 2

#include <iostream>
using namespace std;

template <class T, int size>
class StaticArray_BASE
{
private:
    T m_array[size];

public:
    T* getArray() { return m_array; }

    T& operator[](int index)
    {
        return m_array[index];
    }

    
    void print()
    {
        for (int count = 0; count < size; ++count)
        {
            std::cout << (*this)[count] << ' ';

        }
        std::cout << endl;
    }
    
};

template <class T, int size>
class StaticArray : public StaticArray_BASE<T, size>
{
    
};

// 템플릿 파라미터가 여러 개일 경우에 모두 다 특수화를 하는게 아니고 부분적으로 특수화.
// 멤버 함수에 대해서 특수화를 할 때는 상속 구조를 사용하면 편하게 구현 가능.
// 템플릿의 인스턴스를 필요한 부분만 다시 특수화를 했음.
// 오버라이딩(overriding)을 해줌.
// 내가 특별하게 따로 구현을 하고 싶은 부분 외에는 반복해서 구현할 필요가 없다.

template <int size>
class StaticArray<char, size> : public StaticArray_BASE<char, size>
{
public:
    void print()
    {
        for (int count = 0; count < size; ++count)
        {
            std::cout << (*this)[count];

        }
        std::cout << endl;
    }
};

int main()
{
    StaticArray<int, 4> int4;
    int4[0] = 1;
    int4[1] = 2;
    int4[2] = 3;
    int4[3] = 4;

    int4.print();

    StaticArray<char, 14> char14;
    char14[0] = 'H';
    char14[1] = 'e';
    strcpy_s(char14.getArray(), 14, "Hello, World");

    char14.print();
}

 

 

이번 예제는, 예제 1번과는 다르게 클래스의 멤버 함수에 대해서 특수화를 진행하는 경우이다.

 

StaticArray class를 StaticArray_BASE로 이름을 바꾸고, print 함수를 클래스 내 멤버 함수로 이동시킨 상황이다.

 

만약 특정 type이 들어왔을 때 다르게 작동할 수 있도록 특수화를 하고자 한다면, 위 예제와 같이 상속 구조를 활용하는 방식이 가장 좋다고 한다.

 

즉, StaticArray_BASE는 template <class T, int size>로 정의해 두고,

 

내가 원하는 type의 class는 class StaticArray<char, size> : public StaticArray_BASE<char, size>로 상속을 받아서 처리하는 방식이다.

 

이렇게 상속을 받게 되면 내가 특수화하고자 하는 함수 외 함수는 별도로 다시 구현할 필요가 없다는 장점이 있다.

 

 

 

Chapter 13.7 포인터에 대한 템플릿 특수화

 

 

이번 챕터에서는 자료형이 포인터일 때 템플릿 특수화를 하는 방법에 대해서 다룬다.

 

 

예제 코드 1

#include <iostream>
using namespace std;

template <class T>
class A
{
private:
    T m_value;

public:
    A(const T& input)
        : m_value(input)
    {

    }

    void print()
    {
        cout << m_value << endl;
    }
};

int main()
{
    A<int> a_int(123);
    a_int.print();

    int temp = 456;

    A<int*> a_int_ptr(&temp);
    a_int_ptr.print();

    double temp_d = 3.141592;

    A<double*> a_double_ptr(&temp_d);
    a_double_ptr.print();
}

 

 

int type을 가지는 a_int와, int* type을 가지는 a_int_ptr, double* type을 가지는 a_double_ptr을 정의한 다음 각각 print()를 하도록 만들어두었다.

 

int*와 double*는 포인터이기 때문에 당연히 주소를 가리키고 있어, 결괏값이 메모리 주소가 나오는 것을 확인할 수 있다.

 

만약 template class A를 사용할 때 인스턴스화를 진행할 시 데이터 타입이 포인터인 경우만 따로 빼서 특수화를 할 수는 없을까?

 

 

예제 코드 2

#include <iostream>
using namespace std;

template <class T>
class A
{
private:
    T m_value;

public:
    A(const T& input)
        : m_value(input)
    {

    }

    void print()
    {
        cout << m_value << endl;
    }
};



template <class T>
class A<T*>
{
private:
    T* m_value;

public:
    A(T* input)
        : m_value(input)
    {

    }

    void print()
    {
        cout << *m_value << endl;
    }
};


int main()
{
    A<int> a_int(123);
    a_int.print();

    int temp = 456;

    A<int*> a_int_ptr(&temp);
    a_int_ptr.print();

    double temp_d = 3.141592;

    A<double*> a_double_ptr(&temp_d);
    a_double_ptr.print();
}

 

 

 

template <class T>를 적어주고, class A는 <T*>로 만들어준다. 

 

이렇게 하면 데이터 타입이 포인터인 경우에 대해서만 별도의 특수화를 해줄 수 있다.

 

print() 함수에서 de-reference 하도록 만들어 주소가 아닌 값이 출력될 수 있도록 만들었다.

 

 

 

 

Chapter 13.8 멤버 함수를 한 번 더 템플릿화 하기

 

 

이번 챕터에서는 템플릿이 적용된 클래스 내에 있는 멤버 함수를 한 번 더 템플릿화 하는 예제에 대해서 알아본다.

 

예제 코드 1

#include <iostream>
using namespace std;

template<class T>
class A
{
private:
    T m_value;

public:
    A(const T& input)
        : m_value(input)
    {

    }

    template <typename TT>
    void doSomething(const TT & input)
    {
        cout << typeid(T).name() << " " << typeid(TT).name() << endl;
        //cout << (TT)m_value << endl;
    }

    void print()
    {
        cout << m_value << endl;
    }
};

int main()
{
    
    A<int> a_int(123);
    a_int.print();

    // TT에 대해서도 템플릿화 가능. doSomething 함수에서만 템플릿 파라미터로 적용됨.
    a_int.doSomething<float>(123.4);
    a_int.doSomething(123.4); // double로 나옴.
    a_int.doSomething('a'); // char로 나옴.
}

 

 

 

이번 예제에서는, 템플릿화 된 클래스인 A 내부에서 추가로 템플릿화를 멤버 함수에 적용하는 케이스를 알아본다.

 

class A 안에 void doSomething이라는 멤버 함수가 있고, 여기에 template <typename TT>가 적용되어 있다.

 

main문을 살펴보면, a_int라는 이름으로 int형 class A의 인스턴스를 만들어두었고, 

 

a_int의 doSomething 함수를 3번 호출한 상황이다.

 

a_int.doSomething<float>(123.4);를 살펴보면, doSomething에 float 자료형을 적용하도록 한 상황이라 typeid의 결과가 float인 것을 확인할 수 있다.

 

하지만 <float>를 붙이지 않는다면, double임을 확인할 수 있고, 'a'를 input으로 사용하는 경우 char type이 나오는 것을 볼 수 있다.

 

이처럼 템플릿이 적용된 클래스 내 멤버 함수에 대해서도 템플릿화를 적용할 수 있다.

 

 

 

예제 코드 2

#include <iostream>
using namespace std;

template<class T>
class A
{
private:
    T m_value;

public:
    A(const T& input)
        : m_value(input)
    {

    }

    template <typename TT>
    void doSomething(const TT & input)
    {
        cout << typeid(T).name() << " " << typeid(TT).name() << endl;
        cout << (TT)m_value << endl;
    }

    void print()
    {
        cout << m_value << endl;
    }
};

int main()
{    
    A<char> a_char('A');
    a_char.print();

    a_char.doSomething(0);
}

 

 

 

이번에는 a_char라고 하는 char type의 인스턴스를 만든 상황이고, m_value는 'A'이다.

 

a_char.print()를 하면 A가 출력되고,

 

typeid(T)는 char, typeid(TT)는 int인 것을 볼 수 있다.

 

그리고 m_value가 현재 'A'인데, 이를 (TT)로 casting 한 결과가 65인 것도 확인할 수 있다.

 

 

 

여기까지 템플릿 관련된 챕터를 모두 정리해 보았다.

 

템플릿이라고 하는 기능은 굉장히 유용해 보인다. 자료형에 따라 함수를 여러 개 만들 필요 없이, 하나의 템플릿으로 범용성 있게 사용할 수 있다는 점에서 유용한 기능으로 보인다.

 

 

 

 

이번 게시물에서는 Chapter 12에 대해서 정리해 본다.

 

 

 

 

Chapter 12.1 다형성의 기본 개념

 

 

이번 강의에서는 다형성의 기본 개념에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>

using namespace std;

class Animal
{
protected:
    string m_name;

public:
    Animal(std::string name)
        : m_name(name)
    {
    }

public:
    string getName() { return m_name; }

	void speak() const
    {
        cout << m_name << " ??? " << endl;
    }
};


class Cat : public Animal
{
public:
    Cat(std::string name)
        : Animal(name)
    {
    }

    void speak() const
    {
        cout << m_name << " Meow " << endl;
    }
};

class Dog : public Animal
{
public:
    Dog(std::string name)
        : Animal(name)
    {
    }

    void speak() const
    {
        cout << m_name << " Woof " << endl;
    }
};

int main()
{
    Animal animal("my animal");
    Cat cat("my cat");
    Dog dog("my dog");

    animal.speak();
    cat.speak();
    dog.speak();

    // 자식 클래스를 부모 클래스의 포인터를 사용해서 casting 한 다음 호출을 하면 자기가 부모 클래스인 줄 알고 작동을 한다.
    Animal* ptr_animal1 = &cat;
    Animal* ptr_animal2 = &dog;

    ptr_animal1->speak();
    ptr_animal2->speak();
}

 

 

 

코드를 살펴보면, Animal이라는 부모 클래스가 있고, Cat과 Dog라는 자식 클래스가 Animal을 상속받고 있는 구조이다.

 

Animal, Cat, Dog 각각 speak()라는 멤버 함수가 있어서, 각각을 호출하는 것을 볼 수 있고

 

아래에서는 Animal의 포인터에 자식 클래스인 cat과 dog의 주소를 지정한 모습이다.

 

ptr_animal1과 ptr_animal2에서 speak() 함수를 사용하면, 부모 클래스인 Animal의 speak()를 사용하게 된다.

 

이처럼, 자식 클래스를 부모 클래스의 포인터를 사용해서 casting 한 다음 호출을 하게 되면 부모 클래스처럼 작동한다.

 

 

 

예제 코드 2

#include <iostream>

// 자식 클래스의 객체에 부모 클래스의 포인터를 사용한다면?
// ==> 다형성과 관련이 있다.

using namespace std;

class Animal
{
protected:
    string m_name;

public:
    Animal(std::string name)
        : m_name(name)
    {
    }

public:
    string getName() { return m_name; }

    void speak() const
    {
        cout << m_name << " ??? " << endl;
    }
};


class Cat : public Animal
{
public:
    Cat(std::string name)
        : Animal(name)
    {
    }

    void speak() const
    {
        cout << m_name << " Meow " << endl;
    }
};

class Dog : public Animal
{
public:
    Dog(std::string name)
        : Animal(name)
    {
    }

    void speak() const
    {
        cout << m_name << " Woof " << endl;
    }
};


int main()
{
    // 동물이 적으면 상관없는데, 많은 경우 for문을 계속 만들어야해서 이대로 하는건 한계가 있다.
    Cat cats[] = { Cat("cat1"), Cat("cat2"), Cat("cat3"), Cat("cat4"), Cat("cat5") };
    Dog dogs[] = { Dog("dog1"), Dog("dog2") };

    for (int i = 0; i < 5; i++)
    {
        cats[i].speak();
    }

    for (int i = 0; i < 2; i++)
    {
        dogs[i].speak();
    }

    // dog, cat 모두 같은 부모에서 상속을 받았기 때문에 캐스팅이 가능.
    Animal* my_animals[] = { &cats[0], &cats[1], &cats[2], &cats[3], &cats[4], &dogs[0], &dogs[1] };

    for (int i = 0; i < 7; i++)
    {
        my_animals[i]->speak();
    }
}

 

 

 

main문을 살펴보면, Cat과 Dog type으로 만든 array를 볼 수 있는데, 만약 이렇게 코드를 짜는 경우 동물 종류별로 전부 for문을 만들어야 해서 동물의 수가 많아지는 경우 부담이 클 수밖에 없다.

 

그런데 Animal type으로 array를 만들어서 cat과 dog를 한 번에 보관하면 하나의 for문으로 관리할 수 있어서 훨씬 편하다.

 

 

 

예제 코드 3

#include <iostream>

// 자식 클래스의 객체에 부모 클래스의 포인터를 사용한다면?
// ==> 다형성과 관련이 있다.

using namespace std;

class Animal
{
protected:
    string m_name;

public:
    Animal(std::string name)
        : m_name(name)
    {
    }

public:
    string getName() { return m_name; }

    // Virtual만 붙이면, 포인터는 부모 클래스에 대한 포인터인데, 자식 클래스인 것처럼 행동을 함.
    // 다형성
    virtual void speak() const
    {
        cout << m_name << " ??? " << endl;
    }
};


class Cat : public Animal
{
public:
    Cat(std::string name)
        : Animal(name)
    {
    }

    void speak() const
    {
        cout << m_name << " Meow " << endl;
    }
};

class Dog : public Animal
{
public:
    Dog(std::string name)
        : Animal(name)
    {
    }

    void speak() const
    {
        cout << m_name << " Woof " << endl;
    }
};


int main()
{
    Animal animal("my animal");
    Cat cat("my cat");
    Dog dog("my dog");

    animal.speak();
    cat.speak();
    dog.speak();

    // 자식 클래스를 부모 클래스의 포인터를 사용해서 casting 한 다음 호출을 하면 자기가 부모 클래스인 줄 알고 작동을 한다.
    Animal* ptr_animal1 = &cat;
    Animal* ptr_animal2 = &dog;

    ptr_animal1->speak();
    ptr_animal2->speak();


    // 동물이 적으면 상관없는데, 많은 경우 for문을 계속 만들어야해서 이대로 하는건 한계가 있다.
    Cat cats[] = { Cat("cat1"), Cat("cat2"), Cat("cat3"), Cat("cat4"), Cat("cat5") };
    Dog dogs[] = { Dog("dog1"), Dog("dog2") };

    for (int i = 0; i < 5; i++)
    {
        cats[i].speak();
    }

    for (int i = 0; i < 2; i++)
    {
        dogs[i].speak();
    }

    // dog, cat 모두 같은 부모에서 상속을 받았기 때문에 캐스팅이 가능.
    Animal* my_animals[] = { &cats[0], &cats[1], &cats[2], &cats[3], &cats[4], &dogs[0], &dogs[1] };

    for (int i = 0; i < 7; i++)
    {
        my_animals[i]->speak();
    }
}

 

 

 

 

이전 코드와 바뀐 점은, Animal class의 speak 함수에 virtual이라는 키워드를 추가한 것이다.

 

virtual 키워드를 추가해 주면, 부모 클래스의 포인터를 사용해서 casting 하더라도 자식 클래스인 것처럼 행동하게 된다.

 

이것이 바로 다형성의 핵심이다.

 

'부모 클래스 포인터를 사용해 실제 자식 객체의 오버라이드된 함수를 호출하는 것'

 

 

 

Chapter 12.2 가상함수와 다형성

 

 

이번 챕터에서는 상속이 여러 단계로 이루어졌을 때 virtual 키워드가 어떻게 적용되는지를 알아본다.

 

 

예제 코드 1

#include <iostream>

// Virutal을 사용하면 가상 함수라고 부름.

using namespace std;

class A
{
public:
    virtual void print() { cout << "A" << endl; }
};

class B : public A
//class B
{
public:
    void print() { cout << "B" << endl; }
};

class C : public B
{
public:
    void print() { cout << "C" << endl; }
};

class D : public C
{
public:
    void print() { cout << "D" << endl; } // return type을 변경할 수 없음. 에러 발생. return type이 같아야 함.
};

// virtual 키워드는 virtual 테이블을 찾아서 함수가 어딨나 찾아가는 과정을 거침.
// 찾아가는 과정이 있다보니 느림. 실시간으로 반응해야 하는 상황에서 for문에 virtual function으로 만들면 엄청 느려짐.

int main()
{
    A a;
    B b;
    C c;
    D d;

    A& ref = b;
    ref.print();

    A& ref2 = c;
    ref2.print();

    B& ref3 = c;
    ref3.print();

}

 

 

 

우선 class의 상속 구조부터 살펴보자.

 

B는 A를 상속하고, C는 B를 상속하고, D는 C를 상속하는 구조이다.

 

 

이전 강의에서와 약간 다르게, 이번에는 reference를 활용해서 구현한다.

 

A type의 reference에 b 타입의 인스턴스를 넣어준다.

 

현재 A class의 print 멤버 함수에 virtual 키워드를 사용하기 때문에, A 타입의 reference 임에도 B가 나오는 것을 확인할 수 있다.

 

 

 

다음은 A type의 reference에 c 타입의 인스턴스를 넣어준다.

 

A와 C는 다이렉트로 직접 상속을 하는 관계가 아니라, C가 B를 상속하고, B가 A를 상속해서 중간에 거쳐가는 관계이다.

 

그런데도 불구하고, A의 reference를 통해서 print 멤버 함수를 사용하게 되면 C에 있는 print 함수를 사용한다.

 

즉, 상속이 이루어지는 경우 가장 상위에 있는 부모 class를 기준으로 판단한다. 그래서 현재 코드에서는 A class에 virtual이 적용되면 C가 출력된다.

 

 

다음은 B type의 reference에 C 타입의 인스턴스를 넣어준다.

 

가장 상위 클래스에 있는 게 virtual이 적용되면, 밑에 있는 class 들도 virtual처럼 행동을 한다.

 

따라서 B class의 print 멤버 함수에 virtual이 적용되어 있지 않더라도, virtual처럼 행동한다.

 

그래서 결과가 C로 나오게 된다.

 

 

만약 class B가 class A를 상속하지 않으면, 결과는 B로 나오게 된다.

 

 

추가로, 자식 클래스에 있는 print에서 return type을 변경하는 것은 불가능하다. 

 

 

virtual 키워드는 virtual 테이블을 찾아서 함수를 찾아가는 과정을 거치다 보니, 느리다는 특징이 있다고 한다. 따라서 빠르게 반응해야 하는 경우에 사용하면 엄청 느려질 수 있다고 한다.

 

 

 

Chapter 12.3 override, final, 공변 반환값

 

 

이번 챕터에서는 오버라이드(override)를 강제하는 override keyword와, 오버라이드를 막아버리는 기능을 하는 final keyword, 그리고 공변 반환값에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>

using namespace std;


class A
{
public:
    virtual void print(int x) { cout << "A" << endl; }
};

class B : public A
{
public:
    void print(short x) { cout << "B" << endl; } // 함수는 파라미터가 다르면 오버라이딩을 할 수 없다.
};


class C : public B
{
public:
    virtual void print() { cout << "C" << endl; }
};

int main()
{
    
    A a;
    B b;

    A& ref = b;
    ref.print(1);
}

 

 

 

부모 클래스인 class A에 있는 멤버 함수인 print에 virtual을 적용했다.

 

그런데, class B의 void print에는 파라미터가 int x가 아닌 short x를 사용했다.

 

함수는 파라미터가 다르면 오버라이딩을 할 수 없다.

 

따라서 ref.print(1);을 사용했을 때 A type의 instance의 print를 적용하기 때문에 결과가 A로 나오게 된다.

 

 

 

예제 코드 2

#include <iostream>

using namespace std;


class A
{
public:
    virtual void print(int x) { cout << "A" << endl; }
};

class B : public A
{
public:
	// override keyword: 이 함수는 무조건 오버라이드 된 것이여야 한다라고 한정짓는 기능
    void print(short x) override { cout << "B" << endl; } 
};


class C : public B
{
public:
    virtual void print() { cout << "C" << endl; }
};

int main()
{
    
    A a;
    B b;

    A& ref = b;
    ref.print(1);
}

 

이전 코드와 달라진 점은, class B에 있는 void print에 override라는 키워드를 추가한 점이다.

 

override를 붙여주면, 이 함수는 무조건 오버라이드 된 것이어야 한다!라고 한정 짓게 된다.

 

그러나, 예제 코드 1번에서 언급했듯이 함수 파라미터가 다른 경우 오버라이드를 할 수 없기 때문에, 위 코드는 빌드 에러가 발생한다.

 

void print(int x) override와 같은 방식으로 짜면 문제없이 돌아간다.

 

만약 void print(int x) override로 만들어뒀다면, 출력의 결과는 B가 될 것이다.

 

override 키워드의 장점은, 만약 해당 함수를 반드시 오버라이드 해야 하는데 오버라이드의 조건이 맞지 않는 경우 미리 이를 확인해서 고칠 수 있다는 점이다.

 

 

 

예제 코드 3

#include <iostream>

using namespace std;


class A
{
public:
    virtual void print(int x) final { cout << "A" << endl; }
};

class B : public A
{
public:
    void print(int x) { cout << "B" << endl; } // 함수는 파라미터가 다르면 오버라이딩을 할 수 없다.
};


class C : public B
{
public:
    virtual void print() { cout << "C" << endl; }
};

int main()
{
    
    A a;
    B b;

    A& ref = b;
    ref.print(1);
}

 

다음은 final 키워드에 대한 설명이다.

 

class A에서 print 함수에 final 키워드를 적용하면, 해당 함수는 오버라이드 할 수 없게 된다.

 

따라서, class B의 void print() 함수에서 에러가 발생하게 된다.

 

 

 

예제 코드 4

#include <iostream>
using namespace std;

class A
{
public:
    void print() { cout << "A" << endl; }
    virtual A* getThis() {
        cout << "A: get this()" << endl;
        return this; }
};

class B : public A
{
public:
    void print() { cout << "B" << endl; }
    virtual B* getThis() { 
        cout << "B: get this()" << endl;
        return this; }
    // 원래는 Return type이 다르면 오버라이딩이 안되지만, 부모 자식 관계이기 때문에 오버라이딩이 가능함.
};

int main()
{
    A a;
    B b;

    A& ref = b;
    b.getThis()->print();
    ref.getThis()->print();
}

 

 

 

이번 코드에서는 공변 반환형에 대해서 알아본다.

 

코드를 살펴보면, class A와 class B에 각각 print() 함수가 있는 형태이고,

 

각각 A* return type과 B* return type을 가지는 getThis 함수를 가지고 있다.

 

원래는 Return type이 다르면 함수 오버라이딩은 안되지만, 부모 자식 관계인 경우는 가능하다고 한다.

 

 

main문을 살펴보자.

 

A reference 타입의 ref에 b을 연결한 상태이다.

 

b.getThis()->print();는 간단하게 결과를 예측할 수 있는데, 문제는 ref.getThis()->print();이다.

 

컴파일러는 참조의 실제 객체가 b라는 사실을 확인하고, B의 getThis()를 호출하게 된다.

 

따라서 B: get this ()가 출력된 것이고, B*으로 this pointer를 반환한다.

 

그런데, ref가 A type이기 때문에, B*를 리턴해주더라도 이걸 다시 A*로 내부적으로 바꾼 다음 class A의 print()를 실행하게 된다.

 

 

 

Chapter 12.4 가상 소멸자

 

 

이번 챕터에서는 가상(virtual) 소멸자에 대해서 다룬다.

 

 

 

예제 코드 1

#include <iostream>
using namespace std;

class Base
{
public:
     ~Base()
    {
        cout << "~Base()" << endl;
    }
};

class Derived : public Base
{
private:
    int* m_array;

public:
    Derived(int length)
    {
        m_array = new int[length];
    }

    ~Derived() 
    {
        cout << "~Derived()" << endl;
        delete[] m_array;
    }

};

int main()
{
    Derived derived(5);
}

 

 

 

이전 강의에서 다뤘던 내용이지만, 상속이 이루어지는 경우 생성자는 부모 클래스 꺼가 먼저 호출되고 자식 클래스꺼가 호출되는 구조였다.

 

그러나 소멸자는 이와 순서가 반대로, 자식 클래스의 소멸자가 먼저 호출되고 부모 클래스의 소멸자가 그 이후에 호출되는 구조이다.

 

 

 

예제 코드 2

#include <iostream>
using namespace std;

class Base
{
public:
     ~Base()
    {
        cout << "~Base()" << endl;
    }
};

class Derived : public Base
{
private:
    int* m_array;

public:
    Derived(int length)
    {
        m_array = new int[length];
    }

    ~Derived() 
    {
        cout << "~Derived()" << endl;
        delete[] m_array;
    }

};

int main()
{
    Derived* derived = new Derived(5);
    Base* base = derived;
    delete base;
}

 

 

main문을 살펴보자.

 

동적할당으로 derived 인스턴스를 만들어주고, Base 포인터에 derived를 집어넣었다. 그리고 base를 delete 했다.

 

그랬더니 Base의 소멸자만 호출이 되었고, derived의 소멸자는 호출이 되지 않았다.

 

 

대부분의 경우 자식 클래스가 무엇으로부터 만들어졌는지를 모르는 경우가 대부분이라서, 베이스 포인터를 이용해서 지우는 것을 시도하는 경우가 많다고 한다.

 

베이스 클래스는 하나이고, 자식 클래스는 여러 가지이기 때문이다. 예를 들어, 동물이 베이스 클래스라면 사자, 개, 호랑이 등이 자식 클래스가 되는 것이다.

 

다형성의 장점을 충분히 사용하려면 지울 때도 베이스 클래스를 이용해서 지우는 것이 일반적이라고 한다.

 

 

만약 다형성의 장점을 사용하는 경우라면, derived도 소멸이 되어야만 메모리가 깔끔하게 지워지는 것이기 때문에 현재와 같은 방식으로는 메모리 leak이 발생할 수밖에 없다.

 

 

 

예제 코드 3

#include <iostream>
using namespace std;

class Base
{
public:
     virtual ~Base()
    {
        cout << "~Base()" << endl;
    }
};

class Derived : public Base
{
private:
    int* m_array;

public:
    Derived(int length)
    {
        m_array = new int[length];
    }

    ~Derived() 
    {
        cout << "~Derived()" << endl;
        delete[] m_array;
    }

};

int main()
{
    Derived* derived = new Derived(5);
    Base* base = derived;
    delete base;
}

 

 

 

예제 코드 2번에서는 Derived가 소멸되지 않는 현상이 있었다.

 

이를 위해서 가상 소멸자를 적용해줘야 한다.

 

Base class의 소멸자인 ~Base()에 virtual을 적용해 주면, 부모 클래스 또한 소멸이 되게 된다.

 

 

 

Chapter 12.5 동적 바인딩과 정적 바인딩

 

 

이번 강의에서는 동적 바인딩과 정적 바인딩이 무엇이고, 어떤 차이가 있는지에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>
using namespace std;

int add(int x, int y)
{
    return x + y;
}

int subtract(int x, int y)
{
    return x - y;
}

int multiply(int x, int y)
{
    return x * y;
}

int main()
{
    int x, y;
    cin >> x >> y;

    int op;
    cout << "0 : add, 1: subtract, 2: multiply" << endl;
    cin >> op;


    // 모든 변수명이나 함수 이름들이 깔끔하게 빌드타임에 정리가 되어 있을 때 Early binding, static binding.
    int result;
    switch (op)
    {
    case 0:
        result = add(x, y);
        break;
    case 1:
        result = subtract(x, y);
        break;
    case 2:
        result = multiply(x, y);
        break;
    }

    cout << result << endl;
}

 

 

 

이번 예제에서는 static binding, 정적 바인딩에 대해서 알아본다.

 

add, subtract, multiply라는 함수가 정의되어 있고, cin을 이용해서 x, y, op을 입력받는다.

 

그리고 switch 문을 이용해 op가 어떤 값인지에 따라 result에 연산한 결과를 저장하게 된다.

 

코드만 봤을 때도, op가 지정되는 경우 모든 변수명과 함수 이름들이 깔끔하게 빌드 타임에 지정이 된다는 것을 알 수 있으며, 이런 케이스를 Early binding, 혹은 static binding이라고 부른다.

 

 

 

예제 코드 2

#include <iostream>
using namespace std;

int add(int x, int y)
{
    return x + y;
}

int subtract(int x, int y)
{
    return x - y;
}

int multiply(int x, int y)
{
    return x * y;
}

int main()
{
    int x, y;
    cin >> x >> y;

    int op;
    cout << "0 : add, 1: subtract, 2: multiply" << endl;
    cin >> op;

    // Dynamic binding, late binding
    // 컴파일 타임에 펑션 포인터가 뭐가 될지 결정되어 있지 않음.
    // 런타임에 op에 뭐가 들어오는지에 따라서 펑션 포인트에 add, subtract, multiply의 주소가 될지에 따라 변경됨.
    // pointer를 통해서 동적으로 할당이 된 함수의 포인터를 찾아가서 호출하는 방식임.
    // function pointer에 저장되어 있는 주소를 보고 그 주소를 쫓아가서 주소 자리에 있는 함수를 실행시키는 것.
    
    int(*func_ptr)(int, int) = nullptr;
    switch (op)
    {
    case 0:
        func_ptr = add;
        break;
    case 1:
        func_ptr = subtract;
        break;
    case 2:
        func_ptr = multiply;
        break;
    }

    cout << func_ptr(x, y) << endl;    
}

 

 

사실 결과는 이전과 큰 차이가 없지만, 차이는 컴파일 타임에 펑션 포인터가 뭐가 될지 결정되어 있지 않다는 점이다.

 

런타임에 op에 뭐가 들어오는지에 따라서 펑션 포인터에 add, subtract, multiply 중 하나의 주소가 결정된다.

 

pointer를 통해서 동적으로 할당된 함수의 포인터를 찾아가서 호출하는 방식이다.

 

 

속도의 관점에서 보면 static binding이 빠르다. dynamic binding은 주소를 타고 한번 더 가야 하기 때문에 느릴 수밖에 없다.

 

다만, dynamic binding을 잘 쓰면 프로그램이 훨씬 유연해진다는 장점이 있고, 잘 사용하면 장점이 잘 부각되게 사용할 수 있으나 잦은 반복이 일어나는 부분에서 사용하면 프로그램 오버헤드가 발생할 수 있다.

 

 

 

Chapter 12.6 가상 함수표

 

 

이번 챕터에서는 가상 함수표에 대해서 알아본다.

 

 

Base 클래스에는 virtual로 선언된 func1, func2 두 개를 가지고 있고, 자식 클래스에는 func1만 override 하는 상황.

 

virtual로 선언이 된 함수가 있는 경우, virtual function으로 표를 만들고 동적 바인딩을 시키게 됨.

 

 

Base 클래스 타입의 object를 하나 만들면, virtual table에 대한 포인터가 있음.(*_vptr)

 

my_base.func1()을 호출하려고 하면 정적 바인딩으로 호출하는 구조가 아니라 virtual function table을 찾고, 테이블 안에는 func1, func2에 대한 포인터를 가지고 있음. 

 

func1을 호출하고 싶다라고 한다면 table에서 func1을 찾고, func1을 가리키고 있는 함수로 감. 그리고 이 메모리에 있는 func1을 실행시키는 구조.

 

 

 

자식 클래스도 마찬가지로, virtual function table에 대한 포인터를 하나 가지고 있음.

 

my_derived.func1()처럼 호출하려고 하면, function table로 가고, 현재 func1이 override가 되어 있기 때문에 Der의 func1을 실행하게 됨.

 

그런데 func2는 Der에 없기 때문에, 부모 클래스에 있는 func2를 실행시키게 됨.

 

자식 클래스로 생성된 객체를 부모 클래스의 포인터나 레퍼런스로 집어넣어도, table은 바뀌지 않으므로 부모 클래스에 있는 func1을 호출하러 가기도 하고, 자식 클래스에 있는 func2를 호출하러 가기도 하는 구조를 유지하면서 다형성을 사용할 수 있다.

 

 

예제 코드 1

#include <iostream>
using namespace std;

class Base
{
public:
    // FunctionPointer *__vptr;
    void func1() {
        cout << "Base::func1" << endl;
    };
    void func2() {
        cout << "Base::func2" << endl;
    };
};

class Derived : public Base
{
public:
    // FunctionPointer *__vptr;
    void func1() {
        cout << "Derived::func1" << endl;
    };
    void func3() {
        cout << "Derived::func3" << endl;
    };
};


int main()
{
    cout << sizeof(Base) << endl;
    cout << sizeof(Derived) << endl;
}

 

 

 

현재 코드 기준으로는, virtual 함수가 하나도 없기 때문에 별도의 virtual function table이 존재하지 않는다. 따라서 size가 1이다.

 

 

 

예제 코드 2

#include <iostream>
using namespace std;

class Base
{
public:
    // FunctionPointer *__vptr;
    virtual void func1() {
        cout << "Base::func1" << endl;
    };
    virtual void func2() {
        cout << "Base::func2" << endl;
    };
};

class Derived : public Base
{
public:
    // FunctionPointer *__vptr;
    virtual void func1() {
        cout << "Derived::func1" << endl;
    };
    void func3() {
        cout << "Derived::func3" << endl;
    };
};


int main()
{
    cout << sizeof(Base) << endl;
    cout << sizeof(Derived) << endl;
}

 

 

func1과 func2에 virtual 키워드를 붙여주게 되면, virtual function table 포인터가 생기면서 각 인스턴스의 size는 4가 된다.

 

단, 이는 x86 기준이고, x64 기준이면 8이다.

 

 

예제 코드 3

#include <iostream>
using namespace std;

class Base
{
public:
    // FunctionPointer *__vptr;
    virtual void func1() {
        cout << "Base::func1" << endl;
    };
    virtual void func2() {
        cout << "Base::func2" << endl;
    };
};

class Derived : public Base
{
public:
    // FunctionPointer *__vptr;
    virtual void func1() {
        cout << "Derived::func1" << endl;
    };
    void func3() {
        cout << "Derived::func3" << endl;
    };
};


int main()
{
    cout << sizeof(Base) << endl;
    cout << sizeof(Derived) << endl;

    Base my_base;
    Derived my_derived;

    Base* base_ptr = &my_derived;

    base_ptr->func1(); // Derived::func1
    my_base.func1(); // Base::func1
    my_derived.func2(); // Base::func2

}

 

 

 

실제 적당한 예시를 직접 만들어서 실행해 본 결과이다.

 

Base 포인터 타입으로 base_ptr을 만들고, 여기에 my_derived의 주소를 연결해 두었다.

 

base_ptr -> func1(); 으로 호출하는 경우, 현재 func1이 virtual이기 때문에 자식 클래스에 있는 func1이 실행된다.

 

my_base.func1();은 Base class에 있는 func1을 실행해 주게 된다. (다형성 적용되지 않는 경우)

 

my_derived.func2();를 실행하면, func2는 Base에만 있기 때문에 Base에 있는 func2가 실행된다.

 

 

 

 

Chapter 12.7 순수 가상 함수, 추상 기본 클래스, 인터페이스 클래스

 

 

이번 챕터에서는 순수 가상 함수와 추상 기본 클래스, 그리고 인터페이스 클래스에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>
using namespace std;

class Animal
{
protected:
    std::string m_name;
public:
    Animal(std::string name)
        : m_name(name)
    {

    }

public:
    string getName() { return m_name; }
    virtual void speak() const = 0; // 순수 가상 함수(Pure virtual function)
    // 부모 클래스에 순수 가상 함수를 구현할 때는, 부모 클래스에서는 어떻게 할지 모르겠다. 근데 자식 클래스에서는 반드시 구현하라 라는 의미.
};

class Cat : public Animal
{
public:
    Cat(string name)
        : Animal(name)
    {
    }

    void speak() const
    {
        cout << m_name << " Meow " << endl;
    }
};

class Dog : public Animal
{
public:
    Dog(string name)
        : Animal(name)
    {

    }

    void speak() const
    {
        cout << m_name << " Woof " << endl;
    }
};

class Cow : public Animal
{
public:
    Cow(string name)
        : Animal(name)
    {

    }

    virtual void speak() const
    {
        cout << m_name << " Moo " << endl;
    }
};

int main()
{
    // Animal::speak가 순수 가상 함수로, instance를 만들 수 없음.
    //Animal ani("Hi");
    
    Cow cow("hello");
}

 

이번 예제에서는 순수 가상 함수와 추상 클래스에 대해서 얘기해 본다.

 

순수 가상 함수란, 바디가 없는 함수를 이야기한다.

 

이런 특징으로 인해, 자식 클래스에서 반드시 오버라이딩을 해줘야만 인스턴스를 만들 수 있다.

 

순수 가상 함수가 포함된 클래스를 추상 클래스라고 한다.

 

보통은 기본 클래스를 추상 클래스로 만든다. 

 

순수 가상 함수가 포함된 클래스인 추상 클래스는 인스턴스를 만들 수 없다.

 

순수 가상 함수의 특징 때문에, 순수 가상 함수는 부모 클래스를 설계할 때 자식 클래스들이 특정 함수를 반드시 override 하도록 강제하고 싶은 경우에 사용한다고 보면 된다.

 

즉, 클래스를 설계할 때 상속 구조 자체를 한꺼번에 설계하도록 도와주는 역할이라고 보면 된다.

 

 

만약 Cow에서 speak() 함수를 오버라이딩 해주지 않으면, instance를 만들 수 없다. 

 

 

 

 

예제 코드 2

using namespace std;

// 인터페이스일 경우 맨 앞에 I를 붙이는 관례
class IErrorLog 
{
public:
    virtual bool reportError(const char* errorMessage) = 0; // 순수 가상 함수

    virtual ~IErrorLog() {}
};

class FileErrorLog : public IErrorLog
{
public:
    bool reportError(const char* errorMessage) override
    {
        cout << "Writing error to file" << endl;
        return true;
    }
};


class ConsoleErrorLog : public IErrorLog
{
public:
    bool reportError(const char* errorMessage) override
    {
        cout << "Printing error to a console" << endl;
        return true;
    }
};


void doSomething(IErrorLog& log)
{
    log.reportError("Runetime error!!");
}

int main()
{
    FileErrorLog file_log;
    ConsoleErrorLog console_log;

    // 상속 구조로 되어 있어서, FileErrorLog도, ConsoleErrorLog도 IErrorLog 레퍼런스로 받을 수 있음.
    // dosomething 안에 if문으로 구분하면 불편한데, 이렇게 하면 편리.
    // IErrorLog 이렇게 클래스로 만들어두면 재활용 가능.
    doSomething(file_log);
    doSomething(console_log);
}

 

 

 

 

이번 예제에서는 인터페이스 클래스에 대해서 얘기해 본다.

 

인터페이스 클래스란, 클래스 내 모든 멤버 함수가 순수 가상 함수인 경우를 얘기한다.

 

즉, 자기가 실제로 뭘 할지는 아무것도 정의를 안 하고 있고, 자식 클래스들이 뭘 가져야 한다라는 것만 가지고 있는 구조이다.

 

인터페이스라고 부르는 이유는, 외부에서 사용할 때 이러이러한 기능이 있을 거다라고 예측을 할 수 있는 인터페이스 중계기 역할을 해주기 때문이다.

 

 

코드상에서 FileErrorLog라는 클래스가 있고, ConsoleErrorLog라는 클래스가 있는데, 이 두 클래스는 모두 IErrorLog를 상속받고 있다. 

 

따라서, 순수 가상 함수인 reportError라는 함수를 반드시 override 해줘야만 인스턴스를 생성할 수 있다.

 

그리고 doSomething 함수를 보면, 파라미터로 IErrorLog의 레퍼런스를 받고 있는데 이렇게 설계하는 경우 IErrorLog를 상속받는 모든 클래스를 인수로 받을 수 있다. 따라서, main문을 살펴보면 file_log라는 instance와 console_log라는 instance를 인수로 받고 있는 것을 볼 수 있다.

 

이렇게 만들어두면 dosomething 안에서 조건문으로 클래스를 나눈다거나 하는 방식이 불필요해진다. 

 

그리고 또 IErrorLog와 같은 인터페이스 클래스를 만들어두면 다른 거 개발할 때 재활용할 수 있다.

 

 

Chapter 12.8 가상 기본 클래스와 다이아몬드 상속 문제

 

 

이번 챕터에서는 상속을 배울 때 언급되었던 다이아몬드 상속 문제를 해결할 수 있는 방법인 가상 기본 클래스에 대해서 알아본다.

 

 

 

이전 강의에서, 다음과 같은 다이아몬드 상속 문제에 대해서 다룬 적이 있다.

 

B1와 B2가 A로부터 상속을 받는 구조이다.

 

 

그런데 실제로 구현할 때는 내부적으로 위와 같이 구현이 되는 경우도 있다고 한다.

 

따라서 이런 문제를 방지하기 위한 방법이 바로 가상 기본 클래스이다.

 

 

 

예제 코드 1

#include <iostream>
using namespace std;
// B1과 B2가 상속할 때 virtual을 붙여서, 가상 기본 클래스를 만든다.

class PoweredDevice
{
public:
    int m_i;

    PoweredDevice(int power)
    {
        cout << "PoweredDevice: " << power << '\n';
    }
};

class Scanner : public PoweredDevice
{
public:
    Scanner(int scanner, int power)
        : PoweredDevice(power)
    {
        cout << "Scanner: " << scanner << "\n";
    }
};

class Printer : public PoweredDevice
{
public:
    Printer(int printer, int power)
        : PoweredDevice(power)
    {
        cout << "Printer: " << printer << "\n";
    }
};

class Copier : public Scanner, public Printer
{
public:
    Copier(int scanner, int printer, int power)
        : Scanner(scanner, power), Printer(printer, power)
    {

    }
};


int main()
{
    Copier cop(1, 2, 3);

    // 타고 타고 들어가서 m_i의 주소를 출력해보면 서로 다름. 즉, 서로 다른 PoweredDevice 인 것.
    cout << &cop.Scanner::PoweredDevice::m_i << endl;
    cout << &cop.Printer::PoweredDevice::m_i << endl;
}

 

 

위 코드를 보면, PoweredDevice를 Scanner와 Printer가 상속하고 있는 구조이고, Copier아 Scanner와 Printer를 상속하는 구조이다. 즉, 다이아몬드 상속 구조가 된다.

 

main문을 살펴보면, cop에서 Scanner를 타고, PoweredDevice 안에 있는 멤버 변수 m_i의 주소를 확인하는 상황이다.

 

그런데, PoweredDevice가 두 번 호출되는 것을 볼 수 있고, 심지어 주소가 다른 것을 확인할 수 있다.

 

즉, 서로 다른 PoweredDevice가 존재하는 상황이라고 볼 수 있다.

 

 

 

 

예제 코드 2

#include <iostream>
using namespace std;
// B1과 B2가 상속할 때 virtual을 붙여서, 가상 기본 클래스를 만든다.

class PoweredDevice
{
public:
    int m_i;

    PoweredDevice(int power)
    {
        cout << "PoweredDevice: " << power << '\n';
    }
};

class Scanner : virtual public PoweredDevice
{
public:
    Scanner(int scanner, int power)
        : PoweredDevice(power)
    {
        cout << "Scanner: " << scanner << "\n";
    }
};

class Printer : virtual public PoweredDevice
{
public:
    Printer(int printer, int power)
        : PoweredDevice(power)
    {
        cout << "Printer: " << printer << "\n";
    }
};

class Copier : public Scanner, public Printer
{
public:
    Copier(int scanner, int printer, int power)
        : Scanner(scanner, power), Printer(printer, power),
        PoweredDevice(power)
    {

    }
};


int main()
{
    Copier cop(1, 2, 3);


    // 타고 타고 들어가서 m_i의 주소를 출력해보면 서로 다름. 즉, 서로 다른 PoweredDevice 인 것.

    // virtual 사용하는 경우, Copier 생성자의 member initializer list에서 PoweredDevice를 반드시 호출해줘야 함.
    cout << &cop.Scanner::PoweredDevice::m_i << endl;
    cout << &cop.Printer::PoweredDevice::m_i << endl;
}

 

 

 

 

예제 코드 1과 다른 점은, 중복 상속을 해주는 대상(PoweredDevice)을 상속받을 때 virtual을 붙여준다는 것이고,

 

또한 Copier의 생성자에서 member initializer list에서 PoweredDevice의 값을 준다는 점이다.

 

이렇게만 바꿔주면, PoweredDevice를 1번만 출력하게 되고, m_i의 주소도 같은 것을 확인할 수 있다.

 

 

 

Chapter 12.9 객체 잘림과 Reference wrapper

 

 

이번 챕터에서는 부모 클래스의 객체에다가 자식 클래스의 객체를 강제 대입할 때 발생하는 현상인 객체 잘림과 이를 방지하는 데 사용되는 Reference wrapper에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>

using namespace std;

class Base
{
public:
    int m_i = 0;

    virtual void print()
    {
        cout << "I'm Base" << endl;
    }
};

class Derived : public Base
{
public:
    int m_j = 1;

    virtual void print() override
    {
        cout << "I'm Derived" << endl;
    }
};

void doSomething(Base& b)
{
    b.print();
}

int main()
{
    Derived d;
    Base b = d; // 복사 대입
    // d만 담고 있는 정보를 b가 담을 수 없음.
    // 다형성이 사라짐.

    b.print(); // Base로 실행이 된다.
    
    doSomething(d); // Derived가 나온다.
}

 

 

이번 챕터들에서 다루었던 다형성에 대해서 다시 한번 정리하면,

 

다형성은 부모 클래스의 포인터(혹은 레퍼런스)를 사용해서 자식 객체의 오버라이드 된 함수를 호출하는 방식이었다.

 

즉, Base& b = d; 라고 사용하면 부모 클래스의 레퍼런스를 사용해서 자식 객체의 오버라이드 된 함수를 호출할 수 있다.

 

그런데, 이번 예제 코드는 포인터나 레퍼런스를 사용하는 것이 아니라 그냥 Base b = d; 방식으로 복사 대입을 해버렸다.

 

일반적으로 부모 클래스에 비해서 자식 클래스가 더 많은 정보를 가지고 있기 때문에, 이렇게 부모 클래스의 객체에다가 자식 클래스의 객체를 강제로 복사시켜버리면 자식 클래스의 정보가 손실된다. 이 때문에 다형성을 사용할 수 없다.

 

 

doSomething은 Base& b를 파라미터로 받고, 현재 자식 클래스 인스턴스인 d를 인수로 준 상황이라 Derived라는 결과가 나온다.

 

단, 만약 doSomething이 Base b를 파라미터로 받으면 동일하게 객체 잘림 현상이 발생되면서 다형성을 사용할 수 없다.

 

 

 

예제 코드 2

#include <iostream>
#include <vector>

using namespace std;

class Base
{
public:
    int m_i = 0;

    virtual void print()
    {
        cout << "I'm Base" << endl;
    }
};

class Derived : public Base
{
public:
    int m_j = 1;

    virtual void print() override
    {
        cout << "I'm Derived" << endl;
    }
};

void doSomething(Base& b)
{
    b.print();
}

int main()
{
    std::vector<Base> my_vec;
    my_vec.push_back(b);
    my_vec.push_back(d);

    for (auto& ele : my_vec)
    {
        ele.print(); // 모두 Base가 나온다. 두 번째는 Derived를 넣었지만, push_back을 하면서 slicing이 발생했기 때문.
    }
}

 

 

 

이번에는 std::vector에 Base 클래스를 담는 경우에 대해서 알아본다.

 

my_vec에다가 b와 d를 각각 push_back 했고, 이에 대해서 각각 print()를 찍어보면 모두 Base가 나오는 것을 알 수 있다.

 

이 또한 push_back을 하면서 slicing이 발생했기 때문이다.

 

 

 

예제 코드 3

#include <iostream>
#include <vector>

using namespace std;

class Base
{
public:
    int m_i = 0;

    virtual void print()
    {
        cout << "I'm Base" << endl;
    }
};

class Derived : public Base
{
public:
    int m_j = 1;

    virtual void print() override
    {
        cout << "I'm Derived" << endl;
    }
};

void doSomething(Base& b)
{
    b.print();
}

int main()
{
    std::vector<Base*> my_vec;
    my_vec.push_back(&b);
    my_vec.push_back(&d);
    for (auto& ele : my_vec)
    {
        ele->print(); // Base, Derived가 나온다.
    }
}

 

 

 

이전 예제와는 다르게, std::vector아 Base type의 pointer를 담을 수 있도록 만들어두었고 push_back을 할 때도 b와 d의 주소를 담도록 했다.

 

이렇게 하면 정상적으로 Base와 Derived가 나오는 것을 확인할 수 있다.

 

 

 

예제 코드 4

#include <iostream>
#include <vector>
#include <functional>

using namespace std;

class Base
{
public:
    int m_i = 0;

    virtual void print()
    {
        cout << "I'm Base" << endl;
    }
};

class Derived : public Base
{
public:
    int m_j = 1;

    virtual void print() override
    {
        cout << "I'm Derived" << endl;
    }
};

void doSomething(Base& b)
{
    b.print();
}

int main()
{
    std::vector<std::reference_wrapper<Base>> my_vec; // Base의 레퍼런스를 저장하는 벡터가 된다.
     my_vec.push_back(b);
     my_vec.push_back(d);

     for (auto& ele : my_vec)
     {
         ele.get().print(); // Base, Derived가 나온다.
     }
}

 

 

 

 

만약 std::vector에 pointer가 아니라 reference를 사용하는 방식으로 하고 싶다면?

 

그럴 때 사용하는 것이 바로 std::reference_wrapper이다. 

 

std::reference_wrapper<Base>를 하면 std::vector<Base&>처럼 작동하게 된다. 

 

이때, ele.print();로 사용할 수는 없고 ele.get().print();로 작성해주어야 한다는 차이점이 있다.

 

 

 

Chapter 12.10 동적 형변환

 

 

이번 챕터에서는 자식 클래스의 포인터를 부모 클래스의 포인터로 바꿨다가, 다시 자식 클래스의 포인터로 바꿔줄 때 사용할 수 있는 동적 형변환에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>
using namespace std;

class Base
{
public:
    int m_i = 0;

    virtual void print()
    {
        cout << "I'm Base" << endl;
    }
};

class Derived1 : public Base
{
public:
    int m_j = 1024;

    virtual void print() override
    {
        cout << "I'm derived" << endl;
    }
};

class Derived2 : public Base
{
public:
    string m_name = "Dr. Two";

    virtual void print() override
    {
        cout << "I'm derived2" << endl;
    }

};

int main()
{
    Derived1 d1;
    Base* base = &d1;
    
    auto* base_to_d1 = dynamic_cast<Derived1*>(base);
    
    if (base_to_d1 != nullptr)
    {
        cout << base_to_d1->m_j << endl;
        base_to_d1->print();
    }
    else
    {
        cout << "Failed! " << endl;
    }
}

 

 

 

앞에서 다형성에 대해서 계속 얘기했듯이, Base* base = &d1;은 부모 클래스의 포인터를 사용해서 자식 클래스의 오버라이드 된 함수를 호출할 수 있게 해 준다.

 

그런데 만약 base를 다시 자식 클래스인 Derived1의 포인터로 바꿔야한다면? 이때 사용할 수 있는 것이 바로 동적 형변환인 dynamic_cast이다.

 

dynamic_cast를 이용하면, base_to_d1가 다시 Derived1* type으로 바뀌면서, base_to_d1->m_j나, base_to_d1->print()처럼 클래스 내 멤버 변수 및 멤버 함수에 접근할 수 있다.

 

특이한 점은, dynamic_cast는 컴파일 타임에 형변환이 가능한지 별도의 검사를 하고 만약 불가능한 경우는 base_to_d1에 nullptr를 준다.

 

그래서 dynamic_cast를 사용하는 경우에는, if문으로 nullptr가 아닌지를 검사해서 돌아가도록 만들어야 한다.

 

 

그렇다면 이전에 많이 사용했던 static_cast는 불가능할까?

 

static_cast도 사용이 가능하다. 그런데, static_cast는 컴파일 타임에 형변환이 가능한지 별도의 검사를 하지 않고 일단 밀어붙이게 된다. 그래서 안되어야 할 것도 되는 경우가 있다는 특징이 있다.

 

조금 더 안전하게 형변환을 하는 방향이 dynamic_cast라고 볼 수 있겠다.

 

다만, 자꾸 왔다 갔다 바꾸는 것이 좋은 것은 아니라서, 꼭 필요한 게 아니라면 실제 사용하기 전에 한번 더 생각해 볼 필요가 있다.

 

 

 

Chapter 12.11 유도 클래스(Derived class)에서 출력 연산자 사용하기

 

 

이번 챕터에서는 자식 클래스에서 출력 연산자를 사용하는 방법에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>

// 유도 클래스에서 출력 연산자를 다형성과 함께 사용하려면.

class Base
{
public:
    Base() {}

    // 멤버로 만들 수 없는 함수를 오버라이딩 한 것처럼 사용하기 위해서
    // 기능을 위임받은 일반 함수를 구현해서 virtual로 만들고 자식 클래스에서 오버라이딩 함.
    friend std::ostream& operator << (std::ostream& out, const Base& b)
    {
        return b.print(out);
    }

    virtual std::ostream& print(std::ostream& out) const
    {
        out << "Base";
        return out;
    }
};

class Derived : public Base
{
public:
    Derived() {}

    virtual std::ostream& print(std::ostream& out) const override
    {
        out << "Derived";
        return out;
    }
};




int main()
{
    Base b;
    std::cout << b << "\n";

    Derived d;
    std::cout << d << "\n";

    // operator overloading 이루어지며, Base가 들어와야 하는데 Derived가 들어오게 되면 알아서 Base로 변환. Virtual Function call 이루어짐.
    Base& bref = d;
    std::cout << bref << '\n';
}

 

 

 

이번 예제에서는 다형성 + 연산자 오버로딩을 같이 다루게 된다.

 

Base 클래스를 보면, << 라는 연산자에 대해 오버로딩을 한 것을 볼 수 있고, 특이한 것은 Base class의 reference를 입력받아서 b.print(out)를 해준다.

 

이렇게 하면 이전에도 배웠듯이 부모 클래스의 reference가 들어오던, 혹은 자식 클래스의 reference가 들어오던 사용할 수 있다.

 

그리고 연산자 오버로딩은 멤버 함수로 사용할 수 없기 때문에, 이를 print라는 일반 함수를 구현해서 virtual로 만들고 자식 클래스에서 오버라이딩 하는 방식을 사용했다.

 

 

 

그래서 bref를 << 연산자로 출력하게 되면, 먼저 Base 클래스의 operator <<에 들어가고, 그다음 b.print(out);를 실행하게 되며 이때 연산자 오버라이딩이 일어나면서 Derived class의 print가 실행된다.

 

 

 

 

여기까지 Chapter 12를 모두 정리해 보았다.

 

올해 안에 이 강의를 모두 정리하는 것이 목표인데, 남은 기간 열심히 해서 꼭 마무리해봐야겠다.

 

 

이번 글에서는 Chapter 11을 정리해 본다.

 

 

 

 

 

Chapter 11.1 상속의 기본(1)

 

 

 

이번 챕터에서는 상속이 무엇인지에 대한 기본적인 내용을 다룬다.

 

 

 

예제 코드 1

#include <iostream>

using namespace std;

// 객체 지향에서의 상속은 부모 클래스가 사라진다거나 그런건 아님. 

class Mother
{
//protected: // protected 사용 시 자식한테는 허용됨.
private:
    int m_i;

public:
    
    Mother(const int& i_in)
        : m_i(i_in)
    {
        std::cout << "Mother Constructor" << std::endl;
    }
    

    void setValue(const int& i_in)
    {
        m_i = i_in;
    }

    int getValue()
    {
        return m_i;
    }
};

// Mother class를 상속 받는다.
// Child: Derived class
// Mother: Generalized class
class Child : public Mother // Child class is derived from Mother class
{
    // Child class는 Mother class로 받은 것들을 모두 사용할 수 있음. 기본적으로는 다 사용함.
    // 인캡슐레이션을 유지하기 위해 조금 더 복잡한 요소들이 있음.
    // 상속 -> 재사용이 편하다.

private:
    double m_d;

public:

    // Constructor가 존재하는 이유는 메모리가 할당되기 전에 이렇게 해주세요 라는 의미이다.
    // Child class를 생성할 때 m_i 메모리가 없음, member initialization list 사용 X

    // Child 클래스가 생성될 때 Mother 클래스의 생성자를 같이 호출함.
    // 결과적으로 이렇게 하는게 좋아서. child 클래스가 Mother 클래스에 대해서 아는 내용이 적을수록 좋다.
    Child(const int& i_in, const double& d_in)
        : Mother(i_in), m_d(d_in)
    {
        //Mother::setValue(i_in);
        //m_d = d_in;
    }

    void setValue(const int& i_in, const double& d_in)
    {
        Mother::setValue(i_in);
        m_d = d_in;
        //m_i = i_in;
    }

    void setValue(const double& d_in)
    {
        m_d = d_in;
    }

    double getValue()
    {
        return m_d;
    }
};



int main()
{
    Mother mother(1024);
    mother.setValue(1024);
    cout << mother.getValue() << endl;

    Child child(1024, 128);
    //child.Mother::setValue(1000);
    //child.setValue(128);
    cout << child.Mother::getValue() << endl;
    cout << child.getValue() << endl;

}

 

 

 

우선 글로 설명할 내용들부터 정리하고자 한다.

 

객체 지향에서의 상속은 부모 클래스가 사라지는 개념은 아니고, 부모 클래스에서 받은 것들을 모두 사용할 수 있다는 개념이다.

 

그대로 사용할 수 있다는 점에서 재사용성이 좋아진다는 특징이 있다.

 

영어 표기 상으로는, 부모 클래스를 Generalized class라고 하고, 상속을 받은 자식 클래스를 Derived class라고 부른다.

 

 

부모 클래스부터 먼저 살펴보자.

 

m_i라는 int형 멤버 변수를 가지고 있고, int형 변수를 받는 생성자가 만들어져 있으며 setValue와 getValue라는 멤버 함수를 가지고 있다.

 

 

다음으로는 자식 클래스를 살펴보자.

 

상속을 받을 때는 class child : public Mother와 같은 문법을 사용해 상속 여부를 표시해 준다.

 

m_d라는 double형 멤버 변수를 가지고 있고, 자식 클래스는 생성자에서 int 변수와 double 변수를 모두 받게 되어 있다.

 

생성자에서 member initializer list를 사용하는데, 부모 클래스의 멤버 변수가 private으로 되어 있는 상태이기 때문에 직접적으로 m_i를 접근할 수는 없으며, 따라서 Mother 생성자를 호출하도록 만들어서 m_i의 값을 설정할 수 있다.

 

member initializer list를 사용하지 않으려면, Mother::setValue()를 이용해서 부모 클래스 내에 있는 멤버 함수를 호출하는 방법도 가능하다.

 

자식 클래스가 생성될 때 부모 클래스의 생성자를 같이 호출하는 것이 상속의 특징인데, 그렇다 보니 부모 클래스의 멤버 변수를 public으로 한다고 해도 member initializer list에서 부모 클래스의 멤버 변수를 직접 접근해서 사용할 수는 없다. 자식 클래스의 생성자가 호출될 때 아직 부모 클래스의 생성자가 완전히 실행되지 않았을 수 있기 때문이다. 따라서 C++ 컴파일러가 이를 문법적으로 허용하지 않는다고 한다.

 

 

main문을 살펴보면, Child class의 인스턴스인 child를 만든 뒤, child.Mother::getValue()와 같은 방식으로 부모의 멤버 함수를 사용하는 것을 볼 수 있다.

 

Child class의 생성자에서 반드시 Mother의 생성자를 명시적으로 불러야 하는가? 에 대해서는, 현재 소스 코드상에서 Mother class에 사용자 정의 생성자가 있기 때문에, child 클래스의 생성자는 반드시 Mother class의 생성자를 호출해야 한다고 한다.

 

 

 

Chapter 11.2 상속의 기본(2)

 

 

이전에는 간단하게 구조만 살펴보았다면, 이번 챕터에서는 실제 사례에 가까운 예시를 통해서 더 자세히 알아본다.

 

 

예제 코드 1

Chapter11_2.cpp

#include <iostream>
#include "Teacher.h"
#include "Student.h"

// 기본적으로는 코드에서 중복되는 부분을 한군데 모아놓고 재사용해서 작업하는 양을 줄여주는 것이 기본적인 동기이다.

// Stduent.h와 Teacher.h에 있는 operator overloading 사용 시 const 안 붙여서 에러남
// 이는 Person.h에 있는 getName()에 const만 붙여주면 해결.
// ==> 객체지향을 사용하는 이유: 중복되는 기능이 있을 때 그 기능을 수정하거나 유지보수를 하거나 할 때 부모 클래스만 수정해주면 한번에 해결.

int main()
{
	Student std("Jack Jack");
	std.setName("JAACK");
	std::cout << std.getName() << std::endl;


	Teacher teacher1("Dr. H");
	teacher1.setName("Dr. K");

	std::cout << teacher1.getName() << std::endl;

	std::cout << std << std::endl;
	std::cout << teacher1 << std::endl;

	std.doNothing();
	teacher1.doNothing();

	std.study();
	teacher1.teach();

	Person person;
	person.setName("Mr. Incredible");
	person.getName();
	// 자식 클래스에 구현된 것을 부모 클래스가 가져가진 못함.
	// person.study();
	// person.teach();
}

 

 

Person.h

#pragma once

#include <string>
#include <iostream>


// m_name에 대한 관리 책임은 Person class가 가지고 있다.

class Person
{
private:
	std::string m_name;

public:
	Person(const std::string& name_in = "No Name")
		: m_name(name_in)
	{

	}

	void setName(const std::string& name_in)
	{
		m_name = name_in;
	}

	std::string getName() const
	{
		return m_name;
	}

	void doNothing() const
	{
		std::cout << m_name << " is do nothing" << std::endl;
	}
};

 

 

Teacher.h

#pragma once

//#include <string>
#include "Person.h"


class Teacher : public Person
{
private:

public:
	Teacher(const std::string& name_in = "No Name")
		//: m_name(name_in)
		: Person(name_in)
	{
		//this->getName(); // 부모 클래스에 있는 것을 자기가 가져다 쓸 수 있어서 사용 가능.
	}
	
	friend std::ostream& operator << (std::ostream& out, const Teacher& teacher)
	{
		//out << teacher.m_name; // m_name 을 직접 접근할 수 없는 상황이라 getName으로 받아서 출력한다.
		out << teacher.getName();
		return out;
	}

	void teach()
	{
		std::cout << getName() << " is teaching " << std::endl;
	}

};

 

 

 

Student.h

#pragma once

//#include <string>
#include "Person.h"


// Student가 호출이 되는 상황에서는 m_name이 정의가 되어 있지 않다
// -> 생성자는 Person에서 담당을 해줘야함.
// Student에서 Person의 생성자를 호출해서 간접적으로 초기화하는 방식으로 간다.

// Person에 있는 m_name을 사용할 수는 있는데, 관리 책임은 일차적으로 Person에 있다.
// 따라서 Encapsulation을 깔끔하게 정리하려면 영역을 나눠주는 것이 좋기 때문에
// 간접적으로 Student의 생성자를 통해서 Person의 생성자를 호출하는 방식으로 구현을 한다.

// 중복되는 것들은 가급적 Person으로 보내버리고, 중복되지 않는 특별한 요소들만 자식 클래스에 추가로 구현을 해서 넣는 것이 상속을 사용하는 효율적인 방법임.
// setIntel은 Teacher class에 없으므로 Student class에서 유지됨.

class Student : public Person
{
private:
	int m_intel;

public:
	Student(const std::string& name_in = "No Name", const int& intel_in = 0)
		//: m_name(name_in), m_intel(intel_in)
		: Person(name_in), m_intel(intel_in)
	{
	}

	void setIntel(const int& intel_in)
	{
		m_intel = intel_in;
	}

	friend std::ostream& operator << (std::ostream& out, const Student& stu_in)
	{
		out << stu_in.getName() << " " << stu_in.m_intel;
		return out;
	}

	void study()
	{
		std::cout << getName() << " is studying " << std::endl;
	}
};

 

 

이번 예제 코드에는 학생과 선생님으로 구성되어 있다.

 

학생도 사람이고, 선생님도 사람이기 때문에, 이는 Is-a 관계에 해당하며 이런 경우는 상위 개념의 부모 클래스를 만들어서 코드를 단축시킬 수 있다.

 

현재 코드상에서 보면, 학생도 선생님도 각 클래스가 멤버 변수로 이름을 가져야 하고, 이름을 정해주는 함수인 setName과 이름을 return 해주는 함수인 getName이 필요하다.

 

변수와 함수가 모두 같은 기능을 하기 때문에, 상위 클래스를 하나 만들어주고 상속을 해주는 방식으로 간다.

 

 

 

이를 도표로 표현하면 아래와 같다.

 

 

 

 

 

Teacher의 헤더파일 먼저 살펴보겠다.

 

Teacher class는 Person class를 상속받는 것을 확인할 수 있고,

 

생성자를 살펴보면 m_name(name_in)와 같은 방식으로 member를 초기화할 수 없는 것을 볼 수 있다.

 

이는 m_name이 현재 Person class에 멤버 변수로 들어가 있기 때문에 m_name에 대해서는 access를 할 수 없기 때문이다.

 

그 대신, Person 클래스의 생성자에서 Person 클래스 호출 시 m_name을 name_in 값으로 지정하게끔 해주고 Teacher class의 생성자에서 Person class의 생성자를 호출하도록 하면 이를 간접적으로 구현할 수 있다.

 

Teacher만 고유하게 사용하는 멤버 함수인 teach()를 가지고 있다.

 

 

 

Student의 헤더파일도 마찬가지이다.

 

Student의 생성자에서 m_name을 접근할 수 없어서 Person의 생성자를 호출해서 처리한 것을 확인할 수 있다.

 

그리고 Teacher와는 다르게 Student는 m_intel이라고 하는 int형 멤버 변수를 별도로 가지고 있다.

 

따라서 Student는 사실상 2개의 멤버 변수(m_name, m_intel)를 가지고 있는 것이나 다름이 없다.

 

Student만 고유하게 사용하는 멤버 함수인 study()를 가지고 있다.

 

 

 

 

 

main문을 살펴보자.

 

Student class로 std라는 인스턴스를 만들었고, Teacher class로 teacher1이라는 인스턴스를 만들어주었다.

 

각각 setName()과 getName()을 사용해 준 것을 볼 수 있다.

 

doNothing()은 Person class에 있던 함수로, 이름과 is do nothing을 출력해 주는 함수이다.

 

그리고 학생은 study()를, 선생은 teach()를 출력한 것을 확인할 수 있다.

 

 

 

사실 코드가 복잡하기보다는, 상속에 대한 개념을 익히는 것이 중요하다고 본다.

 

결국 상속이라는 것은 중복되는 클래스 내 변수나 함수를 상위 클래스로 통합시켜서 코드를 간결하게 만드려고 하는 것이라고 보면 될 것 같다.

 

 

 

 

Chapter 11.3 유도된 클래스의 생성 순서

 

 

이번 챕터에서는 부모 클래스와 자식 클래스가 있을 때 어떤 순서로 생성되는지에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>

class Mother
{
public:
    int m_i;

    // 특정 값으로 초기화 하는 기능 + Default도 가능하게 만드는 방법( = 0 넣어두면 default constructor 기능 함)
    Mother(const int& i_in = 0)
        : m_i(i_in)
    {
        std::cout << "Mother Constructor " << std::endl;
    }
};

class Child : public Mother
{

public:
    double m_d;

public:
    Child()
        //: m_i(1024)
        //: Mother(), m_d(1.0) // Mother()는 따로 타이핑하지 않더라도 숨어있는 것이다.
        : m_d(1.0), Mother(1024) // 순서를 바꿔주더라도 부모 클래스의 Constructor를 우선 실행.
    {
        m_i = 10; // 부모 클래스의 constructor에서 초기화가 된 이후에 메모리로 잡히면 그때는 m_i에 접근해서 수정 가능함.
        std::cout << "Child Constructor " << std::endl;
    }
};



// Mother에 있는 것을 다 초기화한 다음에 Child를 초기화 하기 시작함.
// 따라서, Child의 생성자에서 m_i를 강제로 초기화할 수 없다.
// 이는 Mother에서 먼저 한 후에야 건드릴 수 있기 때문.

int main()
{
    Child c1;
    std::cout << "Hello World!\n";
}

 

 

 

이번 코드에서는 자식 클래스(Child class)와 부모 클래스(Mother class)가 있는 케이스를 다룬다.

 

 

Mother class부터 살펴보겠다.

 

int형 멤버 변수인 m_i를 가지고 있고, 생성자를 가지고 있다. 생성자는 매개변수로 값을 받으면 그 값으로 m_i를 초기화해 주고, 그러지 않은 경우에는 0으로 초기화해 준다. i_in = 0으로 해줘야만 default constructor 기능이 수행된다.

 

 

Child class를 살펴보겠다.

 

double형 멤버 변수인 m_d를 가지고 있고, 생성자를 가지고 있다.

 

생성자의 member initializer list에는 m_d(1.0)와 Mother(1024)를 가지고 있다.

 

알아야 할 내용은, 만약에 member initializer list에 m_d(1.0)만 적고 Mother(1024)를 적지 않더라도 기본적으로는 Mother()가 수행되며, 이는 숨어있는 것이다.

 

또한, member initializer list에서 m_d(1.0), Mother(1024)를 작성하든 Mother(1024), m_d(1.0)를 작성하든 동일하게 Mother 생성자부터 실행된다.

 

 

Debug 모드로 바꿔서 실행해 보면, 다음과 같은 흐름으로 진행된다.

 

1) Child c1;을 실행시키면 가장 먼저 Child class의 생성자로 이동한다.

 

2) Child class 생성자의 member initializer list에 접근한다.

 

3) Mother class의 생성자로 이동한다.

 

4) Mother class의 생성자 내 member initializer list를 실행한다

 

5) Mother class의 생성자 내 함수 body를 실행한다.

 

6) 다시 Child class의 생성자로 이동한다.

 

7) Child class의 생성자 내 함수 body를 실행한다.

 

 

이와 같은 흐름을 고려한다면, Child class의 생성자 내 member initializer list에서 m_i(1024)와 같은 명령어는 수행할 수 없다.

 

왜냐하면, 실행 흐름상 Child class의 생성자 내 member initializer list가 가장 먼저 수행되는데, 이때에는 아직 Mother class의 생성자를 실행하기 전이기 때문에 m_i이라고 하는 변수가 메모리 상에 입력되기 전이기 때문이다. 

 

그리고, Child class의 생성자 내 함수 body를 보면, m_i를 명시적으로 10이라는 값으로 바꿔줄 수 있는 것을 볼 수 있는데, 이는 실행 흐름 상 Child class의 생성자 내 함수 body를 실행하는 시점에는 이미 Mother class의 생성자가 모두 실행된 이후이기 때문에 m_i가 메모리에 존재하는 시점이기 때문이다.

 

 

 

예제 코드 2

#include <iostream>

class A
{
public:
    A()
    {
        std::cout << "A Constructor" << std::endl;
    }
};

class B : public A
{
public:
    B()
    {
        std::cout << "B Constructor" << std::endl;
    }
};

class C : public B
{
public:
    C()
    {
        std::cout << "C Constructor" << std::endl;
    }
};



int main()
{
    C c1;
}

 

 

 

만약 여러 번의 상속을 진행하는 경우는 어떻게 될까?

 

이번에는 B가 A의 상속을 받고, C가 B의 상속을 받는 케이스를 만들어보았다.

 

우리가 앞에서 봤듯이, 상속이 일어나면 부모 class의 생성자를 먼저 생성하도록 하기 때문에, 이를 연쇄적으로 생각해 보면 당연히 A > B > C 순으로 실행되는 것을 확인할 수 있다.

 

 

 

 

Chapter 11.4 유도된 클래스들의 생성과 초기화

 

 

이번 챕터에서는 유도된 클래스의 size에 대해서 알아보고, 생성과 초기화 순서에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>

using namespace std;

class Mother
{
private:
    int m_i;

public:
    Mother(const int& i_in = 0)
        : m_i(i_in)
    {
        std::cout << "Mother Constructor " << std::endl;
    }
};

class Child : public Mother
{

public:
    float m_d;

public:
    Child()
        : m_d(1.0), Mother(1024)
    {
        std::cout << "Child Constructor " << std::endl;
    }
};

class Check
{
public:
    float m_f;


};

int main()
{
    Child c1;

    // Child class에서 보이는 건 m_d 뿐이지만, 실제로는 Mother class에 선언되어 있는 변수까지 사용할 수 있으므로
    // 이를 포함해서 큰 메모리 사이즈를 할당 받게 된다.

    std::cout << sizeof(Mother) << std::endl;
    std::cout << sizeof(Child) << std::endl;
    std::cout << sizeof(Check) << std::endl;

}

 

 

 

이번 예제 코드에서는 과연 상속이 이루어지는 경우 클래스의 사이즈는 어떻게 될지에 대해서 알아본다.

 

Mother class는 int형 변수 1개만 멤버 변수로 가지고 있고, Child class는 float형 변수 1개를 멤버 변수로 가지고 있다.

 

그리고 비교를 위해 Check class를 만들고 float형 변수 1개를 멤버 변수로 가지도록 만들었다.

 

 

Check class를 보면 알겠지만, float형 변수 1개는 size 상으로 4 byte에 해당한다.

 

그런데, Child class는 float형 변수 1개만 가지고 있음에도 size 상으로 8 byte인 것을 확인할 수 있다.

 

이는 상속이 이루어지는 경우, 부모 클래스에 선언되어 있는 변수까지 사용할 수 있도록 더 큰 메모리 사이즈를 할당받게 되기 때문이다.

 

따라서 Mother class의 멤버 변수 크기 + Child class 멤버 변수 크기를 감안해서 할당이 된다고 보면 된다.

 

 

 

예제 코드 2

#include <iostream>

using namespace std;

class A
{
public:
    A(int a)
    {
        cout << "A: " << a << endl;
    }

    ~A()
    {
        cout << "Destructor A" << endl;
    }
};

class B : public A
{
public:
    B(int a, double B)
        : A(a)
    {
        cout << "B: " << B << endl;
    }

    ~B()
    {
        cout << "Destructor B" << endl;
    }
};

class C : public B
{
public:
    C(int a, double B, char c)
        : B(a, B)
    {
        cout << "C: " << c << endl;
    }

    ~C()
    {
        cout << "Destructor C" << endl;
    }
};



int main()
{
    C c(1024, 3.14, 'a');
}

 

 

 

이번 예제 코드에서는 생성과 소멸 순서에 대해서 살펴본다.

 

이전 챕터와 동일하게, B는 A의 상속을 받는 class이고, C는 B의 상속을 받는 class로 구성이 되어 있다.

 

A, B, C class의 생성자에는 각각 A, B, C의 값을 출력하도록 해두었고, 소멸자에는 각각 Destructor A, B, C가 출력되도록 했다.

 

 

 

출력된 순서를 살펴보면, 이전 챕터에서도 보았듯이, 가장 상위 class인 A부터 생성자를 호출하고, 다음으로는 B, 다음으로는 C를 호출한다.

 

그런데 소멸자는 이와 반대로 C를 먼저 부르고, B, A 순으로 부르게 된다.

 

 

이를 통해 알 수 있는 점은, 생성자는 가장 상위 class부터 호출하지만 소멸자는 그와 반대로 가장 하위 class부터 호출이 된다는 점이다.

 

 

 

Chapter 11.5 상속과 접근 지정자

 

 

 

이번 챕터에서는 상속 시 사용하는 접근 지정자에 대해서 알아본다.

 

 

 

예제 코드 1

#include <iostream>

// 상속과 접근 지정자

using namespace std;

class Base
{
public:
    int m_public;

protected: // 상속된 곳에서 접근이 되느냐?
    int m_protected;

private:
    int m_private;
};

class Derived : public Base
{
public:
    Derived()
    {
        m_public = 134; // 가능
        m_protected = 156; // 가능
        // m_private = 111; // 불가능
    }
};

// 부모 클래스의 access classifier하고 protected가 만나서 더 엄격한 쪽으로 설정됨.
// 즉, m_public은 protected, m_protected는 protected, m_private은 private 수준임.
class Derived2 : protected Base
{
public:
    Derived2()
    {
        m_public = 134; // 가능
        m_protected = 156; // 가능
        // m_private = 111; // 불가능
    }
};

class Derived3 : private Base
{
public:
    Derived3()
    {
        m_public = 134; // 가능
        m_protected = 156; // 가능
        // m_private = 111; // 불가능
    }
};

// 
class GrandChild : public Derived3
{
public:
    GrandChild()
    {
        // Derived3::m_public // 불가능
        // Derived3::m_protected // 불가능
    }
};

int main()
{
    Base base;
    base.m_public = 123;
    //base.m_protected = 134;

    Derived deri;
    deri.m_public = 1024;
    // deri.m_protected = 134; // 불가능
    // deri.m_private = 111; // 불가능

    Derived2 deri2;
    // deri2.m_public = 134; // 불가능, public이 아니면 상속 받은 것들을 싹 외부에서 못받게 막아버리는 것.


}

 

 

class 내부의 멤버 변수 및 멤버 함수에 대한 외부의 접근을 제어할 때 우리는 접근 지정자(Access specifier)를 사용한다.

 

public은 모두에게 열려 있는 것이고, protected은 자식 클래스에만 허용, private은 클래스 내부에만 접근을 허용한다.

 

 

 

상속을 할 때도, 자식 클래스가 부모 클래스로부터 상속받는 멤버들의 접근 권한을 지정하게 되는데, 이 또한 접근 지정자라고 한다.

 

class Derived : public Mother에서 public에 이에 해당한다. 

 

class 내부 멤버에 대한 접근 권한을 제어하는 것과는 다르게,

 

상속에 대한 접근 권한을 제어할 때는 부모 클래스의 접근 지정자와 상속의 접근 지정자 중 더 엄격한 쪽의 적용을 받는다.

 

 

예를 들어,

 

class Derived의 경우, public Base로 설정되어 있는데, public은 가장 낮은 단계이므로 기존 부모 클래스 멤버의 접근 지정자 기준으로 생각하면 된다.

 

class Base의 m_public, m_protected 까지는 자식 클래스에서 접근이 가능하다. 하지만 private은 자식 클래스에서 접근이 불가능한 것을 볼 수 있다.

 

위 예제 코드에서 class Derived의 생성자에서 m_public, m_protected가 접근 가능한 것을 볼 수 있다.

 

 

 

class Derived2를 살펴보자. protected Base로 되어 있으니, 기존 부모 클래스 멤버의 접근 지정자와 protected 중 더 엄격한 쪽의 적용을 받는다.

 

class Base의 m_public은 protected로 적용받고, m_protected은 그대로 protected으로 적용, m_private은 private로 적용된다.

 

위 예제 코드에서 class Derived2의 생성자에서 m_public, m_protected가 접근 가능한 것을 볼 수 있으며, main문에서 보면 Derived2 class로 만든 인스턴스에서 m_public로 접근이 안 되는 것을 확인할 수 있다. 이는 deri2.m_public이 protected로 적용을 받기 때문에 외부에서 접근이 불가능해진 것이다.

 

 

class Derived3을 살펴보자. private Base로 되어 있으니, 모든 멤버들이 전부 private으로 취급된다.

 

이를 확인할 수 있는 것이 바로, Derived3을 상속한 클래스인 GrandChild class이다.

 

모든 멤버들이 전부 private이다 보니, 자식 class에서 어떤 변수도 접근을 할 수 없게 된다.

 

 

 

Chapter 11.6 유도된 클래스에 새로운 기능 추가하기

 

 

이번 챕터에서는 자식 클래스에 새로운 기능을 추가할 때 생각해야 될 점에 대해서 다룬다.

 

 

예제 코드 1

#include <iostream>
using namespace std;

// 자식 클래스에 새로운 기능을 추가할 때???

class Base
{
//private:
protected:
    int m_value;

public:
    Base(int value)
        : m_value(value)
    {

    }
};

class Derived : public Base
{
public:
    Derived(int value)
        : Base(value)
    {

    }

    void setValue(int value_input)
    {
        Base::m_value = value_input;

        // do some work with the variables defined in Derived
        // Derived에 정의된 변수들과 Base class에 있는 변수를 직접 접근해야하는 경우.
        // ==> Base class의 멤버를 protected으로 바꾸거나, 혹은 private 상태에서 getValue 같은 함수를 만들어서 접근.
        // 만약 getValue 같은 함수를 사용하는 경우면, 상위 클래스의 함수를 한번 거쳐야 되기 때문에 퍼포먼스가 떨어질 수 있는 리스크가 있음.
        // 상황에 따라서 어떤 선택을 할 것인지가 중요한 객체지향 실력이다.
    }

};


int main()
{

}

 

이번 예제에서 다루는 것은, 

 

자식 클래스의 멤버 함수에서 부모 클래스에 있는 변수를 직접 접근해야 하는 경우에 어떻게 해야 하는지에 대해서 이다.

 

 

방법은 크게 두 가지인데,

 

1) 부모 클래스의 멤버를 protected로 선언하여 자식 클래스에서 접근할 수 있도록 바꿔주는 경우

 

2) 부모 클래스의 멤버를 private으로 유지하되, getValue와 같은 함수를 사용해서 값을 받아오는 방식이다.

 

 

만약 getValue와 같은 함수를 사용한다면 결국 상위 클래스의 함수를 한번 거쳐야 되기 때문에 퍼포먼스가 떨어질 수 있는 리스크가 있으며, 이런 상황에서 어떤 선택을 할 것인가가 실력이라고 한다.

 

 

 

 

Chapter 11.7 상속받은 함수를 오버라이딩 하기

 

 

이번 챕터에서는 부모 클래스로부터 상속받은 함수를 오버라이딩 하는 상황에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>

// 부모 클래스와 자식 클래스 간의 기능 차이가 많지는 않을 수 있다.
// 상당 부분 겹치는 기능을 약간씩 다르게 가지고 있는 경우가 있어서, 이럴 때 유용하게 사용하는 함수 오버라이딩.

using namespace std;

class Base
{
protected:
    int m_i;

public:
    Base(int value)
        : m_i(value)
    {

    }

    void print()
    {
        cout << "I'm base" << endl;
    }
};

class Derived : public Base
{
private:
    double m_d;

public:
    Derived(int value)
        : Base(value)
    {

    }

    void print()
    {
        Base::print();
        cout << "I'm Derived" << endl;

        // print(); 하면 재귀호츌됨. 무한반복됨.
        // 이름이 같지만 상속해준 클래스로부터 함수를 가져다 사용할 때는 클래스 이름을 써서 사용해주면 된다.
    }
};


int main()
{
    Base base(5);
    base.print();

    Derived derived(8);
    derived.print();
}

 

 

 

부모 클래스와 자식 클래스 간의 기능 차이가 많지 않을 수 있는데, 이럴 때 유용하게 사용할 수 있는 기능인 함수 오버라이딩에 대해서 알아본다.

 

 

부모 class인 Base class에, I'm base를 출력하는 print()라는 함수가 있다.

 

자식 class인 Derived class에, I'm Derived를 출력하는 print()라는 함수가 있다.

 

자식 class에서 만약 부모 class의 함수 print()를 활용하면서, 동일한 이름으로 오버라이딩 하고 싶다면 Base::print();처럼 부모 클래스::함수명으로 부모 클래스에 있는 함수를 직접적으로 끌고 올 수 있다.

 

단, 만약 void print() 안에서 print();를 호출하게 되면 동일한 함수가 재귀호출 방식으로 무한 반복되니 주의해야 한다.

 

 

예제 코드 2

#include <iostream>

// 부모 클래스와 자식 클래스 간의 기능 차이가 많지는 않을 수 있다.
// 상당 부분 겹치는 기능을 약간씩 다르게 가지고 있는 경우가 있어서, 이럴 때 유용하게 사용하는 함수 오버라이딩.

using namespace std;

class Base
{
protected:
    int m_i;

public:
    Base(int value)
        : m_i(value)
    {

    }

    void print()
    {
        cout << "I'm base" << endl;
    }

    friend std::ostream& operator << (std::ostream& out, const Base& b)
    {
        cout << "This is Base output" << endl;
        return out;
    }
};

class Derived : public Base
{
private:
    double m_d;

public:
    Derived(int value)
        : Base(value)
    {

    }

    void print()
    {
        Base::print();
        cout << "I'm Derived" << endl;

        // print(); 하면 재귀호츌됨. 무한반복됨.
        // 이름이 같지만 상속해준 클래스로부터 함수를 가져다 사용할 때는 클래스 이름을 써서 사용해주면 된다.
    }

    friend std::ostream& operator << (std::ostream& out, const Derived& b)
    {
        cout << static_cast<Base>(b); // Derived를 Base로 static_cast 해버리는 방법으로 접근. 
        // 메모리 사이즈를 생각해보면, 앞에서는 부모 클래스에 대한 정보가 들어있고
        // 뒤에 추가로 자식 클래스에 대한 정보가 추가되는 형태.
        // 즉, 자식 클래스에서는 부모 클래스꺼의 내용을 메모리에 가지고 있다. 그래서 casting이 가능하다.
        out << "This is Derived output" << endl;
        return out;
    }
};


int main()
{
    Base base(5);
    //base.print();

    cout << base;

    Derived derived(8);
    //derived.print();

    cout << derived;
}

 

 

 

 

다음으로는 operator 오버로딩에 대해서 알아본다.

 

부모 class에서 operator 오버로딩을 미리 만들어둔 상황이고,

 

자식 class에서도 동일하게 operator 오버로딩을 사용하는 상황이다.

 

 

만약 자식 class에서 부모 class의 operator 오버로딩을 사용하고 싶다면,

 

이전처럼 Base::print()와 같은 방식으로는 불가능하고 operator로 들어오는 데이터를 부모 클래스의 자료형으로 casting 한 다음 operator를 사용하는 방식을 취한다. (static_cast<Base>(b))

 

이전 챕터에서 다루었듯이, 자식 클래스는 부모 클래스의 사이즈를 포함해서 배정받게 되는데, 그렇다 보니 메모리의 앞쪽에는 부모 클래스에 대한 정보가, 뒤쪽에는 자식 클래스에 대한 정보가 추가되는 형태라서 casting을 하면 부모 클래스의 내용을 가져올 수 있다고 한다.

 

 

 

Chapter 11.8 상속받은 함수를 감추기

 

 

이번 챕터에서는 부모 클래스로부터 상속받은 함수를 감추는 방법에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>
using namespace std;

class Base
{
protected:
    int m_i;

public:
    Base(int value)
        : m_i(value)
    {

    }

    void print()
    {
        cout << "I'm base" << endl;
    }
};

class Derived : public Base
{
private:
    double m_d;

public:
    Derived(int value)
        : Base(value)
    {

    }

    using Base::m_i; // 원래 protected는 자식 클래스의 멤버 함수 내에서만 접근할 수 있고, 클래스 인스턴스를 통해서 외부에서 직접 접근 불가능.

private:
	
    //using Base::print; // 1) private으로 설정해서 사용하지 못하게 하는 방법
    void print() = delete; // 2) 함수 자체를 delete 처리하는 방법
};

int main()
{
    Base base(5);
    Derived derived(7);

    derived.m_i = 10;
    //derived.print(); // private으로 바뀜. 호출 불가

    base.print(); // 이건 가능.
}

 

이번 챕터에서는 2가지에 대해서 알아본다.

 

 

첫 번째로, 자식 클래스에서 using을 사용하여 부모 클래스 내 멤버 변수를 클래스 인스턴스를 통해서 외부에서 접근할 수 있게 만들 수 있다.

 

부모 클래스에서 protected로 선언된 멤버의 경우, 자식 클래스 내 멤버 함수 내에서는 접근이 가능하지만 클래스 인스턴스를 통해서 외부에서는 직접 접근이 불가능하다.

 

하지만 using Base::m_i;를 선언해 주면, 외부에서 접근이 가능하도록 만들 수 있다.

 

 

두 번째로, 부모 클래스에서 상속받은 함수를 사용하지 못하게 막을 수 있다.

 

자식 클래스 내에서 private: 으로 선언해 주고 using Base::print;를 해주거나, 혹은 void print() = delete;와 같은 방식으로 함수 자체를 delete 시키는 방법이 있다.

 

 

 

Chapter 11.9 다중 상속

 

 

이번 챕터에서는 여러 클래스에서 상속을 받는 다중 상속에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>
using namespace std;

class USBDevice
{
private:
    long m_id;

public:
    USBDevice(long id) 
        : m_id(id) 
    {
    }

    long getID()
    {
        return m_id;
    }

    void plugAndPlay() {}

};

class NetworkDevice
{
private:
    long m_id;

public:
    NetworkDevice(long id)
        : m_id(id)
    {
    }

    long getID() {
        return m_id;
    }

    void networking() {}

};

class USBNetworkDevice : public USBDevice, public NetworkDevice
{
public:
    USBNetworkDevice(long usb_id, long net_id)
        : USBDevice(usb_id), NetworkDevice(net_id)
    {

    }
};


int main()
{
    USBNetworkDevice my_device(3.14, 6.022);

    my_device.plugAndPlay();
    my_device.networking();

    // my_device.getID(); // 어느쪽에서 상속받은 것을 사용할 것인지 모호하다.

    // 구분해서 호출할 수 있는 방법.
    my_device.USBDevice::getID();
    my_device.NetworkDevice::getID();
}

 

USBNetworkDevice라는 클래스를 살펴보면, USBDevice class에서도 상속을 받고, NetworkDevice class에서도 상속을 받고 있는 것을 알 수 있다.

 

이처럼 여러 class로 상속을 받는 것을 다중 상속이라고 한다.

 

그래서 main문을 살펴보면, USBNetworkDevice class로 만든 인스턴스에서 USBDeivce의 멤버 함수인 plugAndPlay도 사용하고 NetworkDevcie의 멤버 함수인 Networking도 사용하는 것을 볼 수 있다.

 

 

다만, 다중 상속을 할 때는 여러 부모 class에서 같은 이름의 멤버 함수를 가지는 경우 모호성이 발생할 수 있다.

 

따라서 이런 경우에는 부모 class를 정확하게 명시해 주면서 멤버 함수를 사용해 주면 모호성이 해소된다.

 

 

 

그리고, 위 사진처럼 B1과 B2는 A에서 상속을 받고, C는 다시 B1와 B2로부터 상속을 받는 구조를 다이아몬드 상속이라고 한다.

 

이런 방식의 상속을 사용하는 경우, 여러 가지 문제가 발생할 수 있기 때문에 피하는 것을 추천한다고 한다.

 

다만, 다중상속 자체가 문제가 되는 것은 아니며 다이아몬드 구조가 문제가 되는 것이라고 한다.

 

 

 

여기까지 챕터 11을 모두 정리해 보았다.

 

 

이번 게시물에서는 Chatper 10을 정리한다.

 

 

 

 

 

Chapter 10.1 객체들의 관계

 

 

이번 챕터에서는 객체들의 관계 4가지를 설명하고, 이를 설명한다.

 

 

 

 

연계, 제휴에서 양방향이 가능한 이유: 서로 도움을 주고받을 수 있기 때문

 

 

Chapter 10.2 구성 관계

 

 

 

이번 챕터에서는 구성 관계에 대해서 알아본다.

 

 

 

Chapter10_2.cpp

#include <iostream>
#include "Monster.h"

using namespace std;

int main()
{
    Monster m1("Sanson", Position2D(0, 0));
    cout << m1 << endl;
    {
        // event 발생 시
        m1.moveTo(Position2D(1, 1));
        cout << m1 << endl;
    }
}

 

 

Monster.h

#pragma once
#include <iostream>
#include "Position2D.h"

using namespace std;

class Monster
{
private:
	std::string m_name; // char * name, unsigned int length
	Position2D m_location; // composition 관계. location이 몬스터의 일부분이다.

public:
	// 생성자
	Monster(const std::string& name_input, const Position2D& pos_in)
		: m_name(name_input), m_location(pos_in)
	{

	}

	void moveTo(const Position2D& pos_in)
	{
		m_location.set(pos_in); // 기능을 분리시키고, 상위 클래스는 뭘 할지만 알면 되고 어떻게 하는지는 안 보일수록 좋다.
	}

	friend std::ostream& operator << (std::ostream& out, const Monster& monster)
	{
		out << monster.m_name << " " << monster.m_location;
		return out;
	}
};

 

 

 

Position2D.h

#pragma once
#include <iostream>

class Position2D
{
private:
	int m_x, m_y;

public:
	Position2D(const int& x_in, const int& y_in)
		: m_x(x_in), m_y(y_in)
	{

	}

	// TODO: overload =

	void set(const Position2D& pos_in)
	{
		set(pos_in.m_x, pos_in.m_y);
	}

	void set(const int& x_in, const int& y_in)
	{
		m_x = x_in;
		m_y = y_in;
	}

	friend std::ostream& operator << (std::ostream& out, const Position2D& Position2D)
	{
		out << Position2D.m_x << " " << Position2D.m_y;
		return out;
	}
};

 

main cpp를 살펴보면,

 

Monster라는 class type을 가지고 있는 m1 instance를 만들었고, "Sanson"이라는 이름을 가진 monster이며 처음 좌표는 (0, 0)이 된다.

 

그러다가, { } 안으로 들어오면 m1을 (1, 1) 위치로 옮기는 코드가 된다.

 

 

그럼 Monster class를 정의한 Monster.h로 돌아가보자.

 

Monster class는 std::string로 몬스터의 이름을 입력받고, Positidion2D라는 class로 위치를 입력받는다.

 

Monster는 moveTo라는 멤버 함수를 통해 위치를 바꾸고, << operator를 오버로딩 해둔 상태이다.

 

 

다음으로는 Position2D class를 정의한 Position2D.h로 돌아가보자.

 

Position2D class는 x좌표와 y좌표를 int로 받고, (x, y) 형태로 입력 받아서 set이 되는 경우는 각각 값을 바꿔주고, Position2D로 입력 받는 경우는 각각의 x, y를 set 해주는 구성으로 되어 있다.

 

 

 

 

 

이 예제 코드에서, 우리는 Monster class와 Position2D 클래스 간의 관계를 살펴보아야 하는데, 이는 Part of, 즉 구성 관계에 있다.

 

Monster class에게 Position2D는 부품에 불과하다. 즉, Monster가 전체, Position2D는 부품이다.

 

다른 클래스에도 속할 수 있는가?라는 질문이 No인데, 이는 이런 케이스를 생각해 보면 된다.

 

예를 들어서, Knight class를 만들었다고 하면, 여기에도 Position2D class를 이용해서 location을 표현해주어야 한다.

 

그런데, 이는 Knight class의 부품이지 Monster class의 부품이 아니다. 그래서 다른 클래스에 속할 수 없는 경우라고 보면 된다.

 

클래스가 멤버의 존재를 관리하는가?라는 질문이 Yes인데, 이는 몬스터나 기사가 죽는 경우, m_location도 같이 사라지기 때문에 Yes라고 보면 된다.

 

방향성이 단방향인 이유는, Monster class를 작업할 때 Position2D 클래스에 대해서 어떤 액세스 함수가 있는지 알고 있지만, Position2D class는 Monster class에 대해서 모른다. 즉, 전체는 부품을 알지만, 부품은 전체를 알 수 없다는 점이다.

 

 

 

Chapter 10.3 집합 관계

 

 

이번 챕터에서는 집합 관계(Aggregation Relationship)에 대해서 알아본다. 

 

 

 

예제 코드 1 (구성 관계 적용)

 

#include <iostream>
#include "Lecture.h"

class Teacher
{
private:
	std::string m_name;

public:
	Teacher(const std::string& name = "No Name")
		: m_name(name)
	{

	}

	void setName(const std::string& name)
	{
		m_name = name;
	}

	std::string getName()
	{
		return m_name;
	}

	friend std::ostream& operator << (std::ostream& out, const Teacher& teacher)
	{
		out << teacher.m_name;
		return out;
	}
};


class Student
{
private:
	std::string m_name;
	int m_intel;

public:
	Student(const std::string& name = "No Name", const int& intel = 0)
		: m_name(name), m_intel(intel)
	{
	}

	void setName(const std::string& name_in)
	{
		m_name = name_in;
	}

	void setIntel(const int& in)
	{
		m_intel = in;
	}

	int getIntel()
	{
		return m_intel;
	}

	friend std::ostream& operator << (std::ostream& out, const Student& stu)
	{
		out << stu.m_name << " " << stu.m_intel;
		return out;
	}
};


class Lecture
{
private:
	std::string m_name;

	Teacher teacher;
	std::vector<Student> students;

public:
	Lecture(const std::string& name_in)
		: m_name(name_in)
	{

	}

	
	void assignTeacher(const Teacher& teacher_in)
	{
		teacher = teacher_in;
	}	
	void registerStudent(const Student& student_in)
	{
		students.push_back(student_in);

		// &student_in != &students[0], 복사해서 넣기 때문에 주소가 다르다.
	}
	void study()
	{
		std::cout << m_name << " Study " << std::endl << std::endl;

		
		for (auto &e : students)
		{
			e.setIntel(e.getIntel() + 1);
		}
		
	}

	friend std::ostream& operator << (std::ostream& out, const Lecture& lec)
	{
		out << "Lecture name: " << lec.m_name << std::endl;

		out << lec.teacher << std::endl;

		for (auto element : lec.students)
			out << element << std::endl;


		return out;
	}
};



int main()
{
    using namespace std;

    Lecture lec1("Introduction to Computer Programming");
    lec1.assignTeacher(Teacher("Prof. Hong"));
    lec1.registerStudent(Student("Jack Jack", 0));
    lec1.registerStudent(Student("Dash", 1));
    lec1.registerStudent(Student("Violet", 2));

    Lecture lec2("Computational Thinking");
    lec2.assignTeacher(Teacher("Prof. Good"));
    lec2.registerStudent(Student("Jack Jack", 0));

    {
        cout << lec1 << endl;
        cout << lec2 << endl;

        lec2.study();

        cout << lec1 << endl;
        cout << lec2 << endl;

    }
}

 

 

 

main문에 대해서 살펴보자.

 

우선 Lecture class인 instance lec1을 만들어주고. 해당 강의에 Prof. Hong이라는 Teacher를 assign, 그리고 Jack Jack과 Dash, Violet이라는 학생을 lec1에 등록해 준다.

 

다음으로 lec2를 만들어주고, 여기에 Prof. Good이라는 선생님을 추가하고 Jack Jack이라는 학생을 추가해 준다.

 

기본적으로 이 코드는 강의를 들으면, 학생들의 intelligence가 +1 되는 코드이다.

 

lec2.study()라는 코드를 통해, lecture 2가 실제로 진행되었음을 나타낸다. 따라서 lecture 2에 등록된 학생의 intelligence가 +1이 되어야 한다.

 

그런데, 출력창을 살펴보면 lecture 2에 등록했던 Jack Jack은 +1이 되어 1이 된 것을 확인할 수 있지만, 이상하게도 다른 강의에서는 Jack Jack의 intel이 0으로 되어 있다.

 

즉, lec1에 등록된 Jack Jack과 lec2에 등록된 Jack Jack이 동기화가 되지 않는 상황인 것이다.

 

현재 코드는 구성 관계에 해당하는 상황인데, 이러한 상황에서는 구성 관계로 코드를 구성하면 동기화가 되지 않는 문제가 있다.

 

따라서, 이런 경우에는 집합 관계(Aggregation Relationship)로 만들어주어야 우리가 의도한 대로 된다고 볼 수 있다.

 

 

예제 코드 2 (집합 관계 적용)

 

#include <iostream>
#include <vector>

class Teacher
{
private:
	std::string m_name;

public:
	Teacher(const std::string& name = "No Name")
		: m_name(name)
	{

	}

	void setName(const std::string& name)
	{
		m_name = name;
	}

	std::string getName()
	{
		return m_name;
	}

	friend std::ostream& operator << (std::ostream& out, const Teacher& teacher)
	{
		out << teacher.m_name;
		return out;
	}
};


class Student
{
private:
	std::string m_name;
	int m_intel;

public:
	Student(const std::string& name = "No Name", const int& intel = 0)
		: m_name(name), m_intel(intel)
	{
	}

	void setName(const std::string& name_in)
	{
		m_name = name_in;
	}

	void setIntel(const int& in)
	{
		m_intel = in;
	}

	int getIntel()
	{
		return m_intel;
	}

	friend std::ostream& operator << (std::ostream& out, const Student& stu)
	{
		out << stu.m_name << " " << stu.m_intel;
		return out;
	}
};


class Lecture
{
private:
	std::string m_name;
    
	// Student는 vector의 인스턴스, Lecture class의 instance가 사라지면 Student는 사라진다.
	// 하지만 Student 포인터가 가리키고 있는 원래 주소에 있는 것들은 그대로 유지가 됨.
	Teacher* teacher;
	std::vector<Student*> students;

public:
	Lecture(const std::string& name_in)
		: m_name(name_in)
	{

	}
	
	void assignTeacher(Teacher* const teacher_in)
	{
		teacher = teacher_in;
	}
	
	void registerStudent(Student* const student_in)
	{
		students.push_back(student_in);

		// &student_in != &students[0], 복사해서 넣기 때문에 주소가 다르다.
	}
	

	void study()
	{
		std::cout << m_name << " Study " << std::endl << std::endl;		
		for (auto &element : students)
		{
			//(*element).setIntel((*element).getIntel() + 1);
			element->setIntel(element->getIntel() + 1);
		}
		
	}

	friend std::ostream& operator << (std::ostream& out, const Lecture& lec)
	{
		out << "Lecture name: " << lec.m_name << std::endl;

		out << *(lec.teacher) << std::endl;

		for (auto element : lec.students)
			out << *element << std::endl;


		return out;
	}
};


int main()
{
    using namespace std;

    Student std1("Jack Jack", 0);
	Student std2("Dash", 1);
	Student std3("Violet", 2);

    Teacher teacher1("Prof. Hong");
    Teacher teacher2("Prof. Good");

    // Composition Relationship
    // lec1의 잭잭과 lec2의 잭잭이 다르다.
    // Student를 따로 만든다.

    // Student를 각각 정의하고, 주소만 가져다가 사용하기 때문에 Lecture1과 Lecture2에서 같은 학생으로 인식을 하는 것.
	// 포인터로 공유하면, Lecture 2에서 student1의 속성을 바꿔줬을 때, 자동으로 반영이 된다. ==> Aggregation Relationship
    Lecture lec1("Introduction to Computer Programming");
    lec1.assignTeacher(&teacher1);
    lec1.registerStudent(&std1);
    lec1.registerStudent(&std2);
    lec1.registerStudent(&std3);

    Lecture lec2("Computational Thinking");
    lec2.assignTeacher(&teacher2);
    lec2.registerStudent(&std1);

    {
        cout << lec1 << endl;
        cout << lec2 << endl;

        lec2.study();

        cout << lec1 << endl;
        cout << lec2 << endl;

    }
}

 

 

예제 코드 1번과 달라지는 점은, 학생과 선생님을 미리 변수로 만들어서 가지고 있다가, 각 lecture에 전달해 주는 방식이다.

 

즉, 각 학생과 각 선생님 변수에 해당하는 포인터를 전달하는 방식이다. 이렇게 하면, lec1에 참여한 Jack Jack(std1)과 lec2에 참여한 Jack Jack(std2)는 동일하게 인식된다.

 

main문에서 살펴보면, assignTeacher와 registerStudent에 전달해 줄 때 주소를 전달해 주는 것을 볼 수 있다.

 

이는 Lecture class를 확인해 보면 달라진 것을 알 수 있다.

 

 

 

기존에 Lecture class에서는 멤버 변수로 Teacher teacher;와 std::vector<Student> students;를 가지고 있었다.

 

그런데 이를 Teacher* teacher;와 std::vector<Student*> students;로 변경하여, 포인터를 가지고 있도록 변경한다.

 

이렇게 하면 메모리 주소를 전달받는 방식이기 때문에 Lecture class의 instance가 사라지더라도 Stduent 포인터가 가리키고 있는 원래 주소에 있는 내용은 유지가 된다.

 

Lecture class의 멤버 함수들을 살펴보면, 

 

assignTeacher 함수는 매개변수를 Teacher* 로 받게끔 바뀌고, registerStudent도 매개변수를 Student*로 받게끔 바뀐다.

 

그리고 study()에서,

 

기존에 for each문으로 students를 접근하면 바로 setIntel을 접근할 수 있었는데, 이젠 students에 있는 요소들이 포인터이기 때문에 이를 de-reference 해서 (*element)로 만든 다음 setIntel을 해주거나, element->setIntel을 해서 접근해야 한다.

 

그리고 operator << 오버로딩 한 부분도 원래 lec.students는 그냥 out으로 내보냈는데, 이젠 포인터이기 때문에 *element로 해야만 우리가 원하는 방식대로 출력이 된다. (element로 그대로 두면 메모리 주소만 출력됨)

 

 

 

예제 코드 3 (집합 관계 + 동적 할당 방식 적용)

int main()
{
    using namespace std;

    // 동적할당 하는 방식으로도 구현 가능.
    Student *std1 = new Student("Jack Jack", 0);
    Student *std2 = new Student("Dash", 1);
    Student *std3 = new Student("Violet", 2);

    Teacher *teacher1 = new Teacher("Prof. Hong");
    Teacher *teacher2 = new Teacher("Prof. Good");

    // Composition Relationship
    // lec1의 잭잭과 lec2의 잭잭이 다르다.
    // Student를 따로 만든다.

    // Student를 각각 정의하고, 주소만 가져다가 사용하기 때문에 Lecture1과 Lecture2에서 같은 학생으로 인식을 하는 것.
    // 포인터로 공유하면, Lecture 2에서 student1의 속성을 바꿔줬을 때, 자동으로 반영이 된다. ==> Aggregation Relationship
    Lecture lec1("Introduction to Computer Programming");
    lec1.assignTeacher(teacher1);
    lec1.registerStudent(std1);
    lec1.registerStudent(std2);
    lec1.registerStudent(std3);

    Lecture lec2("Computational Thinking");
    lec2.assignTeacher(teacher2);
    lec2.registerStudent(std1);

    {
        cout << lec1 << endl;
        cout << lec2 << endl;

        lec2.study();

        cout << lec1 << endl;
        cout << lec2 << endl;

    }

    delete std1, std2, std3, teacher1, teacher2;

}

 

예제 코드 2와 동일한데, 이번에는 각 학생과 각 선생님을 정적 할당이 아닌 동적 할당을 적용하는 케이스이다.

 

동적 할당을 하면 포인터가 되니, 이제는 &std1로 바꿔서 register 할 필요 없이 std1를 그대로 register 하면 된다.

 

동적 할당이므로, 맨 마지막에 delete를 해주는 부분만 기억하면 된다.

 

 

즉 이번 챕터에서 강의와 학생의 관계를 구성 관계로 적용하면 다른 강의도 함께 듣는 학생의 정보가 동기화되지 않으므로, 이를 집합 관계로 적용해야 한다는 것이 핵심이다.

 

 

 

Chapter 10.4 제휴 관계

 

이번 챕터에서는 서로가 전체/부품 관계가 아닌 경우인 제휴 관계에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>
#include <vector>


// 구성(Composition)이나 집합(Aggregation) 은 어느 한쪽이 주, 다른 한쪽이 부품
// 제휴관계는 명확하지 않은 케이스.
// 환자 클래스와 의사 클래스가 서로 내부에 상대방을 멤버로 가지고 있게 됨.
// 서로 제휴관계일 경우 한쪽에서는 전방선언이 필요하고, 일부 함수의 정의를 밖으로 빼야할 수 있다.
// 포인터를 쓰지 않고 환자의 id를 int로 부여해서 아이디로 환자를 찾는 방식을 구현하기도 함.

// 반사적 연관: 의사가 의사를 만날 수 있다.

// 서로가 서로를 사용하는 입장. 동등한 관계. 용도 외에는 무관함. 환자가 다 나았다면 특정한 의사를 만날 필요가 없을 수 있다.
// 그러다가 다시 아프면 의사를 다시 찾아가는 구조. 용도가 있을 때만 결합이 되는 관계다.
// 다른 클래스에도 속할 수 있다. (여러 의사들에게도 속할 수 있다)
// 서로를 관리할 수 없음. 
// 양방향일 경우에 많이 사용을 하고, 경우에 따라서는 단방향이 되기도 함.

using namespace std;

class Doctor; // forward declaration

// 빨간줄 쳐주는거는 Visual Studio가 찾아주는거고, 컴파일할 때 오류가 생기는 경우는 컴파일러에서 에러 나는것.

class Patient
{
private:
    string m_name;
    vector<Doctor*> m_doctors;
public:
    Patient(string name_in)
        : m_name(name_in)
    {
    }

    void addDoctor(Doctor* new_doctor)
    {
        m_doctors.push_back(new_doctor);
    }

    void meetDoctors(); // 선언만 남김

    friend class Doctor;
};

class Doctor
{
private:
    string m_name;
    vector<Patient*> m_patients;
public:
    Doctor(string name_in)
        : m_name(name_in)
    {
    }

    void addPatient(Patient* new_patient)
    {
        m_patients.push_back(new_patient);
	}

    void meetPatients()
    {
        for (auto& pa : m_patients)
        {
            cout << "Meet Patients : " << pa->m_name << endl;
        }
    }

    friend class Patient;
};

void Patient::meetDoctors()
{
    for (auto& doc : m_doctors)
    {
        cout << "Meet doctor : " << doc->m_name << endl;
    }
}

int main()
{
    Patient* p1 = new Patient("Jack Jack");
    Patient* p2 = new Patient("Dash");
    Patient* p3 = new Patient("Violet");

    Doctor* d1 = new Doctor("Doctor K");
    Doctor* d2 = new Doctor("Doctor L");

    p1->addDoctor(d1);
    d1->addPatient(p1);

    p2->addDoctor(d2);
    d2->addPatient(p2);

    p2->addDoctor(d1);
    d1->addPatient(p2);

    p1->meetDoctors();

    d1->meetPatients();

    delete p1;
    delete p2;
    delete p3;

    delete d1;
    delete d2;
}

 

 

 

이번 챕터에서 알아볼 관계는 제휴 관계(Association Relationship)이다.

 

이전에 구성, 집합 관계는 서로가 전체와 부품의 관계였다.

 

이번 예제에서는 환자와 의사를 이용해서 제휴 관계를 알아본다.

 

 

 

제휴 관계는, 서로가 전체/부품의 관계가 아니다. 

 

서로가 서로를 멤버로 가지고 있게 되는 경우이다.

 

main문을 살펴보면, 환자와 의사를 각각 동적할당 해주고, 환자는 의사를 만나고, 의사는 환자를 만나는 구조로 만들어져 있다.

 

환자는 어떤 의사를 만났는지 알아야 하고, 의사는 어떤 환자를 만났는지 알아야 하기 때문에 서로가 상대방을 멤버로 가지고 있게 된다.

 

Patient class에는 Doctor를 담는 vector를 가지고 있고, Doctor class에는 Patient를 담는 vector를 가지고 있다.

 

이처럼 제휴 관계는 서로가 서로를 사용하게 되며, 동등한 관계에 놓여있다.

 

 

서로가 서로의 클래스 변수에 접근해야 하기 때문에, 반드시 전방 선언이 이루어지게 되며 이때 코드상 앞쪽에 작성된 class에서는 다음 class의 구조를 모르니 함수의 정의를 밖으로 빼야 하는 경우도 있다.

 

위 코드에서는 Patient class에서 meetDoctors() 함수 내 doc의 m_name을 접근할 수 없어서 Doctor class 뒤로 뺀 것을 확인할 수 있다.

 

 

제휴 관계는 용도 외에는 무관관 관계의 형태를 띠고 있는데, 이는 환자의 입장에서 아프면 의사를 찾아가지만 그렇지 않은 경우에는 서로가 무관하기 때문이다.

 

그리고 다른 클래스에도 속할 수 있는데, 환자가 의사 말고도 간호사나 물리치료사와 같은 클래스에도 속할 수 있다.

 

마지막으로 서로가 서로를 관리할 수 없기 때문에, 멤버의 존재를 클래스가 관리하지는 않는다.

 

방향성은 주로 양방향이고, 단방향이 되기도 한다.

 

 

Chapter 10.5 의존 관계

 

 

이번 챕터에서는 가장 많이 사용하는 관계이자 느슨한 관계인 의존 관계에 대해서 알아본다.

 

 

예제 코드 1

 

Chapter10_5.cpp

#include <iostream>
#include <chrono>
#include "Worker.h"

int main()
{
    Worker().doSomething();
}

 

 

Worker.h

#pragma once

class Worker
{
public:
    void doSomething();
};

 

 

Worker.cpp

#pragma once

#include "Timer.h"
#include "Worker.h"

void Worker::doSomething()
{
    Timer timer;

    timer.elapsed();
}

 

 

Timer.h

#pragma once
#include <chrono>
#include <iostream>

using namespace std;

class Timer
{
    using clock_t = std::chrono::high_resolution_clock;
    using second_t = std::chrono::duration<double, std::ratio<1>>;

    std::chrono::time_point<clock_t> start_time = clock_t::now();

public:
    void elapsed()
    {
        std::chrono::time_point<clock_t> end_time = clock_t::now();

        std::cout << std::chrono::duration_cast<second_t>(end_time - start_time).count() << std::endl;
    }
};

 

가장 흔하게 볼 수 있는 관계인 의존(Dependency) 관계이다.

 

가장 메인이 되는 Chapter10_5.cpp를 살펴보면, main문에 딱 Worker().doSomething(); 이라는 한 줄의 코드만 있고 나머지는 전혀 없다.

 

이에 대해서 확인하기 위해, include 된 "Worker.h" 파일을 살펴보면, Worker라는 class가 정의되어 있고 doSomething()이라는 함수의 함수 이름만 나와 있다.

 

이에 대해서 자세히 알아보려면 Worker.cpp에서 확인할 수 있고, 여기서 Timer라는 class를 활용하는 것을 볼 수 있다.

 

Timer에 대한 내용은 Timer.h를 확인하면 된다.

 

 

이 예제 코드에서, Worker라는 클래스와 Timer라는 클래스가 바로 의존 관계에 있다.

 

Worker::doSomething()의 메서드 내부에서 Timer 객체를 지역 변수로 생성하고 사용하며, 이 객체는 doSomething 함수가 종료되면 소멸된다.

 

Worker는 Timer 객체를 멤버 변수로 유지하지 않고 메서드를 실행하는 동안에만 잠깐 가져다가 쓰는 상황이며, Worker.h 파일을 보면 Timer에 대해서 전혀 알지 못해도 상관이 없다.(클래스 레벨에서는 아무것도 몰라도 됨) 그리고 Worker는 시간 측정을 하기 위한 특정 목적으로만 Timer를 사용한다.

 

즉, Worker와 Timer는 서로 의존 관계에 있다.

 

 

의존 관계에서는 용도 외엔 무관한 관계인데, 이는 앞에서도 언급했듯이 Worker 클래스 레벨에서는 Timer에 대해서는 아예 몰라도 되는 관계이기 때문이다.

 

타이머는 다른 클래스에도 속할 수 있으니, 다른 클래스에도 속할 수 있는지 여부는 Yes이다.

 

Worker의 doSomething() 메서드를 사용할 때 Timer 클래스가 일시적으로 생겼다가 사라지는 것이기 때문에 멤버의 존재를 클래스가 관리하는 것이 맞다.

 

 

 

Chapter 10.6 컨테이너 클래스

 

 

이번 챕터에서는 다른 클래스들을 담는 역할을 하는 클래스인 컨테이너 클래스에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>
#include <vector>
#include <array>
#include <algorithm>

// 다른 클래스들을 담는 역할을 하는 클래스 : 컨테이너 클래스

using namespace std;

class IntArray
{
private:
    int m_length = 0;
    int* m_data = nullptr;

public:
    IntArray(std::initializer_list<int> list)
    {
        m_length = static_cast<int>(list.size());
        m_data = new int[m_length];
        std::copy(list.begin(), list.end(), m_data);
    }

    ~IntArray()
    {
        delete[] m_data;
        m_data = nullptr;
    }

    // Copy Constructor
    IntArray(const IntArray& source)
    {
        m_length = source.m_length;

        if (m_length > 0)
        {
            m_data = new int[m_length];
            std::copy(source.m_data, source.m_data + source.m_length, m_data);
        }
        else
        {
            m_data = nullptr;
        }
    }

    void insertBefore(const int& input_in, const int& pos_in)
    {
        int* tmp_data = new int[m_length];
        
        // tmp_data에 데이터 일단 옮김
        for (int i = 0; i < m_length; i++)
        {
            tmp_data[i] = m_data[i];
        }

        // m_data 날리기
        delete[] m_data;

        // m_data에 +1 짜리 새로 동적할당
        m_data = new int[m_length + 1];

        // pos_in 이전에는 값 그대로 옮김
        for (int i = 0; i < pos_in; i++)
        {
            m_data[i] = tmp_data[i];
        }

        // pos_in 이후에는 idx + 1 해서 옮김
        for (int i = pos_in; i < m_length; i++)
        {
            m_data[i + 1] = tmp_data[i];
        }

        m_data[pos_in] = input_in;
        m_length++;

        delete[] tmp_data;
        tmp_data = nullptr;
        
    }

    friend std::ostream& operator << (std::ostream& out, const IntArray& I)
    {
        for (int i = 0; i < I.m_length; i++)
        {
            out << I.m_data[i] << " ";
        }
        return out;
    }

    void remove(const int& ix)
    {
        // 기존 데이터 저장할 변수 만들기
        int* tmp_data = new int[m_length];

        // tmp_data에 데이터 일단 옮김
        for (int i = 0; i < m_length; i++)
        {
            tmp_data[i] = m_data[i];
        }

        delete[] m_data;

        m_data = new int[m_length - 1];

        // ix 인덱스 이전꺼는 그냥 복사
        for (int i = 0; i < ix; i++)
        {
            m_data[i] = tmp_data[i];
        }

        for (int i = ix+1; i < m_length; i++)
        {
            m_data[i-1] = tmp_data[i];
        }

        m_length--;

        delete[] tmp_data;
        tmp_data = nullptr;

    }

    void push_back(const int& value)
    {
        int* tmp_data = new int[m_length];

        // tmp_data에 데이터 일단 옮김
        for (int i = 0; i < m_length; i++)
        {
            tmp_data[i] = m_data[i];
        }

        // m_data 날리기
        delete[] m_data;

        m_length++;

        // m_data에 +1 짜리 새로 동적할당
        m_data = new int[m_length];

        for (int i = 0; i < m_length-1; i++)
        {
            m_data[i] = tmp_data[i];
        }

        m_data[m_length-1] = value;

        delete[] tmp_data;
        tmp_data = nullptr;
    }

    // reset 시켜주는 함수
    void reset()
    {
        m_length = 0;
        delete[] m_data;
        m_data = nullptr;
    }

    
    void resize(int new_length)
    {
        // resize 호출 시 기존 길이와 변동 없는 경우 return
        if (m_length == new_length) return;

        int* tmp_data = new int[new_length];
        for (int i = 0; i < std::min(m_length, new_length); i++)
        {
            tmp_data[i] = m_data[i];
        }

        m_length = new_length;

        delete[] m_data;
        m_data = tmp_data;
    }
};

int main()
{
    IntArray my_arr{1, 3, 5, 7, 9};
    my_arr.insertBefore(10, 1); // 1, 10, 3, 5, 7, 9
    cout << my_arr << endl;


    my_arr.remove(3); // 1, 10, 3, 7, 9
    cout << my_arr << endl;


    my_arr.push_back(13); // 1, 10, 3, 7, 9, 13
    cout << my_arr << endl;

    my_arr.resize(4);
    cout << my_arr << endl;
    
    //std::vector<int> int_vec;
    //std::array<int, 10> int_arr;
}

 

컨테이너 클래스란, 다른 클래스들을 담는 역할을 하는 클래스를 의미한다.

 

std::vector라던가, std::array 같은 것이 예시가 된다.

 

사실 컨테이너 클래스에 대한 내용은 이게 다였고, 강의에서 숙제를 내주셨는데

 

IntArray라는 클래스를 만들어서 초기화하고, insertBefore, remove, push_back 하는 기능이 구현되도록 만들라고 하셨다.

 

 

먼저, IntArray my_arr{1, 3, 5, 7, 9} 와 같은 방식으로 my_arr라는 인스턴스를 만들려면, initializer_list가 입력으로 들어올 수 있도록 만들어줘야 한다.

 

따라서, 생성자를 만들 때 std::initializer_list를 받도록 만들었다. 

 

기본적으로 인스턴스를 생성할 때 m_data는 동적 할당으로 만들어지기 때문에, 소멸자를 호출할 땐 반드시 m_data를 다시 메모리 해제해줘야 한다. 따라서 delete[] m_data;가 꼭 필요하다.

 

 

insertBefore 멤버 함수는 숫자와 index를 받아서, 해당 숫자를 해당 index에 추가하는 기능을 수행해야 한다.

 

예를 들어, 기존에 [1, 3, 5, 7, 9]였다면, insertBefore(10, 1);을 했을 때 [1, 10, 3, 5, 7, 9]가 되어야 하는 것이다.

 

기존 데이터를 가지고 있어야 하니, int type의 포인터 변수인 tmp_data를 만든 다음, 데이터를 모두 옮긴 뒤에 길이가 +1인 새로운 m_data를 만들어준다. 

 

추가하려는 index 이전까지는 동일한 index를 복사해 주고, 추가하려는 index부터는 +1씩 해서 뒤로 옮겨주고, pos_in 위치에는 input_in 값을 추가해 주면 된다.

 

 

 

 

여기까지 Chapter 10을 모두 정리해 보았다.

 

이번 챕터에서는 객체들의 관계인 구성(Composition), 집합(Aggregation), 제휴(Association), 의존(Dependency)으로 나누어서 알아보았다.

 

이번 글에서는 Chapter 9을 정리해 본다.

 

 

 

 

 

 

Chapter 9.1 산술 연산자 오버로딩 하기

 

 

이번 챕터에서는 산술 연산자를 오버로딩해서, 사용자 정의 자료형에 대해서도 별도의 함수를 만드는 것이 아니라 우리가 아는 연산자를 이용해서 연산할 수 있도록 해본다.

 

 

예제 코드 1

#include <iostream>
using namespace std;

class Cents
{
private:
    int m_cents;

public:
    Cents(int cents = 0) {m_cents = cents; }
    int getCents() const { return m_cents; }
    int& getCents() { return m_cents; }
};


Cents add(const Cents& c1, const Cents& c2)
{
    return Cents(c1.getCents() + c2.getCents());
}

int main()
{
    Cents Cents1(6);
    Cents Cents2(8);

    cout << add(Cents1, Cents2).getCents() << endl;
}

 

 

 

예제 코드 1번은 우리가 Chapter 8에서 배웠던 객체에 대한 내용을 알고 있다면 바로 이해할 수 있는 내용이다.

 

Cents라는 클래스를 정의하고, m_cents를 각각 6과 8로 주고서 인스턴스를 만들어준다.

 

그리고 Cents1, Cents2라는 인스턴스를 add라는 함수의 인자로 주고, 그 결과로 두 인스턴스의 m_cents를 더한 결과를 기반으로 Cents 인스턴스를 새로 만든 다음, getCents()를 해서 m_cents의 값을 얻는 예제이다.

 

int나 float와 같이 이미 정의된 변수 type의 경우, 간단하게 + 연산자를 사용해서 더할 수 있을 텐데, 지금처럼 사용자가 정의하는 자료형에 대해서는 이와 같은 연산자를 사용하지 못하고 별도의 함수를 만들어주어야만 한다.

 

따라서 이를 조금 더 손쉽게 할 수 있도록 연산자 오버로딩이라는 방법을 이용해 본다.

 

 

 

예제 코드 2

#include <iostream>
using namespace std;

class Cents
{
private:
    int m_cents;

public:
    Cents(int cents = 0) {m_cents = cents; }
    int getCents() const { return m_cents; }
    int& getCents() { return m_cents; }
};

// 1번. 산술 연산자 오버로딩을 일반 함수로 구현하는 방법

Cents operator +(const Cents& c1, const Cents& c2)
{
    return Cents(c1.getCents() + c2.getCents());
}


int main()
{
    Cents Cents1(6);
    Cents Cents2(8);
    cout << (Cents1 + Cents2 + Cents(6)).getCents() << endl;
}

 

 

이번 예제에서는 산술 연산자 오버로딩을 일반 함수를 이용해서 구현하는 방법이다.

 

아까는 Cents add로 해서 add라는 이름으로 함수를 작성했었는데, 이번에는 operator +라는 이름으로 작성해 준다.

 

이렇게 해서 연산자 오버로딩을 해주면, Cents라는 자료형으로 만들어진 인스턴스끼리 바로 덧셈이 가능해진다.

 

아까는 Cents1와 Cents2만 더해줬지만, 이전 챕터에서 배웠던 임시 객체를 활용해서 Cents(6)을 함께 더해주면 20이라는 값을 얻을 수 있다.

 

 

예제 코드 3

#include <iostream>
using namespace std;

class Cents
{
private:
    int m_cents;

public:
    Cents(int cents = 0) {m_cents = cents; }
    int getCents() const { return m_cents; }
    int& getCents() { return m_cents; }

    friend Cents operator +(const Cents& c1, const Cents& c2)
    {
        return Cents(c1.getCents() + c2.getCents());
        // return Cents(c1.m_cents + c2.m_cents);
    }
};

int main()
{
    Cents Cents1(6);
    Cents Cents2(8);

	cout << (Cents1 + Cents2 + Cents(6)).getCents() << endl;
}

 

 

다음으로는, 일반 함수를 이용하는 것이 아니라 연산자 오버로딩 함수를 friend로 지정해 주는 경우이다.

 

이전 챕터에서 배웠지만, class 내에서 friend로 지정해 주는 경우 함수에서 class의 private으로 지정된 멤버 변수에도 접근이 가능하다.

 

따라서 return을 해줄 때 c1.getCents()를 이용하던 코드를 getter를 이용하지 않고 다이렉트로 m_cents 멤버 변수에 접근하는 방식도 가능하다.

 

 

 

예제 코드 4

#include <iostream>
using namespace std;

class Cents
{
private:
    int m_cents;

public:
    Cents(int cents = 0) {m_cents = cents; }
    int getCents() const { return m_cents; }
    int& getCents() { return m_cents; }

    // = 대입, [] array에서 index 집어넣는거, () 괄호 안에 파라미터
    // -> 멤버 선택 연산자 오버로딩은 멤버로만 해야됨.
    
    Cents operator + (const Cents& c2)
    {
        //return Cents(c1.getCents() + c2.getCents());
        return Cents(this->m_cents + c2.getCents());
    }
    
};

int main()
{
    Cents Cents1(6);
    Cents Cents2(8);
    cout << (Cents1 + Cents2 + Cents(6)).getCents() << endl;
}

 

이번 예제에서는, friend를 사용하는 함수가 아니라 Cents class의 멤버 함수로 연산자 오버로딩을 해주는 경우이다.

 

멤버 함수로 연산자 오버로딩을 해주는 경우에는, input 변수를 2개 사용하려고 하면 에러가 발생한다.

 

이는 이미 이 함수가 class로 만든 인스턴스의 m_cents를 가지고 있기 때문에, 추가로 1개의 input만 받아오면 더할 수 있기 때문이다.

 

따라서 기존과 다르게 Cents 객체를 1개만 받으면 된다.

 

그리고 this pointer를 이용해서 this의 m_cents와 c2의 m_cents를 더해서 return 해주면 된다.

 

 

대입에 사용되는 연산자인 '=', array 사용 시 index에 활용되는 연산자인 '[]', 괄호 '( )', 멤버 선택 연산자인 '->' 의 연산자 오버로딩은 반드시 멤버 함수로만 이루어져야 한다고 한다.

 

 

추가로, 삼항 연산자는 오버로딩이 안되고, sizeof도 오버로딩 불가, scope 연산자인 ::도 오버로딩 불가. 멤버 선택인 .와 멤버 포인터 선택인 .*도 안됨.

 

연산자 오버로딩을 사용하더라도 연산자의 우선순위는 유지되므로, 수학시간에 배운 내용들과 직관적으로 통하는 것들만 연산자 오버로딩을 하는 것이 좋다고 한다.

 

마지막으로, XOR 연산자인 ^는 연산자 우선순위가 매우 낮아서, 오버로딩을 하지 않는 것을 추천한다고 한다.

 

 

Chapter 9.2 입출력 연산자 오버로딩

 

 

 

이번 챕터에서는 입출력 연산자를 오버로딩 해본다.

 

 

예제 코드 1

 

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

class Point
{
private:
    double m_x, m_y, m_z;

public:
    Point(double x = 0.0, double y = 0.0, double z = 0.0)
        : m_x(x), m_y(y), m_z(z)
    {
    }

    double getX() { return m_x; }
    double getY() { return m_y; }
    double getZ() { return m_z; }

    // friend 함수는 this 포인터 접근 불가능, 객체명으로 접근 필요.
    // 첫 번째 파라미터가 포인트 클래스가 아니고 아웃풋 스트림이라서, 멤버 function으로 만들 수 없음.
    // return type으로 output stream이 있기 때문에 연쇄로 사용 가능하다.
    friend std::ostream& operator << (std::ostream& out, const Point& point);

    friend std::istream& operator >> (std::istream& in, Point& point)
    {
        in >> point.m_x >> point.m_y >> point.m_z;

        return in;
    }
};


std::ostream& operator << (std::ostream& out, const Point& point)
{
    out << "( " << point.m_x << " " << point.m_y << " " << point.m_z << " )";

    return out;
}




int main()
{

    ofstream of("out.txt");
    ifstream in("in.txt");

    if (!in.is_open())
    {
        cerr << "Error" << endl;
        return 1;
    }

    Point p1(1.2, 1.3, 1.4), p2(2.3, 2.4, 2.5);

    cout << p1 << " " << p2 << endl;
    of << p1 << " " << p2 << endl;

    

    Point p3, p4;

    //cin >> p3 >> p4;
    in >> p3 >> p4;
    cout << p3 << " " << p4 << endl;


    in.close();
    of.close();


}

 

 

 

 

우선 class에 대해서 살펴본다.

 

Point class는 3개의 double을 멤버 변수로 가지고 있으며, 따라서 Point 자료형인 p1, p2를 만들어줄 때 3개의 값을 주어야 한다.

 

만약 별도로 주지 않는 경우는 멤버 변수가 모두 0으로 지정된다.

 

 

p1은 1.2, 1.3, 1.4, p2는 2.3, 2.4, 2.5를 값으로 주어서 인스턴스를 만든 상황이고, 

 

cout operator인 <<를 현재 오버로딩 한 상황이다.

 

이는 std::ostream&를 return 하는 함수이며, std::ostream&과 point 인스턴스를 입력으로 받는다.

 

단, 해당 함수는 Point class 내 멤버 함수가 아니기 때문에, this를 이용해서 멤버 변수에 접근이 불가능하며, 따라서 point.m_x와 같은 방식으로만 접근이 가능하다.

 

ofstream인 of를 이용해서 of << p1 << " " << p2 << endl; 코드를 돌리면, 미리 정해진 txt 파일인 out.txt에 해당 내용이 작성되는 것을 확인할 수 있다.

 

 

 

다음으로는 output file stream이 아닌, input file stream인 ifstream을 이용해 본다.

 

이번에는 입력을 받아야 하기 때문에, Point p3, p4는 별도의 값을 지정해주지 않고 인스턴스만 만들어준다.

 

다음으로 ifstream인 in를 이용해서, in >> p3 >> p4;로 입력받아준다.

 

현재 in.txt는 다음과 같다.

 

 

이를 이용해서 p3, p4를 받을 수 있다.

 

위 코드에서 주의해야 할 점은, 출력해 주는 연산자인 >> 를 오버로딩 할 때는 point 인스턴스를 const로 해야 하지만, 입력받는 연산자인 << 를 오버로딩 할 때는 변수 값이 바뀔 수 있으니 point 인스턴스를 const로 하면 안 된다. const를 사용하면 빌드가 되지 않는다.

 

 

 

 

Chapter 9.3 단항 연산자 오버로딩

 

 

이번 챕터에서는 단항 연산자(-, ! 등)에 대해서도 오버로딩 하는 것을 다룬다.

 

 

예제 코드 1

#include <iostream>
using namespace std;

class Cents
{
private:
    int m_cents;

public:
    Cents(int cents = 0) { m_cents = cents; }
    int getCents() const { return m_cents; }
    int& getCents() { return m_cents; }

    Cents operator - () const
    {
        return Cents(-m_cents);
    }

    friend std::ostream& operator << (std::ostream& out, const Cents& cents)
    {
        out << cents.m_cents;
        return out;
    }
};

int main()
{
    Cents cents1(6);
    Cents cents2(8);

    cout << cents1 << endl;
    cout << -cents1 << endl;
    cout << -Cents(-10) << endl;
}

 

 

 

class 내 멤버 변수를 살펴보면, operator -에 대해서 오버로딩이 이루어진 것을 알 수 있다.

 

해당 operator는 변수의 값을 변경하지 않기 때문에, const를 붙여주었고, return 해줄 때 Cents(-m_cents);로 해서 m_cents에 마이너스 값을 붙여서 Cents 객체로 만든 다음 return 해준다.

 

 

 

아래 main문을 살펴보면,

 

-cents1을 했을 때 음수로 바뀌어서 나오는 것을 볼 수 있으며,

 

-10이라는 값을 멤버 변수로 해서 Cents를 만든 다음 거기에 -를 적용해서 10이 나오는 것을 볼 수 있다.

 

 

 

 

예제 코드 2

 

#include <iostream>
using namespace std;

class Cents
{
private:
    int m_cents;

public:
    Cents(int cents = 0) { m_cents = cents; }
    int getCents() const { return m_cents; }
    int& getCents() { return m_cents; }

    Cents operator - () const
    {
        return Cents(-m_cents);
    }

    bool operator ! () const
    {
        // 문맥에 따라서 프로그래밍 하려는 목적에 따라서 결정해야 함.
        return (m_cents == 0) ? true : false;
    }

    friend std::ostream& operator << (std::ostream& out, const Cents& cents)
    {
        out << cents.m_cents;
        return out;
    }
};

int main()
{
    Cents cents1(6);
    Cents cents2(0);

    cout << !cents1 << " " << !cents2 << endl;
}

 

 

 

이번에는 not 연산자(!)를 오버로딩 해본다.

 

Cents 클래스 내 만들어놓은 함수를 보면,

 

operator !를 오버로딩 해놓은 것을 확인할 수 있고, 이번에는 return type이 bool이다.

 

! 연산자 오버로딩의 경우, 프로그래밍하는 사람의 재량에 따라 내용을 작성하면 되며, 이번 코드에서는 m_cents가 0인지 여부에 따라 true와 false를 return 하도록 만들어졌다.

 

 

 

main문을 살펴보면 cents1은 6이고, cents2는 0이다.

 

따라서 !cents1는 0(false)이고, !cents2는 1(true)이 나오는 것을 확인할 수 있다.

 

 

 

Chapter 9.4 비교 연산자 오버로딩

 

 

이번 챕터에서는 비교 연산자(==, <)를 오버로딩 하는 예제를 다루어본다.

 

 

예제 코드 1

#include <iostream>
using namespace std;

class Cents
{
private:
    int m_cents;

public:
    Cents(int cents = 0) { m_cents = cents; }
    int getCents() const { return m_cents; }
    int& getCents() { return m_cents; }

    friend bool operator == (const Cents& c1, const Cents& c2)
    {
        return (c1.m_cents == c2.m_cents);
    }

};


int main()
{
    Cents cents1(6);
    Cents cents2(0);

    if (cents1 == cents2)
        cout << "Equal!" << endl;
    else
        cout << "Not Equal!" << endl;

}

 

 

 

 

main문을 살펴보면, Cents class로 만들어진 두 개의 instance cents1와 cents2를 비교하려고 하고 있다.

 

둘 다 모두 사용자 정의 자료형이기 때문에, 단순히 cents1 == cents2를 요구하면 컴파일러는 비교할 수 없다.

 

이럴 때 연산자 오버로딩이 필요하다.

 

 

operator ==를 사용했을 때 bool type이 return 되도록 만들어주고, 입력은 2개의 Cents type 인스턴스를 입력받도록 한다.

 

return은 c1의 m_cents와 c2의 m_cents가 같은지 여부이다.

 

 

따라서 위 코드의 main문을 실행했을 때 Not Equal 임을 확인할 수 있다.

 

 

 

 

예제 코드 2

 

#include <iostream>
#include <vector>
#include <algorithm>
#include <random>
using namespace std;

class Cents
{
private:
    int m_cents;

public:
    Cents(int cents = 0) { m_cents = cents; }
    int getCents() const { return m_cents; }
    int& getCents() { return m_cents; }

    friend bool operator < (const Cents& c1, const Cents& c2)
    {
        return c1.m_cents < c2.m_cents;
    }
};


int main()
{
    vector<Cents> arr(20);

    for (int i = 0; i < 20; ++i)
    {
        arr[i].getCents() = i;
    }

    std::random_device rd;
    std::mt19937 g(rd());
    std::shuffle(arr.begin(), arr.end(), g);

    for (auto& e : arr)
    {
        cout << e << " ";
    }
    cout << endl;

    std::sort(arr.begin(), arr.end());

    for (auto& e : arr)
    {
        cout << e << " ";
    }
    cout << endl;

}

 

 

 

이번에는 Cents 자료형 인스턴스로 이루어진 vector를 만든 다음, 이를 sorting 해본다.

 

Cents로 이루어진 vector인 arr를 만들고, for문을 이용해서 arr의 i번째 index의 getCents()를 이용해 값을 할당해 준다.

 

그리고 이를 shuffle을 활용해서 섞어준다. 따라서 0부터 19까지의 수 들이 뒤죽박죽 섞인 것을 확인할 수 있다.

 

다음으로, std::sort를 이용해서 오름차순 정렬을 해준다.

 

이때, 단순히 arr.begin()과 arr.end()를 이용해서 sort를 하려고 하면 에러가 발생한다.

 

arr 안에 들어가 있는 element는 int나 float와 같은 자료형이 아니라 Cents 자료형이기 때문이다.

 

따라서, Cents 자료형에 대해서 비교를 할 수 있도록 비교 연산자를 오버로딩 해주어야 한다.

 

Cents class를 살펴보면, < 연산자를 오버로딩 해둔 것을 확인할 수 있다.

 

sort는 오름차순 정렬을 하기 때문에, 두 수에 대해서 누가 더 큰지를 알아야만 정렬이 가능하다.

 

< 연산자를 오버로딩 해주면, std::sort도 정상적으로 작동하는 것을 확인할 수 있다.

 

 

 

Chapter 9.5 증감 연산자 오버로딩

 

 

이번 챕터에서는 증감 연산자(++)에 대한 오버로딩을 다룬다.

 

 

 

예제 코드 1

#include <iostream>
using namespace std;

class Digit
{
private:
    int m_digit;
public:
    Digit(int digit = 0) : m_digit(digit) {}

    // prefix
    Digit& operator ++ ()
    {
        ++m_digit;
        return *this;
    }

    // postfix, int는 더미
    Digit operator ++ (int)
    {
        Digit tmp(m_digit);

        ++(*this);

        return tmp;
    }

    friend std::ostream& operator << (std::ostream& out, const Digit& d)
    {
        out << d.m_digit;
        return out;
	}

    
};


int main()
{
	int a = 10;

	cout << ++a << endl;
	cout << a << endl;

	cout << a++ << endl;
	cout << a << endl;

	Digit d(5);

	cout << ++d << endl;
	cout << d << endl;

	cout << d++ << endl; // error
	cout << d << endl;

}

 

 

 

C++에서는 ++a와 a++가 다르다는 것은 강의 초반부에 다루었던 내용이다.

 

a가 10일 때, ++a를 하면 11이 되고, 그다음 a는 11이 유지된다. 즉, ++a는 곧바로 +1을 적용하고 이후 변동이 없다.

 

그런데 a++를 했을 땐 11이 유지되고, 그 다음 a는 12가 된다. 즉, a++는 a를 return 해주고, 추후에 +1을 해준다.

 

 

이를 Digit라는 class에 적용하는 예시이다.

 

++a와 a++는 동작이 다르기 때문에, 이를 분리해서 정의해주어야 한다.

 

우선 prefix(++a)는 m_digit을 바로 더해주고, *this를 return 해준다. this는 class의 메모리 주소를 의미하므로, 이를 de-reference 해서 class 그 자체를 return 해주면 된다.

 

다음으로 postfix(a++)는 독특하게도 (int)라는 더미 값이 필요하다. 그리고 a++는 실제 return 되는 것은 a 값이어야 하기 때문에, 우선 현재의 값을 tmp라는 변수에다가 저장해 주고, (*this)를 ++ 해준 다음, tmp만 return 해준다.

 

이렇게 하면 return 되는 건 현재의 a 값인 tmp가 되고, *this에다가 +1을 해주는 효과를 낸다.

 

 

Digit 클래스인 instance d가 5일 때, ++d는 6이고 그다음 d는 6이며, d++는 6이고 그 다음 d는 7이 되는 것을 확인할 수 있다.

 

postfix로 만들어줄 때 (int)라는 dummy가 있어야 하는 것은 외워야 된다고 한다.

 

 

 

Chapter 9.6 subscript 연산자 오버로딩

 

 

이번 챕터에서는 subscript [ ] 연산자를 오버로딩 해본다.

 

 

예제 코드 1

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

class IntList
{
private:
    int m_list[10] = { 1, 2, 3, 4, 5, 6, 7 ,8 ,9 ,10 };

public:
    void setItem(int index, int value)
    {
        m_list[index] = value;
    }

    int getItem(int index) const
    {
        return m_list[index];
	}

    int* getList()
    {
        return m_list;
    }
};



int main()
{
    IntList my_list;
    my_list.setItem(3, 1);
    cout << my_list.getItem(3) << endl;
    my_list.getList()[3] = 10;
    cout << my_list.getList()[3] << endl;
}

 

 

 

IntList라는 class 타입의 instance인 my_list를 만들어주고, setItem이라는 멤버 함수를 통해 3번째 index에 있는 값을 1로 바꾸어준다. 

 

그리고 getList()라는 멤버 함수를 이용해서 3번째 index의 값을 10으로 바꿔주었다.

 

현재 상황에서는 my_list에 직접적으로 [ ]를 이용해 값을 바꾸거나 값을 출력할 수 없다.

 

 

 

 

예제 코드 2

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

class IntList
{
private:
    int m_list[10] = { 1, 2, 3, 4, 5, 6, 7 ,8 ,9 ,10 };

public:
    void setItem(int index, int value)
    {
        m_list[index] = value;
    }

    int getItem(int index) const
    {
        return m_list[index];
	}

    int* getList()
    {
        return m_list;
    }

    // index로 접근하거나, 혹은 값을 출력할 수 있도록 하는 기능 모두 지원
    // 항상 Lvalue여야 하니까 주소를 가지고 있는 L 밸류여야 하니까 레퍼런스로 리턴하고 있음.
    // 엄청 많이 쓸 때 사용하기 때문에 if문으로 처리하면 너무 느려짐.
    // if문 사용 권장 X, assert로 걸어놓고 디버깅을 깔끔하게 잘 하자.
    int& operator [] (const int index)
    {
        assert(index >= 0 && index < 10);
        return m_list[index];
    }
};



int main()
{
    IntList my_list;
    my_list[3] = 10;
    cout << my_list[3] << endl;    
}

 

이번 코드에는 subscript operator를 오버로딩 한 케이스이다.

 

class 내 operator 오버로딩 해주는 함수를 보면, int index를 받아서, m_list[index]를 return 해주고 있다. 

 

이때, index로 접근하거나 혹은 값을 출력할 수 있도록 하기 위해서는 객체 그 자체를 return 해주어야 하니 int& (레퍼런스)로 리턴해주고 있다.

 

이를 이용해서 my_list[3] = 10;도 가능하고, my_list[3]으로 cout도 가능하다.

 

 

 

예제 코드 3

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

class IntList
{
private:
    int m_list[10] = { 1, 2, 3, 4, 5, 6, 7 ,8 ,9 ,10 };

public:
    void setItem(int index, int value)
    {
        m_list[index] = value;
    }

    int getItem(int index) const
    {
        return m_list[index];
	}

    int* getList()
    {
        return m_list;
    }

    // index로 접근하거나, 혹은 값을 출력할 수 있도록 하는 기능 모두 지원
    // 항상 Lvalue여야 하니까 주소를 가지고 있는 L 밸류여야 하니까 레퍼런스로 리턴하고 있음.
    // 엄청 많이 쓸 때 사용하기 때문에 if문으로 처리하면 너무 느려짐.
    // if문 사용 권장 X, assert로 걸어놓고 디버깅을 깔끔하게 잘 하자.
    int& operator [] (const int index)
    {
        assert(index >= 0 && index < 10);
        return m_list[index];
    }


    // 값 출력만.
    // const 버전의 Operator를 만들면 const인지 아닌지만 가지고도 오버로딩 가능.
    const int& operator [] (const int index) const
    {
        assert(index >= 0 && index < 10);
        return m_list[index];
    }
};



int main()
{    
    const IntList my_list;
    //my_list[3] = 10;
    cout << my_list[3] << endl;    
}

 

이전 강의에서 다룬 내용인데, const 버전의 멤버 함수를 만들어주면 const 여부만 가지고도 오버로딩이 된다고 했었다.

 

이번 코드에서는 main문을 살펴보면 my_list라는 인스턴스가 const인 것을 볼 수 있다.

 

따라서 이번 케이스에서는 my_list[3]를 통해서 값만 출력할 수 있으면 된다.

 

class 내 operator 함수를 보면, return type도 const int&로 설정해 주고, 함수의 끝쪽에 const를 지정해 준 것을 볼 수 있다.

 

 

 

 

예제 코드 4

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

class IntList
{
private:
    int m_list[10] = { 1, 2, 3, 4, 5, 6, 7 ,8 ,9 ,10 };

public:
    void setItem(int index, int value)
    {
        m_list[index] = value;
    }

    int getItem(int index) const
    {
        return m_list[index];
	}

    int* getList()
    {
        return m_list;
    }

    // index로 접근하거나, 혹은 값을 출력할 수 있도록 하는 기능 모두 지원
    // 항상 Lvalue여야 하니까 주소를 가지고 있는 L 밸류여야 하니까 레퍼런스로 리턴하고 있음.
    // 엄청 많이 쓸 때 사용하기 때문에 if문으로 처리하면 너무 느려짐.
    // if문 사용 권장 X, assert로 걸어놓고 디버깅을 깔끔하게 잘 하자.
    int& operator [] (const int index)
    {
        assert(index >= 0 && index < 10);
        return m_list[index];
    }


    // 값 출력만.
    // const 버전의 Operator를 만들면 const인지 아닌지만 가지고도 오버로딩 가능.
    const int& operator [] (const int index) const
    {
        assert(index >= 0 && index < 10);
        return m_list[index];
    }
};



int main()
{    
    IntList* list = new IntList;
    cout << (*list)[3] << endl;
}

 

마지막은 IntList 타입으로 동적 할당을 해주는 경우이다.

 

이때 list는 포인터가 되기 때문에, list[3]로 접근은 불가능하다.

 

따라서, de-reference를 통해서 포인터가 아닌 포인터가 가리키는 객체로 바꿔주고 난 이후 index로 접근을 해주면 된다.

 

그래서 (*list)[3]를 작성한 것을 알 수 있다.

 

 

 

 

Chapter 9.7 괄호 연산자 오버로딩

 

 

이번 챕터에서는 ( ) 와 같은 괄호 연산자를 오버로딩 하는 내용을 다룬다.

 

 

예제 코드 1

#include <iostream>
using namespace std;

class Accumulator
{
private:
    int m_counter = 0;

public:
    // Functor라고 부름.
    // 객체 이름에다가 괄호 치고 사용하는 방식.
    int operator() (int i) 
    { 
        return (m_counter += i); 
    }

    
};

int main()
{
    Accumulator acc;
    
    cout << acc(10) << endl;
    cout << acc(20) << endl;
    cout << acc(acc(20)) << endl;
}

 

 

 

괄호 연산자는 함수를 호출할 때 사용하는 괄호와 동일한데, 그렇다 보니 괄호 연산자를 오버로딩 하게 되면 마치 함수가 객체인 것처럼 사용을 할 수가 있다.

 

acc는 분명 instance이지만, acc(10)처럼 사용하면 acc가 함수처럼 보인다는 이야기이다.

 

 

class 내 operator ()를 보면, int i를 받아서 m_counter에 더해주는 단순 작업만 진행해 준다.

 

acc(10)는 10, acc(20)은 이전에 m_counter가 10이었기 때문에 30이 되고, acc(acc(20))는 acc(20)의 결과가 50이니, 여기에 50을 더해줘서 100이 되는 구조이다.

 

 

 

 

Chapter 9.8 형변환 오버로딩

 

 

이번 챕터에서는 형변환을 오버로딩하는 예제를 살펴본다.

 

 

 

예제 코드 1

#include <iostream>
using namespace std;

class Cents
{
private:
    int m_cents;

public:
    Cents(int cents = 0)
    {
        m_cents = cents;
    }

    int getCents() { return m_cents; }
    void setCents(int cents) { m_cents = cents; }

    operator int()
    {
        cout << "Casting: " << endl;
        return m_cents;
    }
};

void printInt(const int& value)
{
    cout << value << endl;
}

int main()
{
    Cents cents(7);

    printInt(cents);
}

 

 

 

main문을 살펴보면, Cents class type의 instance인 cents를 만들어주고, 이를 printInt라는 함수를 이용해서 값을 cout 하려고 하고 있다.

 

그런데, cents는 Cents type이기 때문에 printInt의 매개변수인 const int형과 맞지 않는다. 이런 경우 컴파일러가 Cents type을 int type으로 casting 할 수 있는지를 확인한다. 원래는 이러한 casting이 불가능하기 때문에, 코드에서 에러가 발생한다.

 

하지만, 우리가 Cents 클래스 내에 int() casting 연산자를 만들어두었고, m_cents가 return 되도록 했다.

 

따라서, 위 코드에선 컴파일러가 cents를 자동적으로 int casting 해서 printInt가 제대로 동작할 수 있게 된다.

 

 

 

 

예제 코드 2

#include <iostream>
using namespace std;

class Cents
{
private:
    int m_cents;

public:
    Cents(int cents = 0)
    {
        m_cents = cents;
    }

    int getCents() { return m_cents; }
    void setCents(int cents) { m_cents = cents; }

    operator int()
    {
        cout << "Casting: " << endl;
        return m_cents;
    }
};

void printInt(const int& value)
{
    cout << value << endl;
}

class Dollar
{
private:
    int m_dollars = 0;

public:
    Dollar(const int& input) : m_dollars(input) {}

    operator Cents()
    {
        return Cents(m_dollars * 100);
    }
};

int main()
{
    Dollar dol(2);

    Cents c = dol;

    printInt(c);

    printInt(Cents(dol));
}

 

 

 

이번에는 Dollar라는 클래스를 새로 하나 만들어주고, 그 안에 Cents() operator를 오버로딩 해준다. 1달러는 100센트 임을 감안하여, m_dollars * 100의 값을 Cents로 return 하도록 만들어주었다.

 

2달러짜리 instance인 dol을 만들어주었고, 이를 이용해서 Cents(dol)을 해주어 m_cents가 200인 c를 만들어주었다.

 

그래서 printInt(c);를 하면 200이 나오게 된다.

 

그리고, 이 과정을 한 번에 작성하면 printInt(Cents(dol));이 된다.

 

 

 

Chapter 9.9 복사 생성자, 복사 초기화, 반환값 최적화

 

 

이번 챕터에서는 복사 생성자에 대해서 배우고, 이와 관련한 복사 초기화, 반환값 최적화에 대해서 알아본다.

 

 

 

예제 코드 1

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

class Fraction
{
private:
	int m_numerator;
	int m_denominator;

public:
    Fraction(int num = 0, int den = 1)
        : m_numerator(num), m_denominator(den)
    {
        assert(den != 0);
    }

    Fraction(const Fraction& fraction) // copy constructor, private으로 옮기면 외부에서 사용할 수 없음.
        : m_numerator(fraction.m_numerator), m_denominator(fraction.m_denominator)
    {
        cout << "Copy constructor called " << endl;
    }

    friend std::ostream& operator << (std::ostream& out, const Fraction& f)
    {
        out << f.m_numerator << " / " << f.m_denominator << endl;
        return out;
    }

};

int main()
{
    
	Fraction frac(3, 5);

	//Fraction fr_copy(frac); // 복사 생성자 호출하게 됨. (Copy constructor)
	Fraction fr_copy = frac; // 복사 초기화, 동일하게 복사 생성자 호출함. (Copy initialization)

	Fraction f_copy(Fraction(3, 10)); // RVO, Return Value Optimization, 임시 객체가 만들어지지 않고 바로 f_copy로 초기화됨.

	cout << frac << "" << fr_copy << endl;
}

 

 

 

복사 생성자 (Copy constructor)라고 하는 것은, 클래스 자료형인 Fraction 인스턴스가 매개변수로 사용될 때 이를 그대로 복사해 주는 기능을 수행한다.

 

Fraction type의 instance인 frac(3, 5)를 만든 다음, 이를 fr_copy라는 이름으로 그대로 복사해 주는 코드를 main문에서 확인할 수 있다.

 

Fraction fr_copy(frac);로 작성하면 frac라는 인스턴스가 복사되며, 이때 복사 생성자가 호출된다.

 

위의 결과 화면에서 Copy constructor called가 찍힌 것을 확인할 수 있다.

 

 

 

위에서는 복사가 되는 경우를 알아봤는데, 복사 초기화라는 것도 존재한다. 이는 Fraction fr_copy = frac;와 같이 구현할 수 있다. 이는 frac과 동일한 값을 가지도록 instance를 만들어주는 것이다. 단, 이렇게 복사 초기화를 하는 경우에도 동일하게 복사 생성자는 호출한다.

 

 

마지막으로, Fraction f_copy(Fraction(3, 10))에서, Fraction(3, 10)는 사실상 거쳐가는 것이고 실제로는 존재할 필요가 없기 때문에 별도의 복사 생성자가 호출되지 않으며(불필요한 객체 복사를 제거) 바로 f_copy로 초기화가 된다. 이를 Return Value Optimization(RVO)라고 한다.

 

 

 

예제 코드 2

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

class Fraction
{
private:
	int m_numerator;
	int m_denominator;

public:
    Fraction(int num = 0, int den = 1)
        : m_numerator(num), m_denominator(den)
    {
        assert(den != 0);
    }

    Fraction(const Fraction& fraction) // copy constructor, private으로 옮기면 외부에서 사용할 수 없음.
        : m_numerator(fraction.m_numerator), m_denominator(fraction.m_denominator)
    {
        cout << "Copy constructor called " << endl;
    }

    friend std::ostream& operator << (std::ostream& out, const Fraction& f)
    {
        out << f.m_numerator << " / " << f.m_denominator << endl;
        return out;
    }

};

Fraction doSomething()
{
    Fraction temp(1, 2);

    cout << &temp << endl;
    return temp;
}



int main()
{    
    // 컴파일러가 해주고, Debug 모드에서는 안해줌.
    Fraction result = doSomething(); // return value가 함수를 벗어나면 유지가 될 필요가 없기 때문에 임시 객체로 만들어짐. 반환값 최적화.

    cout << &result << endl; // &temp와 동일한 주소값이 나옴. 즉 주소가 같으니 복사를 할 필요가 없음.
    cout << result << endl;
    
}

 

 

 

이번에는 조금 다른 케이스에서 RVO를 살펴본다.

 

doSomething이라는 함수를 만들어주고, 함수 내부에서 Fraction temp(1, 2);라는 변수를 만들어주었다.

 

그리고 temp를 그대로 출력한다.

 

그렇다면, 사실상 temp는 함수를 벗어나면 유지될 필요가 없는 상황이다.

 

따라서, 컴파일러가 RVO를 실행하게 되며, 컴파일러는 temp 객체를 doSomething() 함수의 스택 프레임에 생성하는 대신에, 함수가 호출되는 영역인 main 함수의 스택 프레임에 있는 result의 메모리 공간에 직접 생성한다.

 

따라서, 복사 생성자는 별도 호출되지 않고, temp와 result는 동일한 메모리 주소를 가지게 된다. (위 출력창을 보면, &temp와 &result의 결과가 같은 것을 볼 수 있다.)

 

단, RVO는 컴파일러 최적화라서 Debug 모드에서는 비활성화되는 경우가 많다.

 

 

 

Chapter 9.10 변환 생성자, explicit, delete

 

 

이번 챕터에서는 프로그래머의 편의를 돕기 위해서 생성자를 변환해 주는 역할을 하는 변환 생성자와, 변환 생성자를 사용하지 못하게 막아버리는 explicit keyword, 그리고 생성자를 삭제하는 기능인 delete keyword에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>
#include <cassert>

using namespace std;

class Fraction
{
private:
    int m_numerator;
    int m_denominator;

public:

    Fraction(int num = 0, int den = 1)
        : m_numerator(num), m_denominator(den)
    {
        assert(den != 0);
    }

    Fraction(const Fraction& fraction) // copy constructor, private으로 옮기면 외부에서 사용할 수 없음.
        : m_numerator(fraction.m_numerator), m_denominator(fraction.m_denominator)
    {
        cout << "Copy constructor called " << endl;
    }

    friend std::ostream& operator << (std::ostream& out, const Fraction& f)
    {
        out << f.m_numerator << " / " << f.m_denominator << endl;
        return out;
    }

};

void doSomething(Fraction frac)
{
    cout << frac << endl;
}


int main()
{
    Fraction frac(7);

    doSomething(Fraction(7));

    // 함수 입장에서 봤을 때 받아들일 수 잇는게 Fraction 밖에 없기 때문에, 생성자처럼 자동으로 바꿔줌. 
    // num에 7이 들어가는 상황이고 den은 1로 자동으로 들어감.
    doSomething(7); // 변환 생성자
    
}

 

 

 

이번 챕터에서는 변환 생성자라는 내용을 다룬다.

 

main문을 살펴보면,

 

doSomething이라는 함수에 Fraction(7)이라는 instance를 주면 frac을 출력하는 상황이다.

 

이때, Fraction(7)이 아니라 doSomething(7)을 주면 어떻게 될까?

 

doSomething 함수 입장에서 봤을 때, 인자로 받아들일 수 있는 게 Fraction 밖에 없기 때문에 생성자처럼 자동으로 바꿔주게 된다.

 

즉, doSomething(7)으로 하더라도 함수는 이를 doSomething(Fraction(7))로 작동하는 것이고, Fraction의 num을 7로 지정하면서 작동하게 된다.

 

여기서 Fraction(int num = 0, int den = 1)이라는 생성자가 바로 변환 생성자가 된다.

 

 

 

예제 코드 2

#include <iostream>
#include <cassert>

using namespace std;

class Fraction
{
private:
    int m_numerator;
    int m_denominator;

public:
    // explicit 키워드 사용 시 이 생성자가 변환 생성자로서 기능을 못하게 됨.
    explicit Fraction(int num = 0, int den = 1)
        : m_numerator(num), m_denominator(den)
    {
        assert(den != 0);
    }

    Fraction(const Fraction& fraction) // copy constructor, private으로 옮기면 외부에서 사용할 수 없음.
        : m_numerator(fraction.m_numerator), m_denominator(fraction.m_denominator)
    {
        cout << "Copy constructor called " << endl;
    }

    friend std::ostream& operator << (std::ostream& out, const Fraction& f)
    {
        out << f.m_numerator << " / " << f.m_denominator << endl;
        return out;
    }

};

void doSomething(Fraction frac)
{
    cout << frac << endl;
}


int main()
{
    Fraction frac(7);

    doSomething(Fraction(7));

	// explicit keyword로 인해 변환 생성자 사용 X
    //doSomething(7); // 변환 생성자
    
}

 

이번 예제 코드에서는, 예제 코드 1과 다르게 Fraction 생성자에 explicit라는 keyword가 앞에 붙었다.

 

이런 경우에는, 변환 생성자 기능을 사용할 수 없도록 만든다.

 

이는 프로그래머가 의도하지 않은 방향으로 작동할 수 있어서 이를 방지하기 위함이다.

 

그래서 예제 코드 2에서는 doSomething(7);과 같은 방식으로는 사용이 불가능하다.

 

 

 

예제 코드 3

#include <iostream>
#include <cassert>

using namespace std;

class Fraction
{
private:
    int m_numerator;
    int m_denominator;

public:

    Fraction(char) = delete; // char 타입의 인자를 받는 생성자 삭제.

    // explicit 키워드 사용 시 이 생성자가 변환 생성자로서 기능을 못하게 됨.
    //explicit Fraction(int num = 0, int den = 1)
    Fraction(int num = 0, int den = 1)
        : m_numerator(num), m_denominator(den)
    {
        assert(den != 0);
    }

    Fraction(const Fraction& fraction) // copy constructor, private으로 옮기면 외부에서 사용할 수 없음.
        : m_numerator(fraction.m_numerator), m_denominator(fraction.m_denominator)
    {
        cout << "Copy constructor called " << endl;
    }

    friend std::ostream& operator << (std::ostream& out, const Fraction& f)
    {
        out << f.m_numerator << " / " << f.m_denominator << endl;
        return out;
    }

};

void doSomething(Fraction frac)
{
    cout << frac << endl;
}


int main()
{
    //Fraction frac2('c'); 
    // 지워버렸기 때문에 사용할 수 없음. 버전업을 하면서 사람들이 예전에 쓰던 방식으로 쓸게 걱정되어서 못쓰게 막는 경우.    
}

 

이번 예제에서는, delete keyword를 사용하는 예제이다.

 

Fraction 생성자에 char type의 인자가 들어가는 경우를 막기 위해서, Fraction(char) = delete;로 처리를 해주었다.

 

이렇게 하면, Fraction을 이용해서 instance를 만들어줄 때 char type의 변수를 인자로 주는 경우 사용이 불가능하다.

 

그래서 원래는 Fraction frac2('c');로 만들면 char 변수인 'c'를 int로 바꿔서 frac2 instance가 만들어지지만, delete keyword를 사용해서 막아버린 상황이라 현재 상황에서는 사용할 수 없게 된다.

 

 

 

Chapter 9.11 대입 연산자 오버로딩, 깊은 복사, 얕은 복사

 

 

이번 챕터에서는 깊은 복사, 얕은 복사에 대해서 중점적으로 다룬다.

 

 

예제 코드 1

#include <iostream>
#include <cassert>

using namespace std;

class MyString
{
public:
    // m_data, m_length의 주소값만 복사해주는 경우, 얕은 복사(swallow copy)
    char* m_data = nullptr;
    int m_length = 0;


public:
    MyString(const char* source = "")
    {
        assert(source); // 문자열이 비어있지 않은지 확인.

        m_length = std::strlen(source) + 1; // 문자열의 마지막이라는 것을 표현하기 위한 null 캐릭터 추가하기 위해
        m_data = new char[m_length];

        for (int i = 0; i < m_length; ++i)
        {
            m_data[i] = source[i];
        }

	m_data[m_length - 1] = '\0'; // null 캐릭터 넣어주기
    }

    // 소멸자, m_data가 가리키고 있는 메모리를 해제
    ~MyString()
    {
        delete[] m_data;
    }



    char* getString() { return m_data; }
    int getLength() 
    {
        return m_length;
    }
};





int main()
{
    MyString hello("Hello"); 

    cout << (int*)hello.m_data << endl; 
    cout << hello.getString() << endl;

    {
        MyString copy = hello; // copy가 생성이 되고 있을 땐 복사 생성자를 호출, 우리가 복사 생성자를 생성을 안했으니 기본 복사 생성자 사용.
        //copy = hello; 호출을 할 때는 대입 연산자가 호출됨
        cout << (int*)copy.m_data << endl; // hello의 m_data와 copy의 m_data의 주소가 같음을 확인. 복사 정상적으로 됨.
        cout << copy.getString() << endl;
    }

    // copy는 new를 호출한게 아니라, 그냥 hello의 포인터 주소를 복사한 것임.
    // 즉, heap에는 new로 할당된 메모리가 한 덩어리 밖에 없음.
    // 그런데 scope 벗어나면서 delete가 되어버림. 
    // hello의 m_data는 같은 주소를 가리키고 있었는데, 힙에서 메모리를 지워버렸으니 문제가 발생함.
    cout << hello.getString() << endl; 
    
}

 

 

 

이번 예제 코드에서는, MyString이라고 하는 class를 사용한다.

 

MyString는 char* type의 m_data와, length를 나타내는 int type m_length;를 멤버 변수로 가지고 있다.

 

생성자를 살펴보면, 매개변수로 주어지는 source가 비어있지 않다면, 새로운 m_data를 동적 할당으로 생성한 뒤 source에 있는 char 값을 복사하는 작업을 진행해 준다.

 

 

이제 main문으로 돌아가서,

 

MyString class의 인스턴스인 hello를 만들어주고, source는 "Hello"로 전달했다.

 

다음에는 hello의 m_data를 int* (int형 포인터)로 캐스팅해서 출력해 주고, hello의 getString을 해준다.

 

다음에는 코드 scope를 나눠뒀는데, 

 

MyString type의 instance인 copy를 만들어주고, 여기에 hello를 대입해 준다. 

 

이렇게 생성이 되는 동시에 대입해 주는 경우는 복사 생성자(Copy Constructor)를 호출하게 된다.

 

만약 MyString copy; copy = hello;로 복사만 따로 분리하는 경우는 대입 연산자(=)가 호출된다.

 

나머지 2줄은 동일하게 copy의 m_data와 getString을 출력해 준다.

 

마지막으로는 hello.getString을 다시 출력한다.

 

 

 

출력창을 보면, hello.m_data의 메모리 주소와 copy.m_data의 메모리 주소가 같은 것을 알 수 있다.

 

즉, hello의 m_data가 copy의 m_data로 정상적으로 복사가 된 것을 알 수 있고, 또한 hello.getString()과 copy.getString()이 같은 것도 확인할 수 있다.

 

그런데, 마지막에 hello.getString()에는 이상한 한자들이 나오게 되는데, 

 

이것이 이번 예제 코드에서 다루는 주요한 문제이다.

 

 

 

위에서 언급했듯이, 우리가 별도의 복사 생성자를 만들어주지 않은 상황이기 때문에, 컴파일러는 기본 복사 생성자를 사용해서 MyString copy = hello;를 수행하게 된다. 

 

컴파일러가 제공하는 기본 복사 생성자는 얕은 복사(Shallow Copy)를 수행한다.

 

얕은 복사란, 주소값만 복사해 주는 것을 의미한다.

 

따라서, copy의 m_data와 hello의 m_data는 Heap의 동일한 메모리 주소를 가리키고 있는 포인터가 되는 것이다.

 

그런데, 예제 코드에서 우리는 소멸자를 정의했고, m_data를 delete 하도록 해두었다.

 

소멸자는 코드 scope를 벗어날 때 호출되므로, copy를 감싸고 있는 코드 scope를 벗어났을 때 copy.m_data를 메모리 해제하게 된다.

 

지금 copy.m_data와 hello.m_data는 Heap 영역 상에서 동일한 메모리 주소를 가리키고 있기 때문에, copy.m_data를 메모리 해제하게 되면 hello.m_data 또한 메모리 해제되는 효과를 가져오게 된다.

 

그래서 맨 마지막 줄에서 hello.m_data를 출력하게 되면 이상한 값이 출력되는 것이다. 이미 메모리가 해제된 상태이기 때문이다.

 

따라서, 이런 문제를 해결하기 위해서는 복사 생성자를 따로 구현해 주고, 깊은 복사(Deep Copy)가 일어나도록 해야 한다.

 

 

 

예제 코드 2

#include <iostream>
#include <cassert>

using namespace std;
class MyString
{
public:
    // m_data, m_length의 주소값만 복사해주는 경우, 얕은 복사(swallow copy)
    char* m_data = nullptr;
    int m_length = 0;


public:
    MyString(const char* source = "")
    {
        assert(source); // 문자열이 비어있지 않은지 확인.

        m_length = std::strlen(source) + 1; // 문자열의 마지막이라는 것을 표현하기 위한 null 캐릭터 추가하기 위해
        m_data = new char[m_length];

        // 똑같은 주소를 가리키고 있는 포인터 변수가 메모리를 지워버리게 되면,
        // 힙의 메모리가 있어야 될 곳에 메모리가 지워져서 사라졌는데도 불구하고 또 다른 포인터가 그 주소로 가서 메모리에 있는 값을
        // 가져가는 문제가 발생함.

        for (int i = 0; i < m_length; ++i)
        {
            m_data[i] = source[i];
        }

		m_data[m_length - 1] = '\0'; // null 캐릭터 넣어주기
    }

    // Copy Constructor를 제대로 구현하면 좋지만 여건이 안되어서 shallow copy를 막기만 하는 경우?
    // 생성자를 쓰면 안된다고 막아버리는 방법
	// MyString(const MyString& source) = delete;
     
    
    // 자기 자신이 파라미터로 들어오는 Copy Constructor
    MyString(const MyString& source)
    {
        cout << "Copy constructor" << endl;

        m_length = source.m_length;

        if (source.m_data != nullptr)
        {
            m_data = new char[m_length]; // 주소값을 복사하는게 아니라 메모리를 다시 할당받고, 값을 복사, 깊은 복사(Deep copy)

            for (int i = 0; i < m_length; ++i)
            {
                m_data[i] = source.m_data[i];
            }
        }
        else
        {
            m_data = nullptr;
        }
    }

    // 소멸자, m_data가 가리키고 있는 메모리를 해제
    ~MyString()
    {
        delete[] m_data;
    }



    char* getString() { return m_data; }
    int getLength() 
    {
        return m_length;
    }
};





int main()
{
    MyString hello("Hello"); 
    
    cout << (int*)hello.m_data << endl; 
    cout << hello.getString() << endl;

    {
        MyString copy = hello; // copy가 생성이 되고 있을 땐 복사 생성자를 호출, 우리가 복사 생성자를 생성을 안했으니 기본 복사 생성자 사용.
        //copy = hello; 호출을 할 때는 대입 연산자가 호출됨
        cout << (int*)copy.m_data << endl; // hello의 m_data와 copy의 m_data의 주소가 같음을 확인. 복사 정상적으로 됨.
        cout << copy.getString() << endl;
    }

    // copy는 new를 호출한게 아니라, 그냥 hello의 포인터 주소를 복사한 것임.
    // 즉, heap에는 new로 할당된 메모리가 한 덩어리 밖에 없음.
    // 그런데 scope 벗어나면서 delete가 되어버림. 
    // hello의 m_data는 같은 주소를 가리키고 있었는데, 힙에서 메모리를 지워버렸으니 문제가 발생함.
    cout << hello.getString() << endl; 
    
}

 

 

 

이전에 언급된 문제를 해결하기 위해서, 복사 생성자를 별도로 구현했다.

 

const MyString&을 source로 받고, source의 m_data가 nullptr이 아닌 경우, m_data를 새로 동적 할당한 뒤 source의 m_data를 m_data에 복사해 준다.

 

이렇게 하면 단순히 얕은 복사가 이루어지는 것이 아니라, 별도로 동적 할당을 해주는 것이기 때문에 깊은 복사가 이루어진다.

 

출력창을 확인해 보면, 마지막에 Hello가 정상적으로 출력되는 것을 확인할 수 있다.

 

즉, 코드 scope를 벗어나서 copy.m_data가 delete[] 된 상황이더라도, 기존의 hello.m_data에는 영향이 없다는 것이다.

 

이렇게, 복사 생성자에서 동적 할당이 진행되도록 구현해 주면 문제가 발생하지 않는다.

 

만약, 상황에 따라 위 코드처럼 별도로 구현하기가 어렵지만 얕은 복사를 막고 싶다면 이전 강의에서 다룬 delete를 사용해서 생성자를 막아버리는 방법이 있다.

 

 

 

예제 코드 3

#include <iostream>
#include <cassert>

using namespace std;


// 결국 MyString 따로 만들어서 쓰지 말고 std::string 쓰면 됨.
// std::string
class MyString
{
public:
    // m_data, m_length의 주소값만 복사해주는 경우, 얕은 복사(swallow copy)
    char* m_data = nullptr;
    int m_length = 0;


public:
    MyString(const char* source = "")
    {
        assert(source); // 문자열이 비어있지 않은지 확인.

        m_length = std::strlen(source) + 1; // 문자열의 마지막이라는 것을 표현하기 위한 null 캐릭터 추가하기 위해
        m_data = new char[m_length];

        // 똑같은 주소를 가리키고 있는 포인터 변수가 메모리를 지워버리게 되면,
        // 힙의 메모리가 있어야 될 곳에 메모리가 지워져서 사라졌는데도 불구하고 또 다른 포인터가 그 주소로 가서 메모리에 있는 값을
        // 가져가는 문제가 발생함.

        for (int i = 0; i < m_length; ++i)
        {
            m_data[i] = source[i];
        }

		m_data[m_length - 1] = '\0'; // null 캐릭터 넣어주기
    }

    // Copy Constructor를 제대로 구현하면 좋지만 여건이 안되어서 shallow copy를 막기만 하는 경우?
    // 생성자를 쓰면 안된다고 막아버리는 방법
	// MyString(const MyString& source) = delete;
     
    
    // 자기 자신이 파라미터로 들어오는 Copy Constructor
    MyString(const MyString& source)
    {
        cout << "Copy constructor" << endl;

        m_length = source.m_length;

        if (source.m_data != nullptr)
        {
            m_data = new char[m_length]; // 주소값을 복사하는게 아니라 메모리를 다시 할당받고, 값을 복사, 깊은 복사(Deep copy)

            for (int i = 0; i < m_length; ++i)
            {
                m_data[i] = source.m_data[i];
            }
        }
        else
        {
            m_data = nullptr;
        }
    }

    
    MyString& operator = (const MyString& source)
    {
        // shallow copy => this->m_data = source.m_data;, this->m_length = source.m_length;
        // default copy constructor는 이런 내용으로 진행해줌.

        
        cout << "Assignment operator " << endl;

        if (this == &source) // Prevent self-assignment (프로그램 입장에서는 문제 발생)
            return *this;

        // 생성자는 처음 생성되는 것이기 때문에 그 이전에 동적 할당된 메모리를 갖고 있을 가능성이 없음
        // 하지만 Assignment Operator는 내가 이미 메모리를 가지고 있었을 수도 있으니, 갖고 있는걸 지워야함.
        delete[] m_data; 

        m_length = source.m_length;

        if (source.m_data != nullptr)
        {
            m_data = new char[m_length];

            for (int i = 0; i < m_length; ++i)
            {
                m_data[i] = source.m_data[i];
            }
        }
        else
        {
            m_data = nullptr;
        }

        return *this;
    }
    

    // 소멸자, m_data가 가리키고 있는 메모리를 해제
    ~MyString()
    {
        delete[] m_data;
    }



    char* getString() { return m_data; }
    int getLength() 
    {
        return m_length;
    }
};





int main()
{
    MyString hello("Hello"); 
    
    MyString str1 = hello; // str1이 만들어지는 순간, Copy Constructor 호출됨. 주의 필요!

    MyString str2;
    str2 = hello; // 대입만 하면 Assignment Operator 호출됨. 
}

 

 

 

main문을 살펴보자.

 

이전에 언급한 내용이기는 하지만, 이를 코드상으로 다시 한번 확인한다.

 

instance를 만들 때, 복사를 해주는 경우는 복사 생성자가 실행된다.

 

하지만, 인스턴스를 우선 선언해 주고, 그 이후에 복사를 하는 경우에는 대입 연산자(=)가 실행된다.

 

 

 

대입 연산자 오버로딩 쪽 코드를 살펴보면,

 

if (this == &source) 라는 구문이 존재한다. 이런 경우는 내가 나에게 할당하는 경우라고 보면 된다.

 

예를 들어서 hello = hello; 같은 코드가 되는 것이다.

 

사실 사람이 보기에는 그냥 쓸모없는 코드 같지만, 프로그램 입장에서는 문제가 발생할 수 있다고 한다. 따라서 이런 조건에 걸리는 경우면 *this를 return 해주도록 한다.

 

그리고 대입 연산자를 사용하는 경우에 기존 instance가 m_data에 다른 문자열을 할당받았을 수도 있다.

 

main문 상에서 보면 str2이 이에 해당한다. 즉 str2.m_data에 다른 문자열을 할당받았을 수 있는 것이다. (물론 우리 코드에서는 아니지만)

 

따라서, str2.m_data를 우선 delete 시켜주고, str2.m_data를 다시 동적 할당 해서 새로운 힙 메모리를 할당받은 다음 hello.m_data의 내용을 새로 할당받은 이 메모리에 복사해 준다.

 

 

 

Chapter 9.12 initializer list

 

 

이번 챕터에서는 클래스와 같은 사용자 정의 자료형에서 생성자나 또는 대입 연산자를 만들 때 편하게 사용할 수 있는 initializer list에 대해서 알아본다.

 

 

 

예제 코드 1

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


// 클래스 같은 사용자 정의 자료형에서 생성자 또는 대입 연산자를 만들 때 편하게 사용할 수 있는
// Initializer List.

class IntArray
{
private:
    unsigned m_length = 0;
    int* m_data = nullptr;

public:
    IntArray(unsigned length)
        : m_length(length)
    {
        m_data = new int[length];
    }

    
    IntArray(const std::initializer_list<int>& list)
        : IntArray(list.size())
    {
        int count = 0;
        for (auto& element : list)
        {
            m_data[count] = element;
            ++count;
        }
    }
    

    // initializer list 이용해서 생성자 구현할 때 대입 연산자도 같이 오버로딩 해주는게 좋음.
    // TODO: overload operator =
    IntArray& operator = (const IntArray& source)
    {
        if (this == &source)
            return *this;

        delete[] m_data;

        m_length = source.m_length;

        if (source.m_data != nullptr)
        {
            m_data = new int[m_length];

            for (unsigned i = 0; i < m_length; ++i)
            {
                m_data[i] = source.m_data[i];
            }
        }
        else
        {
            m_data = nullptr;
        }

        return *this;
    }

    ~IntArray()
    {
        delete[] this->m_data;
    }

    friend std::ostream& operator << (std::ostream& out, IntArray& arr)
    {
        for (unsigned i = 0; i < arr.m_length; ++i)
        {
            out << arr.m_data[i] << " ";
        }
        out << endl;
        return out;
    }

};


int main()
{
    int my_arr1[5] = { 1, 2, 3, 4, 5 };
    int* my_arr2 = new int[5] {1, 2, 3, 4, 5};

    auto il = { 10, 20, 30 };

    IntArray int_array = { 1, 2, 3, 4, 5 };
    cout << int_array << endl;

    return 0;
}

 

 

 

 

main문을 살펴보면,

 

우리가 array를 정의해 주는 경우, int my_arr1[5] = {1, 2, 3, 4, 5};처럼 쉽게 값을 주고 정의할 수 있다.

 

동일한 방식으로, 포인터로 동적 할당할 때도 사용이 가능하다.

 

그런데, 사용자 정의 자료형에서는 이를 바로 사용할 수는 없다. 

 

클래스의 생성자에 Initializer list가 들어올 수 있도록 만들어 주면 클래스에서도 사용이 가능하다.

 

 

 

IntArray class의 생성자를 보면, const std::initializer_list를 받는 것을 확인할 수 있고, member initializer list에는 IntArray(list.size())를 정의해 두었다. 즉 initializer_list의 크기와 동일한 사이즈로 IntArray를 만들어준다.

 

 

이러한 방식을 적용하면, 우리가 만든 자료형인 IntArray에서도 initializer list를 사용해 값을 쉽게 초기화할 수 있고, IntArray int_array = { 1, 2, 3, 4, 5 }; 처럼 코드를 작성할 수 있게 된다.

 

수업시간에 initializer list를 이용해서 생성자를 구현할 때 대입 연산자도 같이 오버로딩 해주는 게 좋다고 하셔서, 이러한 기능을 하는 코드를 chapter 9.11 코드를 참고해서 작성해 보았다.

 

지금 main문에서는 IntArray instance를 생성함과 동시에 initializer list를 전달하는 상황이기 때문에, 생성자만 구현하면 된다.

 

하지만, IntArray int_array2(3); int_array2 = {10, 20, 30};와 같이 instance 생성과 할당이 분리되는 경우, 혹은 일반적인 객체 간의 복사 대입(int_array2 = int_array1)과 같은 연산을 모두 지원하기 위해서는 대입 연산자 오버로딩이 필요하다.

 

 

 

여기까지 Chapter 9를 모두 정리해보았다.

 

이번 챕터에서는 연산자 오버로딩 이라는 내용을 다루었는데, 내가 원하는 자료형에 대해서 여러가지 연산자를 적용하고 싶을 때 훨씬 더 손쉽게 작동할 수 있도록 하는 역할을 수행해준다.

 

다음 글에서 Chatper 10을 정리해보겠습니다.

 

 

 

 

 

 

이번 글에서는 Chapter 8을 정리해본다.

 

이번 챕터는 객체지향 프로그램의 기초에 대해서 배운다.

 

 

Chatper 8.1 객체지향 프로그래밍과 클래스

 

 

이번 챕터에서는 객체지향 프로그래밍의 가장 기초가 되는 class에 대해서 알아본다.

 

 

 

예제 코드 1

#include <iostream>
#include <string>
#include <vector>
using namespace std;

void pprint(const string &name, const string &address, const int &age,
	const double &height, const double &weight)
{
	cout << name << " " << address << " " << age << " " << height << " " << weight << endl;
}

class Friend
{
public: // access specifier 접근 지정자, Public, Private, Protected 세가지가 있음.
	string m_name;
	string address;
	int age;
	double height;
	double weight;

	// pprint라는 함수도 struct의 멤버가 된다.
	// 멤버가 되면 굳이 Friend를 거쳐서 접근할 필요 없음. 직접 접근 가능함.
	void pprint()
	{
		cout << m_name << " " << address << " " << age << " " << height << " " << weight << endl;
	}
};

int main()
{
	
	Friend jj{ "Jack Jack", "Uptown", 2, 100, 25 };
	cout << &jj << endl;

	pprint(jj.m_name, jj.address, jj.age, jj.height, jj.weight);
	
	
	jj.pprint();

	vector<Friend> my_friends;
	my_friends.resize(2);

	my_friends[0].pprint();
	my_friends[1].pprint();

	// for문 돌리면?
	for (auto &element : my_friends)
	{
		element.pprint();
	}
}

 

객체지향 프로그래밍에서 '객체'가 무엇인가, 라고 하면 '데이터'와 '기능'이 묶여 있는 것을 객체라고 한다.

 

객체는 영어로 object라고 부른다.

 

자동차를 객체로 만든다고 하면, 데이터에는 제조사, 모델명, 색상, 최고 속도 등이 있을 것이고, 기능에는 시동 걸기, 가속하기, 멈추기와 같은 기능이 있을 것이다.

 

이러한 객체를 프로그래밍 언어로 구현한 것이 바로 class이다.

 

위 코드에서, class Friend를 정의했고, 이름과 주소, 나이, 키, 몸무게를 정의했다. 그리고 Friend라는 객체의 기능으로 이름과 주소, 나이, 키, 몸무게를 cout하는 함수인 pprint를 정의했다.

 

함수가 class의 멤버가 되면, class를 거치지 않고 직접 멤버 변수들에 접근할 수 있다.

 

 

 

main문에서, Friend jj{ "Jack Jack", "Uptown", 2, 100, 25 }; 로 jj라는 Friend class를 정의했는데, 여기서 jj는 Friend class로 만들어진 데이터이며, 이를 클래스의 인스턴스라고 부른다. 그리고 jj를 정의해주는 것을 인스턴시에이션이라고 부른다.

 

main문 위에 정의한 pprint 함수를 사용하려면 jj의 멤버변수들을 직접 인자로 주어야한다.

 

하지만 jj.pprint(); 처럼, class 내 기능이 정의되어 있다면 바로 사용이 가능하다.

 

 

Friend라는 class를 여러개 갖고 있는 vector<Friend>를 만들어준 뒤, for each 반복문을 이용해서 각 element의 pprint()를 사용하게 만들 수 있다.

 

 

위의 Friend class를 사용하지 않고, 만약 여러 친구들의 데이터를 정리하려고 하면 vector로 전부 묶어서 보관을 해야 할 것이다.

 

예를 들어서, vector<int> age_vec, vector<double> height_vec과 같이 친구가 가지고 있는 정보를 각각 vector에 담아야 한다.

 

하지만 class를 활용하면, 인스턴스마다 이름, 키, 주소, 몸무게를 각각 가지고 있으니 데이터를 보관하기도 깔끔하다.

 

 

 

Chapter 8.2 캡슐화, 접근 지정자, 접근 함수

 

 

이번 챕터에서는 캡슐화에 대해서 배운다.

 

 

캡슐화의 핵심은 Class를 사용해서 데이터를 나타내는 멤버 변수와, 그 데이터를 조작하는 멤버 함수를 묶는 것이다. 이 과정에서 접근 지정자를 사용해 외부에서 직접 접근할 수 있는 범위를 제어한다.

 

 

예제 코드 1

#include <iostream>
#include <string>
#include <vector>
using namespace std;

class Date2
{


// 멤버 변수들이 코드 전반에 걸쳐서 여러 군데에서 사용된다고 가정하자.
// 어떠한 이유로 인해 멤버 변수의 변수명을 바꿔야하는 경우가 생기면, 바꿀게 너무 많아지고 관리가 어렵다.
// 하지만, private으로 선언되어 있다면 class 내 access function에서만 변경해주면 된다. (캡슐화의 장점)


// 같은 클래스의 멤버라면, 자기 자신은 public 이더라도 private 멤버를 접근할 수 있다.
// access function을 사용하는 것이 장기적으로는 좋다.
// private이 기본이다.
//public: // access specifier, 외부에서 접근할 수 있게 된다.
//private:
	int m_month;
	int m_day;
	int m_year;

public:
	void setDate(const int& month, const int& day, const int& year)
	{
		m_month = month;
		m_day = day;
		m_year = year;
	}

	// Setter
	void setMonth(const int& month)
	{
		m_month = month;
	}

	void setDay(const int& day)
	{
		m_day = day;
	}

	void setYear(const int& year)
	{
		m_year = year;
	}

	// Getter
	const int& getDay()
	{
		return m_day;
	}

	const int& getMonth()
	{
		return m_month;
	}

	const int& getYear()
	{
		return m_year;
	}

	// 다른 메모리에 있는 애들인데도 불구하고, 같은 클래스로부터 나왔으면 가져다 쓸 수 있음.
	void copyFrom(const Date2& original)
	{
		m_month = original.m_month;
		m_day = original.m_day;
		m_year = original.m_year;
	}

};

int main()
{

	Date2 today2;
	/*
	today2.m_month = 8;
	today2.m_day = 2;
	today2.m_year = 2024;
	*/
	today2.setDate(8, 2, 2024); // setDate는 public이므로 접근 가능하다.
	today2.setMonth(10);

	/*
	cout << today2.m_month << " "
		 << today2.m_day << " "
		<< today2.m_year << endl;
	*/

	cout << today2.getDay() << " "
		 << today2.getMonth() << " "
		<< today2.getYear() << endl;


	Date2 today3;
	today3.copyFrom(today2); 
	
}

 

Date2라는 class를 정의했다.

 

 

class에는 m_month, m_day, m_year라는 멤버 변수가 존재한다.

 

 

현재 접근 지정자(access specifier)를 별도로 지정해주지 않았는데, 이런 경우는 private이 기본이다. public으로 선언하면 외부에서 접근할 수 있으며, private이면 외부에서 접근할 수 없다.

 

 

멤버 변수를 private을 두게 되면, 외부에서 해당 멤버 변수를 접근할 수 없기 때문에 데이터를 보호할 수 있다.

 

이런 상황에선 멤버 변수를 바꾸거나, 혹은 멤버 변수의 값을 얻을 수 없는데, 이를 위해서 멤버 함수들을 정의해준다.

 

값을 바꿔주는 setter와, 값을 확인하는 getter를 선언해준다.

 

 

Date2 today2;로 해서 Date2 class로 today2라는 인스턴스를 선언한 뒤, today2.setDate를 이용해 today2라는 인스턴스의 일, 월, 년 값을 결정해준다. 

 

그리고 today2의 일, 월, 년을 구하고 싶다면 today2에 정의된 getter인 getDay, getMonth, getYear를 활용하면 된다.

 

마지막으로, Date2 class에 copyFrom이라는 멤버 함수를 정의하였는데, 다른 인스턴스의 day, month, year의 값을 복사해오는 함수이다.

 

다른 인스턴스라면 당연히 메모리상에서 다른 메모리 위치에 있지만, 같은 클래스인 경우에는 가져다 쓸 수 있다.

 

 

 

Chapter 8.3 생성자(constructors)

 

 

이번 챕터에서는 생성자에 대해서 다룬다. 생성자란, 이 클래스의 인스턴스들은 만들어지자마자 이러한 것들을 가지고 있어야해! 라는 것을 정해주는 기능을 한다.

 

 

예제 코드 1

#include <iostream>
using namespace std;

class Fraction
{
private:
	int m_numerator;   // 분자
	int m_denominator; // 분모

public:

	Fraction() {
	cout << "Constructor called!\n";
	}

	void print()
	{
		cout << m_numerator << " / " << m_denominator << "\n";
	}

};

int main()
{
	Fraction frac;
	frac.print();
}

 

 

 위 코드를 살펴보면, Fraction 이라는 이름의 class가 있고, private으로 두 개의 int형 멤버 변수를 가지고 있다.

 

그리고 public에 Fraction() 이라는 함수가 존재하는데, 이것이 바로 생성자(constructor)이다. 생성자는 클래스의 이름과 동일한 이름을 가지는 특성이 있다. 특이하게도 return이 없다.

 

생성자 안에 간단하게 문자열을 출력하도록 만들어놓았는데, 클래스가 선언이 됨과 동시에 실행되는 것을 확인할 수 있다.

 

우리가 생성자를 명시적으로 적지 않더라도, 기본적으로 Fraction() { }의 형태로 생성자는 무조건 존재한다.

 

 

예제 코드 2

#include <iostream>
using namespace std;

class Fraction
{
private:
	int m_numerator;   // 분자
	int m_denominator; // 분모

public:
	Fraction(const int& num_in = 1, const int& deno_in = 1)
	{
		m_numerator = num_in;
		m_denominator = deno_in;

		cout << "Constructor called!\n";
	}
	
	void print()
	{
		cout << m_numerator << " / " << m_denominator << "\n";
	}

};

int main()
{

	Fraction frac;
	frac.print();
}

 

 

 

이번에는 생성자에 매개변수가 있는 경우를 보자.

 

num_in이라는 int형 매개변수와 deno_in이라는 int형 매개변수를 정의하고, 각각이 class의 멤버변수에 값을 주도록 만들었다.

 

현재는 매개변수에 기본값을 각각 1씩 설정해두어서, Fraction frac;을 하더라도 오류가 발생하지 않는다.

 

 

 

 

예제 코드 3

#include <iostream>
using namespace std;

class Fraction
{
private:
	int m_numerator;   // 분자
	int m_denominator; // 분모

public:
	Fraction(const int& num_in, const int& deno_in)
	{
		m_numerator = num_in;
		m_denominator = deno_in;

		cout << "Constructor called!\n";
	}
	
	void print()
	{
		cout << m_numerator << " / " << m_denominator << "\n";
	}

};

int main()
{

	//Fraction frac{1, 2};
	Fraction frac(1, 2);
	frac.print();
}

 

이번 코드에는 생성자의 매개변수에 기본값을 주지 않은 경우이다.

 

기본값을 주지 않았기 때문에, Fraction class의 인스턴스를 만들 때 반드시 2개의 int형 인자를 전달해주어야 한다.

 

인자를 전달하는 방법은 두 가지가 있다. frac{1, 2}; 와 frac(1, 2);를 사용할 수 있다.

 

생성자가 있는 경우는 두 방식 모두 가능하지만, { }를 사용하는 uniform initialization은 타입 변환을 허용하지 않아서, 타입에 대해서 더 엄격하다고 알고 있으면 된다.

 

 

 

예제 코드 4

#include <iostream>
using namespace std;

class Second
{
public:
	Second()
	{
		cout << " class Second constructor()" << endl;
	}
};


class First
{
	Second sec;

public: // private면 main에서 First fir; 불가능.
	First()
	{
		cout << " class First constructor()" << endl;
	}
};



int main()
{
	First fir;
}

 

 

 

마지막 예제는 class 내에 class를 선언하는 경우, 어떤 순서로 생성자가 실행되는지를 확인하는 예제이다.

 

코드를 보면, First라는 class 안에 Second라는 class로 만들어진 멤버 변수가 존재하는 상황이다.

 

확인해본 결과, First의 멤버 변수인 Second의 생성자가 먼저 실행되는 것을 확인할 수 있다.

 

이는 C++에서 객체를 생성할 때, 멤버 변수를 먼저 생성한 뒤 해당 클래스의 생성자가 실행되기 때문이다.

 

현재 Second는 First class의 멤버 변수이기 때문에, 순서 상 Second를 먼저 실행하게 되는 것이다.

 

 

 

Chapter 8.4 생성자 멤버 초기화 목록(Constructor member initializer list)

 

 

이번 챕터에서는 멤버 이니셜라이저 리스트에 대해서 알아본다.

 

이는 객체의 멤버변수들을 초기화하는 기능을 수행한다.

 

 

예제 코드 1

#include <iostream>
using namespace std;

class Something
{
private:
	int m_i;
	double m_d;
	char m_c;
	int m_arr;

public:
    Something()
        : m_i(1), m_d(3.14), m_c('a'), 
        m_arr{ 1, 2, 3, 4, 5 } // member initializer list
        // : m_i{1}, m_d{3.14}, m_c{'a'} // member initializer list
    {
    }

    void print()
    {
	std::cout << m_i << " " << m_d << " " << m_c << "\n";
        for (auto m : m_arr)
        {
            cout << m << " ";
        }
        cout << endl;
    }
};


int main()
{
    Something som;
    som.print();
}

 

 

 

앞에서 언급했듯이, class는 생성자를 가지고 있다.

 

그런데, 이번에는 생성자에 : 를 해주고 class의 각 멤버 변수의 값을 전달하고 있다.

 

Something() : m_i(1), m_d(3.14)와 같이, : 를 사용한 후 클래스의 멤버 변수에 각각 값을 주는 방식이 바로 member initializer list라는 기능이다. 

 

이를 이용해서 멤버 변수를 초기화해줄 수 있다.

 

출력 결과를 보면, m_i가 1, m_d가 3.14, m_c가 a, m_arr이 각각 1, 2, 3, 4, 5로 이루어져 있어서 출력된 것을 확인할 수 있다.

 

 

 

예제 코드 2

 

#include <iostream>
using namespace std;

class Something
{
private:
	int m_i;
	double m_d;
	char m_c;
	int m_arr;

public:
    Something()
        : m_i(1), m_d(3.14), m_c('a'), 
        m_arr{ 1, 2, 3, 4, 5 } // member initializer list
        // : m_i{1}, m_d{3.14}, m_c{'a'} // member initializer list
    {
        // initializer list가 실행된 후, 이 부분이 추가로 실행되면서 최종적인 값은 하기 내용으로 적용이 된다.
		m_i = 2;
		m_d = 6.28;
		m_c = 'B';
    }

    void print()
    {
	std::cout << m_i << " " << m_d << " " << m_c << "\n";
        for (auto m : m_arr)
        {
            cout << m << " ";
        }
        cout << endl;
    }
};


int main()
{
    Something som;
    som.print();
}

 

 

 

 

만약 생성자 안에 멤버 변수의 값을 별도로 변경해준다면 어떻게 될까?

 

member initializer list에서는 m_i를 1, m_d를 3.14, m_c를 a로 지정했으나, 생성자 안에서 m_i를 2, m_d를 6.28, m_c를 B로 지정했기 때문에, 실제로 클래스 멤버 변수의 값이 바뀐 것을 확인할 수 있다.

 

 

만약 멤버 변수를 선언할 때 기본값을 주면 멤버 변수의 값은 어떻게 될까?

 

private: int m_i = 100; 이런 방식으로 멤버 변수의 기본값을 주더라도, 생성자를 우선시 하기 때문에 결국 결과는 같다.

 

 

 

예제 코드 3

#include <iostream>
using namespace std;

class B
{
    int m_b;

public:
    B(const int& m_b_in)
        : m_b(m_b_in) // member initializer list
    {
        
	}
};



class Something
{
    // 멤버 변수에 기본값을 주면 초기화가 어디에 적용을 받을까? ==> 생성자가 우선이다.
private:
    int m_i = 100;
	double m_d = 100.0;
    char m_c = 'F';
    int m_arr[5] = { 100, 200, 300, 400, 500 };
    B m_b{ 1024 };


public:
    Something()
        : m_i(1), m_d(3.14), m_c('a'), 
        m_arr{ 1, 2, 3, 4, 5 }, m_b(m_i-1) // member initializer list
        // : m_i{1}, m_d{3.14}, m_c{'a'} // member initializer list
    {
        // initializer list가 실행된 후, 이 부분이 추가로 실행되면서 최종적인 값은 하기 내용으로 적용이 된다.
        m_i = 2;
        m_d = 6.28;
        m_c = 'B';
    }

    void print()
    {
		std::cout << m_i << " " << m_d << " " << m_c << "\n";
        for (auto m : m_arr)
        {
            cout << m << " ";
        }
        cout << endl;
    }
};


int main()
{
    Something som;
    som.print();
}

 

 Something이라는 class의 멤버 변수를 보면, B 라는 class인 m_b라는 멤버 변수가 존재한다.

 

member initializer list에 m_b를 m_i-1를 초기값으로 지정했으니, m_b는 0이 초기값이 된다.

 

그럼 class B 입장에서는 인스턴스인 m_b에 0을 기본값으로 준 것이니, class B의 생성자로 넘어가게 되고 B(const int& m_b_in)의 적용을 받아, class B의 멤버 변수인 m_b를 0으로 지정해주게 된다.

 

 

 

Chapter 8.5 위임 생성자

 

 

이번 챕터에서는 생성자가 다른 생성자를 사용하는 경우인 '위임 생성자'에 대해서 다룬다.

 

 

예제 코드 1

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

class Student
{
    int m_id;
    string m_name;

public:

    Student(const string& name_in)
		: Student(2, name_in) // 위임 생성자
    {
		
    }

    Student(const int& id_in, const string& name_in)
        : m_id(id_in), m_name(name_in)
    {
		
    }

    void print()
    {
        cout << m_id << "  " << m_name << "\n";
	}

};

int main()
{
    Student st1(0, "Jack Jack");
    st1.print();

    Student st2("Peter");
    st2.print();
}

 

 

Student class를 보면, 생성자가 2개 있는 것을 확인할 수 있다.

 

Student(const int& id_in, const string& name_in)으로 2개의 기본값을 받고, member initializer list를 이용해서 m_id와 m_name을 초기화해주는 방식이다.

 

그런데, 만약 id_in은 고정값으로 두면서 name_in만 인자로 받아서 객체를 만들고 싶다면?

 

이를 위해 생성자를 하나 더 만들어준다. 위에 있는 생성자는 Student(const string& name_in)으로 name만 받고, 그 대신 member initializer list에 아래에 있는 생성자를 사용해주면서 id_in은 2로 고정해준다.

 

이렇게 생성자가 다른 생성자를 쓰는 방식이 바로 위임 생성자이다.

 

 

 

예제 코드 2

#include <iostream>
#include <string>

// 생성자가 다른 생성자를 사용하는 것을 위임 생성자 라고 한다.
using namespace std;

class Student
{
    int m_id;
    string m_name;

public:

    Student(const string& name_in)
       // : m_id(0), m_name(name_in)
		//: Student(2, name_in) // 위임 생성자
    {
		init(2, name_in);
    }

    Student(const int& id_in, const string& name_in)
        //: m_id(id_in), m_name(name_in)
    {
		init(id_in, name_in);
    }

    // 초기화 함수를 별도로 만드는 방식도 가능!
    void init(const int& id_in, const string& name_in)
    {
        m_id = id_in;
        m_name = name_in;
	}


    void print()
    {
        cout << m_id << "  " << m_name << "\n";
	}

};

int main()
{
    Student st1(0, "Jack Jack");
    st1.print();

    Student st2("Peter");
    st2.print();
}

 

위임 생성자를 쓰는 방식도 있지만, 초기화 해주는 초기화 함수를 별도로 만드는 방식으로도 구현이 가능하다고 한다.

 

member initializer list를 사용하지 않고, 생성자를 2개 만들어주되, 생성자 안쪽에서 init 함수를 사용해주는 방식이다.

 

 

 

 

Chapter 8.6 소멸자 destructor

 

 

이번 챕터에서는 생성자와 반대의 역할을 하는 소멸자에 대해서 알아본다.

 

생성자는 객체가 생성될 때 호출되는 함수였는데, 소멸자는 변수가 영역을 벗어나서 사라질 때 호출이 되는 함수를 의미한다.

 

 

예제 코드 1

#include <iostream>
using namespace std;

class Simple
{
private:
    int m_id;

public:
    Simple(const int& id_in)
         : m_id(id_in)
    {
        cout << "Constructor " << m_id << endl;
	}

    ~Simple() // parameter 없음
    {
		cout << "Destructor " << m_id << endl;
    }

};

int main()
{
    Simple s1(0);
    Simple s2(1);
}

 

 

 

우리는 앞에서 생성자 (Constructor) 라는 것에 대해서 알아보았다.

 

생성자란, 객체가 생성될 때 실행되는 함수를 의미한다. class의 이름과 동일한 이름의 함수가 바로 생성자이다.

 

위 코드에서, 생성자는 length_in을 매개변수로 해서 멤버 변수 m_id를 결정하게끔 되어 있다.

 

main문을 보면, Simple class의 인스턴스 두 개를 만들었고, 각각 Constructor 0과 Constructor 1을 출력했다.

 

그 뒤에, Destructor 1과 Destructor 0을 출력하는 것을 볼 수 있다.

 

이처럼 소멸자는, 변수가 사라질 때 실행되는 함수이다.

 

생성자와는 약간 다르게, ~를 붙이고 클래스 이름을 그대로 적으면 된다.

 

매개변수는 따로 가지고 있지 않다.

 

 

강의에서 다룬 내용은 아닌데, 생성자는 0번 1번이 나왔는데 왜 소멸자는 1번 0번이 호출되는지 궁금해서 물어보니, 이는 스택이 LIFO(Last-in, First-out) 구조로 되어 있어서 라고 한다.

 

이전 강의에서, Stack과 heap에 대해서 언급된 적이 있었는데, main 함수에서 실행되는 것은 우선 stack 메모리에 잡힌다는 얘기를 했었다. 마찬가지로, main 함수에서 인스턴스를 생성할 때 stack에 저장되기 때문에, LIFO 원리를 따르는 stack의 특성상 뒤에 들어온 1번이 먼저 나가게 되어 소멸자는 1번이 먼저 호출되고 0번이 그 다음으로 호출되는 것이다.

 

 

 

예제 코드 2

#include <iostream>
using namespace std;


class Simple
{
private:
    int m_id;

public:
    Simple(const int& id_in)
         : m_id(id_in)
    {
        cout << "Constructor " << m_id << endl;
	}

    ~Simple() // parameter 없음
    {
		cout << "Destructor " << m_id << endl;
    }

};

int main()
{
    Simple *s1 = new Simple(0);
    Simple s2(1);

    delete s1;
}

 

 

 

이번에는 main문에서 약간 내용이 바뀌었다.

 

이전과 다르게, s1은 동적 할당 방식을 사용했다.

 

동적 할당은 반드시 delete로 메모리를 반납해줘야하니, s1을 delete를 사용해서 메모리를 반납했다.

 

그랬더니 s1에 대한 소멸자가 먼저 호출이 되었고, s2에 대한 소멸자는 main()에서 벗어난 이후 실행되기 때문에 그 다음에 호출된 것을 볼 수 있다.

 

 

 

예제 코드 3

#include <iostream>
using namespace std;

class IntArray
{
private:
    int* m_arr = nullptr; // vector는 자동으로 메모리를 반납함.
    int m_length = 0;

public:
    IntArray(const int& length_in)
    {
        m_length = length_in;
		m_arr = new int[m_length];

        cout << "Constructor " << endl;
    }

    int getLength()
    {
        return m_length;
    }

    ~IntArray() // parameter 없음
    {
        if (m_arr != nullptr)
        {
            delete[] m_arr; // 동적할당된 메모리 해제
        }
	}
};




int main()
{
    while (1)
    {
        IntArray my_int_arr(10000);
    }
    
}

 

 

앞에서 생성자, 소멸자가 어느 시점에 작동하는지는 확인했다.

 

그런데, 그럼 소멸자는 왜 필요한가?

 

위 코드를 보면, IntArray라고 하는 class 타입의 변수 my_int_arr를 만든 것을 알 수 있고, 생성자를 보면 동적 할당 방식으로 m_arr에 주소를 할당하는 것을 볼 수 있다.

 

동적할당을 해줬으면 무조건 delete를 해줘야하는데, 이 때 사용되는 것이 바로 소멸자이다.

 

소멸자는 범위를 벗어나서 변수가 사라질 때 호출되는 함수니까, 소멸자에서 동적할당된 m_arr를 delete 해주면 메모리 누수 없이 깔끔하게 지워지는 것이다.

 

이처럼 소멸자는 해당 변수가 사라질 때 작동해야 하는 작업이 수행되도록 도와주는 기능을 한다.

 

 

 

Chapter 8.7 this 포인터와 연쇄 호출

 

 

이번 챕터에서는 인스턴스의 주소를 알 수 있는 this pointer와, 이를 활용한 연쇄 호출에 대해서 알아본다.

 

 

 

예제 코드 1

#include <iostream>

using namespace std;
class Simple
{
    int m_id;

public:
    Simple(int id)
    {
        setID(id);
		// this->setID(id);

        cout << this << endl; // 자신의 주소를 클래스 안에서 볼 수 있다.
    }

    void setID(int id) {m_id = id;} // 모든 인스턴스가 공유해서 사용하는데.... 내부적으로 this의 setid를 사용하자. 이런식으로 됨.
	int getID() { return m_id; }
};

int main()
{
    Simple s1(1);
	Simple s2(2);

    s1.setID(2);
    s2.setID(4);

	cout << &s1 << " " << &s2 << "\n";

    // setID나 getID를 s1 만들 때 만들고 s2 만들 때 메모리에 만들고 이렇게 사용하지는 않는다.
    // 얘네는 어딘가에 저장되어 있고.
    // Simple::setID(&s1, 1); 이런 느낌으로 작동을 한다. == s2.setID(4);
    // 즉 setID가 호출이 될 때 s1의 주소를 넣어주고 setID에 필요한 인자를 넣어주는 식임. 보이지 않는 포인터가 내부적으로 들어가있다.
}

 

Simple이라는 class 안에는 setID와 getID라는 함수가 있다.

 

만약 Simple class type으로 인스턴스를 많이 만든다고 하면, s1, s2, s3.... s20... 이런식으로...

 

그때 함수들은 각각의 인스턴스 메모리에 각각 만들어지지 않는다고 한다.

 

함수들은 어딘가에 저장되어 있다가, setID가 호출될 때 인스턴스의 주소를 같이 전달해줘서 어떤 인스턴스에서 setID를 호출했는지를 알 수 있도록 해준다.

 

마치 setID(&s1, 1); 처럼 작동한다는 것이다.

 

그렇다면, class 내에서 인스턴스의 주소를 알 수 있어야만 setID를 호출할 때 어떤 인스턴스가 호출했는지를 알 수 있다.

 

인스턴스의 주소를 알기 위해서는 'this' pointer를 사용하면 된다.

 

 

 

위 코드에서, 생성자에 setID(id)가 있는 것을 알 수 있는데, 사실 이것은 this->setID(id)와 동일한 코드라고 한다. 즉, this 포인터를 사용해서 setID를 호출하는 방식이다. 

 

 

 

 

예제 코드 2

#include <iostream>
using namespace std;

class Calc
{
    int m_value;

public:
    Calc(int init_value)
		: m_value(init_value)
    {
        
    }

    /*
    void add(int value)
    {
        m_value += value;
	}

    void sub(int value)
    {
        m_value -= value;
    }

    void mult(int value)
    {
        m_value *= value;
	}
    */

    Calc& add(int value) // Calc&를 반환하는 이유는 연쇄 호출을 위해서
    {
        m_value += value;
        return *this; // this는 포인터이므로 역참조를 해서 객체 자체를 반환
    }

    Calc& sub(int value)
    {
        m_value -= value;
        return *this;
	}

    Calc& mult(int value)
    {
        m_value *= value;
        return *this;
    }

    void print()
    {
        cout << m_value << "\n";
	}
};




int main()
{

    /*
    Calc cal(10);
    cal.add(10);
    cal.sub(1);
    cal.mult(2);
	cal.print(); // (10 + 10 - 1) * 2 = 38
    */

    Calc cal(10);
	cal.add(10).sub(1).mult(2).print(); // 연쇄 호출

	/*
    Calc cal(10);
	Calc& temp1 = cal.add(10); // cal.add(10)의 반환값을 temp1이 받음.
	Calc& temp2 = temp1.sub(1);
	Calc& temp3 = temp2.mult(2);
	temp3.print();
    */

}

 

this pointer를 활용하면, 연쇄 호출을 사용할 수 있다.

 

위 코드를 보면, add, sub, mult 함수들이 모두 Calc&를 return 하는 것을 알 수 있다.

 

Calc&는 Calc라는 클래스형의 참조로, 즉 Calc 객체를 return 한다는 의미이다.

 

Calc 객체를 return하려면, 인스턴스의 주소를 역참조해야 한다. 그래서 코드상에서 return *this;라고 작성이 되어 있는 것이다.

 

 

 

main문을 살펴보면, cal(10);으로 인스턴스를 만든 뒤, cal.add(10)를 해주고 거기에 연이어 sub(1)과 mult(2)를 해준것을 볼 수 있다.

 

이는 cal(10)를 했을 때의 결과가 다시 Calc 객체이기 때문에, 이 객체에 다시 sub를 해주고, mult를 해주고 연쇄적으로 작동하는 것이다.

 

이러한 과정을 조금 더 풀어쓰면 아래에 주석처리한 내용과 같다.

 

우선 Calc cal(10);로 인스턴스를 만들고, cal.add(10);를 한 반환값을 temp1에 저장한다. 그리고 temp1에서 sub(1)을 한 결과를 temp2에 두고, temp2에서 mult(2)를 한 결과를 temp3에 저장하는 방식이다.

 

이러한 방식은 결국 this pointer를 활용해서 class 내에서 인스턴스의 주소를 알 수 있기 때문이다.

 

 

 

Chapter 8.8 클래스 코드와 헤더 파일

 

 

이번 챕터에서는 이전 챕터에서 작성한 코드를 헤더 파일과 cpp 파일을 이용해서 어떻게 깔끔하게 정리하는지에 대해서 다룬다.

 

 

 

이전 코드를 살펴보면, 가장 메인이 되는 cpp 파일에 class의 멤버 변수와 멤버 함수들의 바디가 쭉 나열되어 있어서 코드가 굉장히 긴 것을 볼 수 있었다.

 

 

이렇게 하면 메인 코드가 엄청 길어진다는 단점이 있다.

 

따라서 1차적으로, Class에 대한 내용을 헤더파일 쪽으로 옮겨준다.

 

헤더파일에는 보통 클래스의 선언부만 남겨두고, 함수 바디들은 cpp에 몰려두는 경우가 많아 이 부분도 적용하려고 한다.

 

 

 

 

 

이런식으로, main()문만 남겨두고 Calc.h 라는 헤더파일을 include 하는 방식으로 하면 굳이 Calc 라는 class에 대한 내용은 메인 코드에 따로 정리해둘 필요가 없게 된다.

 

 

 

 

헤더파일에는, class의 정의를 적어둔다. 멤버 변수와 멤버 함수들이 포함되어 있는 것을 알 수 있다.

 

이전 코드의 원래 상태처럼, Calc& add의 실제 내용을 헤더 파일에 정리해두는 방법이 있고, 혹은 헤더 파일에는 함수 이름하고 매개변수만 남겨두고 함수 바디쪽은 cpp로 빼는 방법이 있는데 우선 현재는 바디쪽을 cpp로 빼는 방향으로 설명해주셨다. 헤더에 내용을 그대로 둬도 무방하다.

 

 

 

 

별도의 cpp 파일을 만들어주고, 여기에 Calc 클래스에 있던 생성자하고 멤버 함수들을 다 옮겨주었다.

 

이때, Calc:: 를 이용해서 어떤 클래스에 포함되어 있던 함수인지를 적어주어야 한다.

 

실제로 오픈소스 코드를 보면 막상 main() 쪽에는 별다른 내용이 없고, 위 케이스 처럼 헤더랑 cpp에 코드가 분산되어 있는 경우가 많다고 한다.

 

 

 

 

Chapter 8.9 클래스와 const

 

 

이번 챕터에서는 class에 const를 사용했을 때 벌어지는 일들에 대해서 알아본다.

 

 

 

예제 코드 1

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

class Something
{
public:
    int m_value = 0;

    void setValue(int value) { m_value = value; }
    
    int getValue() 
    // int getValue() const 로 바꿔주자!
    { 
        return m_value; 
    }
};

int main()
{
    // Something의 object를 const로 만든다는 것은, 멤버 변수를 const로 바꾼 것과 같은 효과가 있다.
    // 즉 이럴 땐 setValue 함수를 사용할 수 없다.
    const Something some; // object라고 부르기도 하고, 인스턴스, 변수라고 부르기도 함.
    //some.setValue(3);

    // 컴파일러가 판단할 때는 m_value가 바뀌었냐 안 바뀌었냐를 보는게 아니라, 멤버 함수가 const이냐 아니냐로 판단을 함.
    cout << some.getValue() << endl; 
}

 

 

main()문을 살펴보자.

 

이전에는 Something some;처럼 변수를 그냥 만들었지만, 이번에는 const 키워드를 붙인 상황이다.

 

인스턴스에 const를 붙이는 경우, 해당 인스턴스의 멤버 변수를 const로 바꾼 것과 같은 효과가 있다.

 

따라서, 당연히 some.setValue(3);는 사용이 불가능하다. (멤버 변수의 값을 바꾸는 기능이므로)

 

그런데 이상하게도 멤버 변수의 값을 return 해주는 역할을 하는 some.getValue() 조차 빌드가 되지 않는 것을 알 수 있다.

 

이는 컴파일러 입장에서 getValue() 함수가 const 객체에 사용될 수 있는 안전한 함수인지 알 수 없기 때문이라고 한다.

 

따라서 int getValue() 를 int getValue() const 로 바꾸어주면, 컴파일러에게 이 함수는 멤버 변수를 변경하지 않는다 라는 약속을 해주는 것과 같다고 한다.

 

 

 

예제 코드 2

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

class Something
{
public:
    int m_value = 0;

    // 이런 역할을 해주는게 기본적으로 숨어져 있음.
	// copy constructor
    Something(const Something& st_in)
    {
        m_value = st_in.m_value;

        cout << "Copy Constructor" << endl;
    }

    Something()
    {

        cout << "Constructor" << endl;

    }

    void setValue(int value) { m_value = value; }
    
    // const를 쓸 수 있는 함수에는 다 const로 막아두는 것이 디버깅에 유리하다.
    int getValue() const // : m_value를 바꾸지 않겠다는 약속.
    { 
        return m_value; 
    }
};

void print(Something st)
{
    cout << &st << endl;
    cout << st.m_value << endl;
}

// 가급적 const를 열심히 쓰자.
// Reference로 받으면 복사도 따로 하지 않고 효율적으로 간다.
void pprint(const Something& st)
{
    cout << &st << endl;
    cout << st.m_value << endl;
}

int main()
{
    
    Something some2;

    cout << &some2 << endl;

    print(some2);
    pprint(some2);
    
}

 

 

 

이번 예제 코드에서는 copy constructor에 대해서 알아본다.

 

이전 강의들에서 계속 다루어온 내용이지만, 함수의 매개변수에 &(참조, reference) 기호가 없으면 값을 복사해온다는 얘기를 했었다.

 

물론 여러가지 방법으로 이를 확인했지만, class에서 이를 확인할 수 있는 방법이 바로 copy constructor 이다.

 

 

main()문을 살펴보면,

 

우선 Something type의 some2라는 인스턴스를 만들어준다. 이 때, consturctor가 실행되므로 출력창에 Constructor가 출력된다.

 

다음줄에 &some2를 이용해서 some2의 주소를 찍어본다. 이는 출력창에서 00AFFCD0라는 메모리 주소가 확인된다.

 

다음으로 print(some2);를 실행한다. print는 매개변수를 Something으로 받고 이에 대한 주소와 m_value를 출력해준다.

 

그런데 출력창을 보면 Copy Constructor라는 내용이 찍혀있는 것을 볼 수 있다.

 

위 코드에 보면 생성자(constructor)인데 const Something&을 매개변수로 받는 생성자가 보인다. 이것이 바로 copy constructor이다. 안에 내용을 보면 m_value 값을 그대로 카피해서 가져오는 것을 알 수 있다.

 

copy constructor는 별도로 작성하지 않더라도 기본적으로 숨어져 있다고 한다.

 

다음 내용은 st의 주소인데, 기존에 00AFFCD0과는 다른 00AFFCCC라는 주소인 것을 확인할 수 있다. 이처럼, 매개변수로 참조를 받지 않는 경우 별도의 메모리 공간에 복사를 하는 것을 다시 한번 확인할 수 있다.

 

다음으로는 pprint(some2);인데, pprint는 const Something&을 매개변수로 받는다.

 

즉, some2 객체를 그대로 가져올 수 있다. 따라서 메모리 주소가 같은 것을 확인할 수 있다.

 

 

 

예제 코드 3

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

class Something2
{
public:
    string s_value = "default";

    // 함수 오버로딩
	// const 객체에 대해서는 const 버전이 호출되고,
	// non-const 객체에 대해서는 non-const 버전이 호출된다.
    
    // 기존에는 매개변수가 같냐 아니냐만 가지고 오버로딩을 했는데,
    // const를 가지고 오버로딩이 되고 안되고가 가능하다.
    const string& getValue() const
    {
        cout << "const version" << endl;
        return s_value;
    }

    string& getValue()
    {
        cout << "non-const version" << endl;
        return s_value;
    }
};

int main()
{
    
    Something2 st;
    st.getValue(); // = 10; 와 같은 방식으로 바꿀 수 있음.

    const Something2 st2;
    st2.getValue(); // = 1004; 이런 방식으로 바꾸기 불가.

}

 

 

main()을 살펴보자.

 

그냥 Something2 인스턴스인 st와, const Something2 인스턴스인 st2를 만들어서 각각이 getValue() 멤버 함수를 호출한 상황이다.

 

Something2 class에 코드를 보면, getValue()인 멤버 함수가 두개인데, 하나는 const string&을 return하고, 하나는 string&을 return한다.

 

원래 함수 오버로딩은 매개변수가 같은지 아닌지를 보고 이루어지지만, class에서 함수 오버로딩은 const를 가지고 오버로딩을 할 수 있다.

 

따라서 non-const 인스턴스인 st는 getValue()를 호출했을 때 string&을 return하는 getValue()가 호출되고,

 

const 인스턴스인 st2는 getValue()를 호출했을 때 const string&을 return하는 getValue()를 호출하게 된다.

 

위 출력창을 보고, 이를 확인할 수 있다.

 

 

 

Chapter 8.10 정적 멤버 변수

 

 

이번 챕터에서는 class의 멤버 변수를 static(정적) 으로 선언할 때에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>

using namespace std;

int generateID()
{
    static int s_id = 0;
    return ++s_id;
}
 
int main()
{
	cout << generateID() << endl;
	cout << generateID() << endl;
	cout << generateID() << endl;
}

 

 

main문을 살펴보면, generateID라는 함수를 3번 호출한 내용임을 알 수 있다.

 

generateID 함수에는 정적 변수인 static int s_id가 선언되어 있으며, return할 때 이 값을 1씩 올려준다.

 

이렇게 작동할 수 있는 이유는, static 키워드를 붙였을 때 변수가 메모리의 고정된 위치에 한 번만 할당되고 프로그램이 끝날 때까지 유지가 되기 때문이다.

 

이러한 static의 성질을 다시 한번 기억하고, class의 멤버 변수에 static 키워드를 사용했을 때 어떻게 되는지 살펴보자.

 

 

 

예제 코드 2

#include <iostream>
using namespace std;

class Something
{
public:
    //static int m_value = 1; // static 변수는 초기화를 이렇게 할 수 없음.
    static int s_value;

    // C++에서 정적 멤버 변수는 선언과 정의로 처리된다.
    // 위에 내용은 선언임.
    // int Something::s_value = 1;와 같이 실제 변수에 대한 메모리를 할당하고 초기화하는 정의 부분이 필요. 반드시 cpp 파일에 한 번만 존재해야 함.
    
    // static const int s_value = 1; // static const는 선언과 동시에 초기화 가능.
	// static constexpr int s_value = 1;
};

int Something::s_value = 1; // 헤더에 두면 컴파일 에러 남. 

int main()
{
    
	cout << &Something::s_value << " " << Something::s_value << endl;


	Something st1;
	Something st2;

	st1.s_value = 2;

	cout << &st1.s_value << " " << st1.s_value << endl;
	cout << &st2.s_value << " " << st2.s_value << endl;

	Something::s_value = 1024;

	cout << &Something::s_value << " " << Something::s_value << endl;
}

 

 

Something class를 살펴보자.

 

멤버 변수인 static int s_value를 선언했고, class 밖에서 int Something::s_value = 1;로 해서 값을 정의해줬다.

 

이처럼, C++에서는 class의 멤버 변수가 정적 멤버 변수로 선언되는 경우 정의와 선언을 반드시 분리해야 한다.

 

즉, class 내에서 static 멤버 변수에 대해 바로 값을 초기화할 수 없다.

 

 

예제 코드 1번에서 이야기 했듯이, static 키워드를 사용하면 프로그램이 시작될 때 단 한 번 메모리가 할당되고, 프로그램이 끝날 때까지 유지된다고 했었다. 

 

위 예제 코드에서 보면, int Something::s_value = 1; 로 값을 선언해주었는데, 이렇게 선언해줘버리면 해당 변수의 메모리 할당 및 초기화를 전역 영역에서 수행하겠다 라는 의미가 된다. 따라서 컴파일러는 이 줄을 만나면 프로그램이 싫랭되기 전에 s_value를 위한 메모리 공간을 마련하고 1로 초기화를 해주게 된다.

 

따라서, Something st1나 Something st2가 선언되지도 않은 시점에, 이미 Something::s_value는 주소와 값을 모두 갖고 있는 것이다.

 

다음 줄에 st1.s_value를 2로 바꾸어주었는데, st1.s_value와 st2.s_value가 모두 2로 바뀐 것을 확인할 수 있다.

 

이를 통해 알 수 있는 것은, 정적 멤버 변수의 경우 모든 인스턴스가 공유하는 변수이고, 특정 인스턴스에 종속되지 않는다는 점이다. 그래서 Something::s_value처럼 클래스 이름으로 직접 접근할 수도 있고, 혹은 인스턴스(s1, s2)의 멤버 변수로 접근할 수도 있다.

 

 

 

추가로, static const를 사용하는 경우에는 선언과 동시에 초기화가 가능하며, static constexpr을 사용하는 경우에도 선언과 초기화가 동시에 가능하다.

 

 

 

 

Chapter 8.11 정적 멤버 함수

 

 

이번 챕터에서는 이전에 다루었던 정적 멤버 변수에 이어서, 정적 멤버 함수에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>
using namespace std;

class Something
{

private:
    static int s_value;
    int m_value;
public:

    Something()
        //: s_value(1024) // static 생성자는 지원을 하지 않음. 
    {
    }

    static int getValue()
    {
        return s_value;
        //return m_value; // static 함수 안에서 m_value 쓸 수 없음.
        //return this->s_value; // static은 this 포인터를 못 쓴다.
    }

    int temp()
    {
        return this->s_value; // 특정 인스턴스의 주소를 받아서 그 인스턴스에 속해 있는 멤버들의 주소를 가져다가 사용한다.
    }
};

int Something::s_value = 1024;

int main()
{

    // 현재 멤버 변수가 private이라서 직접 접근을 할 수 없다.
    // 하지만 getValue()를 static으로 바꿔주면, getValue() 함수를 통해서 s_value의 값을 얻을 수 있다.
    //cout << Something::s_value << endl;
    cout << Something::getValue() << endl;

    Something s1;
    cout << s1.getValue() << endl;
    //cout << s1.s_value << endl;    
}

 

 

 

main문을 살펴보자.

 

Something에 있는 static int 변수인 s_value를 출력하고 싶은데, private 이기 때문에 직접 접근을 할 수 없는 상황이다.

 

따라서 s_value의 값을 return해주는 getValue() 함수를 이용하려고 한다.

 

근데 신기하게도, Something class type의 인스턴스를 만들기 전에 이미 Something::getValue()를 사용해서 s_value의 값을 1024로 구할 수 있다.

 

이전 강의에서도 언급된 내용이지만, static 멤버 변수는 특정 인스턴스에 종속되는 것이 아니라 global 범위에서 선언되고 관리가 된다. 

 

static 멤버 함수도 마찬가지로, 특정 인스턴스에 종속되지 않고 클래스 자체에 속하며 클래스의 모든 인스턴스가 공유하는 단일 메모리 공간에 저장된다. 그래서 Something::getValue()로 사용이 가능한 것이다.

 

static 멤버 변수인 Something::s_value를 1024로 초기화해 두었으니, 결과가 1024로 출력된다.

 

 

 

이전과 동일하게, Something type의 인스턴스 s1를 만든 뒤, s1.getValue()로 호출해서 사용할 수 있다.

 

getValue()는 static 멤버 함수이니, 앞에서 호출하는 방식처럼 Something 클래스의 getValue()를 호출하나, s1.getValue()를 호출하나 같다. 겉보기에는 s1라는 인스턴스를 통해서 호출되는 것처럼 보이지만, 전혀 아니다.

 

 

추가로 살펴볼 내용은, static 멤버 변수에 대해서는 생성자에서 값을 초기화해줄 수 없다는 것이다.

 

그래서 Something() : s_value(1024)와 같은 코드는 컴파일이 되지 않는다.

 

 

static int getValue()에 대해서 조금 더 살펴보자.

 

return s_value;를 하면 잘 작동하는데, static 멤버 변수가 아닌 m_value는 사용할 수 없다. m_value는 non-static 변수이기 때문에 return type이 안 맞는 것이라고 생각할 수 있다.

 

그리고 이전 강의에서 언급되었듯이, static 멤버 함수가 아닌 일반적인 멤버 함수의 경우에는 return this->변수 형태로 해서 인스턴스의 메모리 주소인 this pointer를 활용하지만 이것이 명시적으로 표시되지는 않는다고 했었다.

 

static 멤버 함수에서 static 멤버 변수를 return할 때는, 특정 인스턴스에 종속되는 경우가 아니기 때문에 this->s_value;를 사용할 수 없다. s_value라고 하는 변수 자체가 static으로 인스턴스에 종속되는 것이 아니기 때문.

 

 

 

예제 코드 2

#include <iostream>
using namespace std;

class Something
{


public:
    class _init
    {
    public:
        _init()
        {
            // 내부 클래스이기 때문에 static 멤버 접근 가능.
            s_value = 1234;
        }
    };
    

private:
    static int s_value;
    int m_value;

    static _init s_initializer;

public:

    Something()
        //: s_value(1024) // static 생성자는 지원을 하지 않음. 
    {
    }

    static int getValue()
    {
        return s_value;
        //return m_value; // static 함수 안에서 m_value 쓸 수 없음.
        //return this->s_value; // static은 this 포인터를 못 쓴다.
        
    }

    int temp()
    {
        return this->s_value; // 특정 인스턴스의 주소를 받아서 그 인스턴스에 속해 있는 멤버들의 주소를 가져다가 사용한다.
    }
};

int Something::s_value = 1024;
Something::_init Something::s_initializer; // Something 클래스 안에 inner 클래스로 _init 클래스인 자료형으로 Something 안의 s_initializer 멤버 변수를 호출. 이러면 생성자 호출됨.

// Something 클래스 안에서 static 멤버 변수의 값을 초기화할 수 있다 라는 것처럼 구현할 수 있다.

int main()
{
    cout << Something::getValue() << endl;

    Something s1;
    cout << s1.getValue() << endl;
}

 

 

 

 

이번에는 Something 클래스 안에서 static 멤버 변수의 값을 초기화할 수 있는 것 처럼 구현하는 방법을 알려주는 코드이다.

 

이는 클래스 안의 클래스(inner class)를 정의하는 방식으로 가능하다.

 

 

코드를 보면, Something이라는 class 안에 _init이라는 class를 내부적으로 만들었고, 이 클래스는 생성자에서 s_value를 1234라는 값으로 바꿔준다. 이는 Something 이라는 class 내부에 위치하기 때문에, s_value가 private 변수여도 접근이 가능하다.

 

즉, 지금 상황에서 _init type의 인스턴스를 만든다면 s_value가 1234 값을 갖게 되는 것이다.

 

그것을 Something class의 멤버 변수로 만들어주었다. static _init s_initializer 라는 변수가 선언된 것을 볼 수 있다.

 

그렇다면, 결국 Something class 내에 있는 s_initializer라는 객체만 생성이 되면, 생성자가 호출되면서 s_value를 1234로 바꿀 수 있는 것이다.

 

그래서 Something이라는 class 안에 있는 class인 _init class type이니까 Something::_init 으로 타입을 정해주고, Something class 내에 있는 s_initializer라는 정적 멤버 변수를 호출한 케이스이다. 이렇게 해주면 s_value가 1234의 값을 가지게 된다.

 

 

Chapter 8.12 친구 함수와 클래스 friend

 

 

이번 챕터에서는 friend 이라는 키워드를 활용해서, 클래스 간 상호작용을 하는 방법을 살펴본다.

 

 

 

예제 코드 1

#include <iostream>
using namespace std;

class A
{
private:
    int m_value = 1;

    friend void doSomething(A& a); // friend function 선언. 특정 class의 private 멤버에 접근 가능하게 해줌.
};

void doSomething(A& a)
{
    cout << a.m_value << endl;
}

int main()
{
    A a;
    doSomething(a);
}

 

 

이번 예제 코드에서는 friend 키워드를 통해서, 특정 클래스 내에 있는 private 멤버 변수를 사용할 수 있도록 만들어본다.

 

doSomething이라는 함수를 보면, A라는 class 타입의 인스턴스 a를 받아서, a의 m_value를 출력하도록 하고 있다.

 

그런데 doSomething 함수는 A class 내부에 선언된 함수가 아니기 때문에, 밖에서는 클래스 내 private 변수에 접근할 수 없는 것이 당연하다.

 

이럴 때 friend를 붙인 다음, 함수의 return type과 함수 이름, 매개변수만 복사해서 붙여주면, doSomething 함수는 A class의 친구로 인정되면서 a의 m_value를 접근할 수 있게 된다.

 

 

예제 코드 2

#include <iostream>
using namespace std;

class B; // forward declaration, 현재 상황에서는 class A 입장에서 B를 모르기 때문에 문제가 발생함.

class A
{
private:
    int m_value = 1;

    friend void doSomething(A& a, B& b); // friend function 선언. 특정 class의 private 멤버에 접근 가능하게 해줌.
};

class B
{
private:
    int m_value = 2;

    friend void doSomething(A& a, B& b);
};

void doSomething(A& a, B& b)
{
    cout << a.m_value << " " << b.m_value << endl;
}

int main()
{
    A a;
    B b;
    doSomething(a, b);
}

 

 

이번에는 doSomething이 두 개의 클래스를 인자로 받는 경우를 살펴본다.

 

doSomething은 A라는 class와 B라는 class의 인스턴스를 각각 받아서, 각각의 m_value를 출력하는 기능을 수행한다.

 

예제 코드 1과 유사하게, class A에도 class B에도 동일하게 friend로 선언해주면 된다.

 

조금 다른 점은, class A가 먼저 코드상으로 작성되어 있기 때문에, class A의 범위 내에서 friend void doSomething을 하는 시점에는 class B에 대해서는 알 방법이 없다. class B는 class A의 뒤에 정의되기 때문이다.

 

따라서 이러한 경우에 대응하기 위해서, 전방 선언(forward declaration)을 사용해줄 수 있다. class B가 있다 라는 것을 먼저 선언해주면, 해당 문제가 해결된다.

 

 

 

예제 코드 3

#include <iostream>
using namespace std;

class B;

class A
{
private:
	int m_value = 1;
	friend class B; // class B 전체를 friend로 지정. B 클래스 안에 있는 모든 멤버 함수들이 A의 private 멤버에 접근 가능.
};

class B
{
private:
    int m_value = 2;

public:
    
    void doSomething(A& a)
    {
        cout << a.m_value << endl;
    }
    
};

int main()
{
    A a;
    B b;
    b.doSomething(a);
}

 

 

이번 예제는, class B의 멤버 함수에서 만약 class A의 인스턴스를 받아 m_value를 출력하려고 하는 경우이다.

 

이럴 때, class A에서 class B를 통째로 friend로 선언해주면 class B 안에 있는 모든 멤버 함수들이 A의 private 멤버에 접근이 가능해진다.

 

 

예제 코드 4

#include <iostream>
using namespace std;

class A;

class B
{
private:
    int m_value = 2;
public:
	// friend member function
    void doSomething(A& a); // 선언만 해주고

};

class A
{
private:
    int m_value = 1;
    friend void B::doSomething(A& a);
};


void B::doSomething(A& a) // 정의는 따로 해준다.
{
    cout << a.m_value << endl;
}

int main()
{
    A a;
    B b;
    b.doSomething(a);
    
}

 

예제 코드 3번에서는, class A에서 class B를 friend로 지정해서 서로간의 private 변수에 접근할 수 있도록 했는데,

 

만약 class를 통째로 friend로 지정하는 것이 조금 꺼림칙한 경우에는 어떻게 하는지에 대해서 다뤄본다.

 

 

 

결국 지금 문제는 class B에서 멤버 함수를 통해 class A의 인스턴스를 인자로 받아서 private 멤버 변수의 값을 출력하고 싶은 것이다.

 

그래서 우선 class A를 전방선언 해주어서 class A라는 것이 있다 라는 것을 알려준다.

 

그리고 class B에서 doSomething의 선언만 남기고, 정의는 class A 이후로 옮겨준다.

 

class A에서는 class B에 정의된 doSomething를 friend로 지정해준다.

 

이렇게 하면 정상적으로 작동하는 것을 확인할 수 있다.

 

 

 

Chapter 8.13 익명 객체

 

 

이번 챕터에서는 기존에 사용하던 객체와는 다르게, 일회용으로 사용할 수 있는 익명 객체에 대해서 다룬다.

 

 

예제 코드 1

#include <iostream>
using namespace std;

class A
{
public:
    
    int m_value;

    A(const int& input)
        : m_value(input)
    {
        cout << "Constructor" << endl;
    }

    void print()
    {
        cout << m_value << endl;
    }

    ~A()
    {
        cout << "Destructor" << endl;
    }
};

int main()
{
    A a(1);
    a.print();

    A(1).print(); // 임시 객체
    A(1).print(); // 앞의 객체와는 다른 객체
}

 

 

 

기존에는 A a(1);를 하고 a.print();를 해서 클래스 내 멤버 함수를 사용했었다.

 

하지만 익명 객체는 이렇게 인스턴스를 만들어서 사용하는 것이 아니라, 일회용으로 사용할 수 있는 기능이다.

 

A(1).print();가 바로 그 예시이다. A(1)는 R value처럼 작동하기 때문에, 변수명이 별도로 없고 오직 한번만 사용을 하고 바로 사라져버리는 특징을 가지고 있다.

 

 

그리고 출력 결과를 보면, class A인 인스턴스를 만들 때 Constructor와 1이 출력되고,

 

그 이후에 A(1).print(); 코드가 작동하면서 Constructor, 1, Destructor가 출력된다.

 

마찬가지로 A(1).print();가 작동하면서 Constructor, 1, Destructor가 출력되고, A a(1)이 마지막에 소멸된다.

 

 

이처럼, 익명 객체는 바로 바로 소멸되는 것을 알 수 있다.

 

 

 

예제 코드 2

#include <iostream>
using namespace std;

class Cents
{
private:
    int m_cents;


public:
    Cents(int cents) {
        m_cents = cents;
    }

    int getCents() const { return m_cents; }
};

Cents add(const Cents& c1, const Cents& c2)
{
    return Cents(c1.getCents() + c2.getCents());
}

int main()
{
    cout << add(Cents(6), Cents(8)).getCents() << endl;
    cout << int(6) + int(8) << endl;
}

 

익명 객체를 다르게 활용하는 예시이다.

 

add라는 함수를 보면, Cents 자료형의 인스턴스를 2개 입력 받고, c1의 getCents()를 한 int 결과와 c2의 getCents()를 한 int 결과를 더해서 Cents라는 인스턴스를 만든 뒤 return하는 함수이다.

 

그래서 main문을 보면,

 

Cents(6)으로 임시 객체를 만들었고, Cents(8)으로 임시 객체를 만든 다음, 이 두개를 add 함수의 인자로 전달했다.

 

그래서 add 함수 내부적으로는 각각의 getCents()를 적용한 다음 둘을 더하면 14가 되고, return Cents(14);가 되는 것이다.

 

그렇다면 add(Cents(6), Cents(8)) 의 결과는 Cents(14)가 되는 것이며, 여기에다가 getCents()를 하면 14라는 값을 얻을 수 있다.

 

int(6) + int(8)도 위 코드와 동일하게 14라는 결과를 얻게 되는데, 유사성을 한번 생각해보라고 하셨다.

 

 

Chapter 8.14 클래스 안에 포함된 자료형 (nested types)

 

이번 챕터에서는 특정 자료형을 특정 클래스 안에서만 사용하는 경우, 클래스 안에 자료형을 정의하는 경우에 대해서 알아본다.

 

 

 

예제 코드 1

#include <iostream>

class Fruit
{

public:
    enum FruitType
    {
        APPLE,
        BANANA,
        CHERRY
    };

    class InnerClass
    {

    };

    struct InnerStruct
    {

    };

private:
    FruitType m_type;

public:
    Fruit(FruitType type)
        : m_type(type)
    {

    }

    FruitType getType() { return m_type; }
};


int main()
{
    Fruit apple(Fruit::APPLE);

    if (apple.getType() == Fruit::APPLE)
    {
        std::cout << "Apple! " << std::endl;
    }
}

 

enum type의 FruitType은 Fruit라는 class 안에서만 사용되는 상황이다.

 

class 내에서 enum type을 정의할 수 있다.

 

class 내에서 정의했기 때문에, Fruit apple(Fruit::APPLE); 처럼 APPLE을 그냥 쓰진 못하고, Fruit라는 class 내에 있다는 것만 명시해주면 바로 사용할 수 있다.

 

 

Chapter 8.15 실행 시간 측정하기

 

 

이번 챕터에서는 코드가 실행되는 시간을 측정하는 방법에 대해서 다룬다.

 

 

예제 코드 1

#include <iostream>
#include <vector>
#include <algorithm>
#include <random>
#include <chrono>

using namespace std;

class Timer
{
    using clock_t = std::chrono::high_resolution_clock;
	using second_t = std::chrono::duration<double, std::ratio<1>>;

    std::chrono::time_point<clock_t> start_time = clock_t::now(); // 시작 시간, 현재 시간.

public:
    void elapsed()
    {
        std::chrono::time_point<clock_t> end_time = clock_t::now();

        cout << std::chrono::duration_cast<second_t>(end_time - start_time).count() << endl; // second로 casting.
    }
};

int main()
{
    random_device rnd_device;
    mt19937 mersenne_engine{ rnd_device() };

    vector<int> vec(100000);
    for (unsigned int i = 0; i < vec.size(); ++i)
    {
        vec[i] = i;
    }

    std::shuffle(begin(vec), end(vec), mersenne_engine);

    /*
    for (auto v : vec) cout << v << " ";
    cout << endl;
    */

    Timer timer;

    std::sort(begin(vec), end(vec));

    timer.elapsed();

    /*
    for (auto& e : vec) cout << e << " ";
    cout << endl;
    */
}

 

Timer라는 class를 선언하고,

 

Timer가 생성될 때 곧바로 현재 시간을 찍어준다. 이것이 start_time이고,

 

elapsed()를 호출했을 땐 현재 시간을 end_time으로 구한 뒤, end_time - start_time을 해서 걸린 시간을 측정한다.

 

이를 second_t 로 duration_cast 해서 초 단위로 표현되게 해준다.

 

 

main문은,

 

10만개짜리 벡터를 만든 뒤, shuffle을 해서 섞어주고, 이를 다시 std::sort해줬을 때 얼마나 시간이 걸린지를 측정하는 코드이다.

 

시간 측정의 경우 대부분 요구하는 기능이 비슷하기 때문에, 거의 그대로 복붙해서 사용하면 된다고 한다.

 

 

그리고 컴퓨터에서는 다양한 프로세스들이 실행되고 있기 때문에, 측정할 때마다 실행 시간이 달라질 수 있다.

 

따라서 최소한 3번은 측정해야 한다고 하셨고,

 

실행 시간을 최적화하는 것은 매우 어려운 일이라고 하셨다.

 

 

 

 

 

여기까지 chapter 8을 정리해보았다.

 

이번 챕터에서는 특히 class를 중점적으로 다루었는데, 멤버 변수와 멤버 함수, 생성자, member initializer list, 소멸자 등 여러가지 재미있는 기능들을 다룰 수 있었다.

 

 

이번 글에서는 Chapter 7을 정리해봅니다.

 

 

 

 

 

 

 

 

Chapter 7.1 매개변수와 실인자의 구분

 

 

이번 챕터에서는 매개변수(Parameter)와 인자(Argument)를 구분해봅니다.

 

 

예제 코드 1

#include <iostream>

using namespace std;

// 정수형 변수 x가 함수의 파라미터다.
// 매개변수(Parameter): 함수가 어떤 기능을 하는지 조절해주는 기능.
// '함수가 받아들일 값을 위한 변수들의 목록'
int foo(int x, int y)
{
	return x + y;
} // x와 y는 os로 메모리가 반납. 지역변수임. 

int main()
{
	int x = 1, y = 2;

	// 6, 7은 argument라고 부름. 인자. 실매개변수, 실인자(actual parameters)
	// 인자(Argument): 함수를 호출할 때, 매개변수에 실제로 전달되는 값
	foo(6, 7);

	// x에 들어있는 1이라는 값이 argument가 되는거고, 매개변수 x로 전달이 되게 됨.
	foo(x, y + 1);
}

 

매개변수(Parameter)는 함수가 받아들일 값을 위한 변수들의 목록이다.

 

예를 들어서 foo 라는 함수는 int x, int y라는 매개변수가 준비되어 있고, 함수는 2개의 int type의 값을 받으려고 한다.

 

인자(Argument)는 함수를 호출할 때, 매개변수에 실제로 전달되는 값이다.

 

foo(6, 7); 에서 6과 7이 foo의 매개변수인 int x와 int y에 대응될 것이다. 

 

 

 

Chapter 7.2 값에 의한 전달

 

 

이번 챕터에서는 값에 의한 전달(Call by value)에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>

using namespace std;

void doSomething(int y)
{
	cout << "In func " << y << " " << &y << endl;
}

int main()
{
	// 매개변수로 y라는 값이 전달이 되어야 하는 상황.
	// 함수가 실행되면서 int y가 선언이 되고.
	// y라는 메모리에 5라는 값이 복사가 됨.
	doSomething(5);

	int x = 6;

	// 함수에서의 주소와, x의 주소는 다르다.
	// 즉 x라는 변수 자체가 전달이 되는게 아니라 값만 전달되는게 맞다.
	cout << "In main " << x << " " << &x << endl;

	// x이 전달되는게 아니라 x에 들어있는 6이라는 값만 전달되는 것이다.
	// 6이 인자로 매개변수로 전달된다.
	doSomething(x);
	doSomething(x + 1); // x + 1은 값이다. 6 + 1 = 7이라는 값이 전달해주는 것이다.

	// int y와 x가 주소가 다르기 때문에 doSomething 안에서 뭘 하더라도 x라는 변수에 영향을 줄 수 없다.
	// 값에 의한 전달을 할 때는 좀 편하다. 외부로 영향을 끼치지 않는다.
	// 매개변수 y가 함수 안에서만 있고 함수 끝나면 사라진다.

	
}

 

 

 

 

doSomething(5);를 실행했을 때, 함수가 실행되면서 int y라는 변수가 지역변수로 선언이 되고, 변수 y가 차지하고 있는 메모리에 5라는 값이 복사가 된다. 즉 인자로 전달된 값이 복사가 되는 개념이다.

 

 

위 코드를 실행해봤을 때, 함수 내에서의 주소와 main에서의 주소가 다른 것을 알 수 있는데, 이를 통해서도 확인할 수 있는 것은 int x라는 변수를 doSomething 함수의 인자로 전달하더라도 변수 x 자체가 함수로 넘어가는 것이 아니라 변수 x의 값만 전달이 된다는 점이다.

 

생각해보면, x + 1을 인자로 줬을 때 함수가 실행이 되는데, 만약 값만 복사하는게 아니라 주소를 넘겨주는거라면 x + 1이라는 메모리 주소가 있어야할탠데 이건 불가능하다.

 

이렇게 값만 전달하는 경우를 '값에 의한 전달' 이라 부르고, 인자(Argument)와 매개변수(Parameter)는 주소가 다르기 때문에 함수에서 어떤 작업을 하더라도 인자로 사용된 변수에는 영향을 주지 않는다.

 

 

Chapter 7.3 참조에 의한 인수 전달

 

 

이번 챕터에서는 이전 챕터와 다른 전달 방식인 참조에 의한 인수 전달에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>
using namespace std;

// &를 뺀다면 값에 의한 전달로, 의미가 없어짐.
void addOne(int &y)
{
	cout << y << " " << &y << endl;
	y = y + 1;
}


// 입력을 앞쪽에, reference를 위쪽에 위치하는 경향.
// const로 막아두면 얘는 입력으로 사용하나보다 라고 생각할 수 있다.
// 값이 바뀌지 않을 경우는 const로 선언하는 것이 안전하다.
void getSinCos(const double degrees, double &sin_out, double &cos_out)
{
	static const double pi = 3.141592; // 함수 안에서 매번 서로 정의 X 재사용됨.
	
	const double radians = degrees * pi / 180.0;
	sin_out = std::sin(radians);
	cos_out = std::cos(radians);

}

int main()
{

	int x = 5;

	cout << x << " " << &x << endl;

	
	addOne(x);

	cout << x << " " << &x << endl;

	// 값에 의한 전달은, 함수 안에서 새롭게 변수를 정의하고 거기에 복사를 하는 형태
	// 참조에 의한 호출에서는, 변수 자체를 넘기는 것.


	// 변수를 미리 선언한 뒤, call by reference를 사용하면 return 값이 여러개 나오는 것처럼 구현할 수 있다.
	double sin(0.0);
	double cos(0.0);

	getSinCos(30.0, sin, cos);
	
}

 

 

 

이전 챕터에서 다룬 값에 의한 전달과는 다르게, 참조에 의한 전달은 reference 방식을 사용하고, 함수의 매개변수로 줄 때 변수의 주소값을 전달하기 때문에 변수에 직접적으로 접근, 수정이 가능하다.

 

int형 변수인 x를 정의하고, 이를 addOne이라고 하는 함수의 인자로 전달했다.

 

addOne은 매개변수인 y를 &y로 받아서 참조를 이용해서 받는다.

 

따라서 위 출력 결과를 보면 main문에서 찍은 x의 주소와, addOne 함수 내에서의 y의 주소가 같은 것을 확인할 수 있다.

 

그리고 addOne에서 y = y + 1으로 y의 값을 증가시켰는데, addOne 함수에서 벗어난 뒤 x의 값을 찍었을 때 6이 되는 것을 통해 값을 통한 전달과의 차이점을 알 수 있다.

 

 

C++에서 함수는 return을 하나만 할 수 있는데, 위 코드에서처럼, 변수를 미리 선언한 뒤, 참조에 의한 전달을 통해 함수의 매개변수로 전달하게 되면 함수 내부에서 변수를 직접 수정할 수 있기 때문에 마치 여러개의 return을 하는 것처럼 구현할 수 있다.

 

getSinCos 함수에서 벗어난 뒤, sin 변수와 cos 변수를 출력해보면 값이 변한 것을 확인할 수 있다.

 

그리고 getSinCos 함수의 매개변수들을 보면, degrees는 값에 의한 전달을 사용하고, 뒤에 sin_out과 cos_out만 참조에 의한 전달을 사용하는데, 이처럼 input으로만 사용되는 변수는 값에 의한 전달을 사용하면 되니 앞쪽에 위치하고, 참조에 의한 전달을 통해 return의 기능을 하게 되는 매개변수들은 뒤쪽에 위치한다고 한다. 추가로, input의 경우 함수 내에서 변경될 일이 없기 때문에 안전하게 const로 상수화 시켜주면 좋다.

 

 

 

 

예제 코드 2

#include <iostream>
using namespace std;

typedef int* pint;

// 사용자 정의 자료형의 레퍼런스로 보이게 된다.
void foo_2(pint &ptr)
{
	cout << ptr << " " << &ptr << endl;
}

int main()
{
	int x = 5;
	int *ptr = &x; // 포인터지만 변수처럼 보임.

	foo_2(ptr);
	
	cout << ptr << " " << &ptr << endl;
}

 

 

이번 예제 코드에서는 포인터 변수에 참조에 의한 전달을 사용하는 예시이다.

 

int형 변수 x를 정의하고, int형 포인터 변수인 *ptr가 x의 주소를 가리키게 한다.

 

이 포인터 변수 ptr을 foo_2라는 함수의 인자로 전달하는데, foo_2의 매개변수를 보면 pint &ptr를 사용하는 것을 알 수 있다.

 

pint는 위에 typedef를 이용해서 만든 사용자 정의 자료형으로, int형 포인터 변수를 나타낸다.

 

원래 foo_2(int *&ptr)로 작성했는데, 이렇게 하면 주소에 역참조에 너무 복잡하게 보이기 때문에, (int *) &ptr 느낌으로 작성하기 위해서 int *를 별도의 자료형으로 분리한 것이라고 보면 된다.

 

해당 코드의 출력 결과를 보면, main문 에서의 ptr의 값과 &ptr, 그리고 함수 내에서의 ptr의 값과 &ptr를 비교하면 같은 것을 알 수 있다.

 

따라서 pointer형 변수 또한 참조에 의한 전달을 사용할 수 있다.

 

 

 

예제 코드 3

 

#include <iostream>
#include <vector>

using namespace std;

void printElement(const vector<int>& arr)
//void printElement(int (&arr)[4])
{
	for (int i = 0; i < arr.size(); ++i)
	{
		cout << arr[i] << endl;
	}
}


int main()
{
	
	//int arr[] = { 1, 2, 3, 4 };
	vector<int> arr{ 1, 2, 3, 4 };

	printElement(arr);
	
}

 

 

다음 예제 코드에서는 앞에서 정적 array 대신에 사용하는 동적 array인 std::vector에 참조에 의한 전달을 사용하는 예시이다.

 

int형 std::vector를 선언하고, 이를 printElement 함수에 전달했다.

 

printElement 함수에서는 매개변수로 vector<int>& arr를 받는 것을 확인할 수 있고, std::vector 또한 참조에 의한 전달을 사용할 수 있다.

 

해당 함수는 단순히 출력하는 기능을 수행하기 때문에, arr에 대한 값이 바뀔일이 없기 때문에 const로 선언해준 것을 볼 수 있다.

 

 

 

Chapter 7.4 주소에 의한 인수 전달

 

 

이번 챕터에서는 앞에서 다룬 값에 의한 전달, 참조에 의한 전달에 이어서 주소(pointer)를 활용한 전달에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>

using namespace std;

typedef int* pint;

//void foo(pint ptr)
// pointer를 이용한 전달
// 주소라는 값을 값에 의한 전달을 한 것.
// 주소값이 복사가 되는 것 뿐임.
// 포인터 변수도 변수다.
void foo(const int *ptr)
{
	cout << *ptr << " " << ptr << " " << &ptr << endl;

	// *ptr = 10; //const 사용 시 de-referencing 한 값을 못 바꿈.
	// const를 사용하지 않으면 값 바꿀 수 있음.
	// 값 바꾸면 value 변수 값이 바뀌게 됨.
	// 즉, 포인터를 사용해서 함수의 출력처럼 사용할 수 있고, 참조에 의한 전달하고 같은 맥락임.
}

int main()
{
	int value = 5;

	cout << value << " " << &value << endl;

	int *ptr = &value;

	cout << "ptr address in main " << &ptr << endl;

	

	foo(ptr);
	foo(&value);
	//foo(5); // literal은 안됨!
	
}

 

 

 

value라는 int type 변수를 선언해주고, value의 주소를 가리키는 int형 포인터 변수인 ptr를 선언해준다.

 

출력 결과의 첫 번째 줄과 두 번째 줄을 보면, value의 값은 5이고, value라는 변수의 주소는 0058FBF0이며, value의 주소를 가리키는 포인터 변수의 주소는 0058FBF8임을 확인할 수 있다.

 

foo라는 함수는 int형 포인터를 매개변수로 받는 함수인데, 이 함수의 인자로 ptr과 &value를 전달해준다.

 

출력 결과의 세 번째 줄과 네 번째 줄을 보면,

 

함수의 매개변수인 ptr의 역참조인 *ptr가 5인 것을 확인할 수 있고, ptr이 가리키는 주소는 0058FBF0인 것을 확인할 수 있다. 이는 변수 value의 주소와 동일한 것을 알 수 있다. 그리고 ptr의 주소는 0058FBF4인 것을 확인할 수 있으며, 이는 main문에서 ptr의 주소를 출력했을 때 결과와 다른 것을 알 수 있다.

 

따라서 이를 통해 알 수 있는 것은, 주소라는 값을 값에 의한 전달을 한 것이다.

 

즉, value라고 하는 주소는 전달했지만, main 문에서 value의 주소를 가리키는 pointer 변수의 주소와 foo 함수에서의 pointer 변수는 아예 다른 것이다.

 

 

 

예제 코드 2

#include <iostream>

using namespace std;

void foo_2(double degrees, double *sin_out, double *cos_out)
{
	*sin_out = 1.0;
	*cos_out = 2.0;
}


int main()
{
	double degree = 30;
	double sin_out = 0, cos_out = 0;

	cout << sin_out << " " << cos_out << endl;

	foo_2(degree, &sin_out, &cos_out);

	cout << sin_out << " " << cos_out << endl;
	
	
}

 

 

참조에 의한 전달 챕터에서 언급했듯이, C++ 특성상 return은 하나만 할 수 있다.

 

주소에 의한 전달 방식도 결국 변수의 주소를 이용해서 왔다 갔다하는 것이기 때문에, 참조에 의한 전달 챕터에서 했듯이 함수의 인자로 전달된 변수를 변경할 수 있다. 이를 통해 여러 개의 return이 가능한 것처럼 구현할 수 있다. 위 예제 코드는 이를 보여준다.

 

double형 변수인 sin_out과 cos_out을 0으로 초기화 해줬기 때문에, 처음 출력은 둘 다 0인 것을 확인할 수 있다.

 

foo_2 함수에 sin_out과 cos_out의 주소를 전달해주었고, foo_2 함수 내에서는 역참조를 통해 해당 변수의 값을 변경할 수 있다. 함수 내에서 sin_out은 1로, cos_out은 2로 변경해주었으며, foo_2 함수 밖으로 나오고 난 후 sin_out과 cos_out의 값을 찍어보았을 때 실제로 값이 1과 2로 변화한 것을 확인할 수 있다.

 

 원리는 참조에 의한 전달과 동일하니, 이해하기 어렵지 않다.

 

 

 

 

 

Chapter 7.5 다양한 반환 값들(값, 참조, 주소, 구조체, 튜플)

 

 

이번 챕터에서는 함수의 반환값이 값일 때, 참조일 때, 주소일 때, 구조체일 때, 튜플일 때에 대해서 알아본다.

 

 

 

 

예제 코드 1

int getValue(int x)
{
	int value = x * 2;
	return value;
}

int main()
{
	int value = getValue(3);
}

 

가장 베이직한 방식인 값을 반환하는 경우이다.

 

3이라는 int 값을 인자로 주면, 함수에서 매개변수 int x에 받은 다음 이를 2배로 만들고 return해준다.

 

 

 

 

 

예제 코드 2

int* getValue2(int x)
{
	int value = x * 2;
	return &value;
}

int main()
{
	// 방법 1
	int *value2 = getValue2(3);
    
	// 방법 2
	int value2 = *getValue2(3);
}

 

두 번째 코드는 함수의 반환값이 주소인 경우이다.

 

첫 번째 방법은 3을 인자로 넘겨주면, 함수 내부에서 value라는 변수가 6이 되고 value의 주소를 return하는 방식이다.

 

이를 value2라고 하는 포인터 변수에 전달해주는 것이다.

 

다만, value라는 변수가 지역변수이기 때문에 함수 구간에서 나오면 변수가 사라지기 때문에 문제가 될 수 있다.

 

두 번째 방법은 첫 번째 방법과 내부적으로는 같지만, return된 value 변수의 주소에 역참조를 한 값을 value2라는 변수에 전달해주는 상황이다.

 

이 경우에도, value라는 변수가 지역변수 이기 때문에, 사라질 변수를 역참조 하는 경우가 되므로 문제가 될 수 있다.

 

두 경우 모두 빌드 시 warning C4172: 지역 변수 또는 임시: value의 주소를 반환하고 있습니다. 의 warning이 발생한다.

 

 

예제 코드 3

int& getValue3(int x)
{
	int value = x * 2;
	return value;
}


int main()
{
	int value3 = getValue3(5);
}

 

세 번째 코드는 함수의 반환값이 참조인 경우이다.

 

5를 인자로 주면, 함수 내부에서 value가 10이 되고, value에 대한 참조를 return하게 된다.

 

main문에서 value3은 value의 참조를 받게 된다.

 

다만, 이번에도 함수 내 value라는 변수는 함수 밖으로 나오면 사라지기 때문에, value3의 값을 출력했을 때 10이 아닌 쓰레기 값이 출력될 수 있다.

 

 

 

예제 코드 4

#include <array>

int& get(std::array<int, 100>& my_array, int ix)
{
	return my_array[ix];
}

int main()
{
	std::array<int, 100> my_array;
	my_array[30] = 10;

	get(my_array, 30) = 1024; // 함수에 array의 레퍼런스만 보내주고 변수처럼 편리하게 작업 가능.

	cout << my_array[30] << endl;
}

 

네 번째 코드는 std::array에 대한 참조를 출력하는 경우이다.

 

먼저 main문에서 my_array를 선언해서 메모리 잡아준 뒤, get 함수를 이용해서 값을 변경해주는 예제이다.

 

get 함수에서는 my_array의 reference를 인자로 받기 때문에, my_array에 직접 접근해서 변경할 수 있고, my_array의 ix번째 element의 참조를 return하기 때문에 get(my_array, 30) = 1024;는 my_array[30] = 1024;과 동일한 작업을 수행하게 된다.

 

 

 

예제 코드 5

struct S
{
	int a, b, c, d;
};

S getStruct()
{
	S my_s{ 1, 2, 3, 4 };
	return my_s;
}

int main()
{
	S my_s = getStruct();
	
	cout << my_s.b << endl;
}

 

다섯 번째 예제는 구조체를 return하는 경우이다.

 

getStruct() 함수는 구조체 S 타입의 my_s를 정의하고, 이를 return한다.

 

 

 

 

예제 코드 6

#include <tuple>

std::tuple<int, double> getTuple()
{
	int a = 10;
	double d = 3.14;
	return std::make_tuple(a, d);
}

int main()
{
	
	std::tuple<int, double> my_tp = getTuple();

	cout << std::get<0>(my_tp) << endl;
	cout << std::get<1>(my_tp) << endl;
	

	// C++ 17 이상
	auto[a, d] = getTuple();

	cout << a << endl;
	cout << d << endl;

 

마지막 예제는 tuple을 return하는 경우이다.

 

int형 변수와 double형 변수를 함께 return 하고 싶을때 사용한다고 생각하면 될 듯 하다.

 

 std::get을 사용하면 원하는 인덱스에 있는 값을 출력할 수 있으며,

 

C++ 17부터는 auto 키워드를 활용해서 더욱 손쉽게 return 받을 수 있다고 한다.

 

 

Chapter 7.6 인라인 함수

 

 

이번 챕터에서는 인라인 함수라는 기법에 대해서 알아본다.

 

 

 

예제 코드 1

#include <iostream>

using namespace std;

inline int min(int x, int y)
{
	return x > y ? y : x;
}


int main()
{
	cout << min(5, 6) << endl;
	cout << min(3, 2) << endl;

	cout << (5 > 6 ? 6 : 5) << endl;
	cout << (3 > 2 ? 2 : 3) << endl;
}

 

함수 앞에 inline 이라는 키워드를 사용해주는 것이 인라인 함수를 사용하는 방법이다.

 

인라인 함수를 사용하면, 함수가 아닌 것 처럼 작동한다고 한다.

 

강제로 이 함수를 무조건 인라인으로 해라 라고 만드는게 아니라, 컴파일러가 봤을 때 인라인으로 쓰면 좋을 것 같으면 인라인으로 적용하라고 권장, 권유 하는 방식이라고 한다.

 

모든 함수를 inline으로 사용한다고 해서 빨라지는 것이 아니며, 컴파일러가 inline으로 쓰는게 낫다고 생각하면 자동으로 해주기도 한다고 한다.

 

컴파일러가 실제로 inline 방식으로 해줄지 안해줄지는 알기가 어렵다고 한다.

 

그리고 인라인으로 된 함수가 엄청 많고, 컴파일러가 실제로 인라인으로 구현했다면, 컴파일 된 프로그램이 많이 커지면서 오히려 인라인을 사용한 효과를 못 볼 수도 있다고 한다. 

 

따라서 인라인이라고 하는 방식을 권장한다고 얘기하기는 어렵다고 한다.

 

 

 

 

Chapter 7.7 함수 오버로딩

 

 

이번 챕터에서는 동일한 이름을 가지는 함수를 여러 개 만드는 방식인 함수 오버로딩에 대해서 다룬다.

 

 

 

예제 코드 1

#include <iostream>

using namespace std;

int add(int x, int y)
{
	return x + y;
}

double add(double x, double y)
{
	return x + y;
}

int main()
{
	add(1, 2);
	add(3.0, 4.0);
}

 

위 소스 코드를 보면, add 라는 이름의 함수가 2개 정의되어 있는 것을 볼 수 있다.

 

이렇게 같은 이름으로 함수를 여러 개 만드는 방식이 바로 함수 오버로딩 이라고 한다.

 

 

 함수의 이름이 같더라도, 매개변수가 다르면 다른 함수처럼 정의가 된다.

 

같은 이름으로 여러 개의 함수가 정의된 경우, 매개변수 타입이 가장 잘 맞는 함수를 찾아서 작동하게 된다.

 

단, 여러 함수 중 어떤 함수가 가장 적합한지는 '컴파일' 할 때 결정 되어야 한다.

 

 

파라미터가 같은 함수 2개가 있는 경우 문제가 발생하며, C++에서는 return type이 다른 것만 가지고는 함수 오버로딩이 안되며 매개변수가 달라야 한다.

 

 

 

예제 코드 2

void getRandom(int &x) {}

void getRandom(double &x) {}

int main()
{
	int x;
	getRandom(x);
}

 

다음으로는 return type 은 같은데, 매개변수가 다른 경우이다.

 

참조를 활용해서 변수를 인자로 주고, 해당 인자를 바꾸는 방식으로 구현할 수도 있다.

 

 

 

예제 코드 3

// 두개는 같은 타입으로 인식됨.
// 컴파일러에게 눈속임을 할 수는 없다.
typedef int my_int;

void print(int x) {}
void print(my_int x) {}

void print(char *value) {}
void print(int value) {}

int main()
{
	print(0);
	print("a");
}

 

이번 예제 코드에서는 2가지를 정리하려고 한다.

 

typedef를 사용하면 사용자 정의 자료형을 사용할 수 있는데, my_int라는 이름으로 int형 자료형을 만들었다.

 

void print(int x)와 void print(my_int x)로 매개변수가 다른 2가지 함수를 구현했는데, 결국 my_int도 int이기 때문에 사용 시 'void printint)' 함수에 이미 본문이 있습니다. 라고 하는 경고가 출력되면서 빌드가 되지 않는다.

 

따라서 typedef를 사용하더라도, 컴파일러에게 눈속임을 할 수는 없다.

 

 

다음으로, char 타입의 포인터 변수를 받는 print 함수와, int형 변수를 받는 print 함수를 살펴보자.

 

print(0)의 경우 당연히 int형 변수를 받는 함수쪽으로 갈탠데, "a"를 인자로 전달하는 경우 두 함수 모두와 매칭이 되지 않는다.

 

char *value로 받는 함수의 경우, 포인터를 받아야 하는데 "a"는 리터럴이니 수정 가능하지 않아서 사용할 수 없다.

 

 

이렇게, 매칭되는 함수가 없는 경우, 인수 목록이 일치하는 함수가 없다고 뜬다.

 

위 케이스에서 만약 어떻게든 돌아가게끔 만들고 싶다면, const char *value로 바꿔주면 가능하다. 이는 메모리 주소를 상수로 사용한다는 의미이기 때문에 가능하다. 

 

 

 

 

예제 코드 4

void print(unsigned int value) {}
void print(float value) {}

int main()
{
	print('a'); // print((unsigned int) 'a');
	print(0); // print(0u)
	print(3.14159); // print(3.14159f)
}

 

마지막 예제 코드에서는, 여러 함수에 모두 다 가능한 경우, 즉 어떤 함수를 사용할지 모호한 경우에 대해서 알아본다.

 

 

'a'의 경우 ASCII 코드 값으로 97인 상황인데, 이를 unsigned int에 맞춰서 쓸지, 혹은 float에 맞춰서 쓸지 모르겠는 상황이다.

 

0과 3.14159도 다 마찬가지 이다.

 

따라서 이럴 땐, 인자로 전달할 때 명확하게 어떤 쪽에 맞춰서 쓸지를 명시해줘야 한다.

 

'a'는 int이니 (unsigned int)로 캐스팅해서 주면 되고, 0은 0u 로 전달해주면 unsigned int라는 것을 알려줄 수 있다.

 

3.14159는 3.14159f로 전달해주면 float인 것을 알 수 있다.

 

 

 

 

 

Chapter 7.8 매개변수의 기본값

 

 

이번 챕터에서는 함수의 매개변수에 기본값을 주는 것에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>
using namespace std;

void print(int x = 1024)
{
	cout << x << endl;
}

int main()
{
	print(10);
	print();
}

 

 

void print (int x = 1024)와 같은 방식으로, 함수 매개변수의 기본값을 정의할 수 있다.

 

print(10); 으로 사용하면 x에 10을 전달하지만, print(); 처럼 값을 따로 주지 않으면 기본값인 1024가 적용된다.

 

 

 

예제 코드 2

#include <iostream>
using namespace std;

void print(int x = 10, int y = 20, int z = 30)
{
	cout << x << " " << y << " " << z << endl;
}

int main()
{
	print(100);
	print(100, 200);
	print(100, 200, 300);
}

 

 

다음은 매개변수가 여러 개인 경우에 대해서 알아본다.

 

위 코드는 3개의 매개변수 모두에 기본값을 적용한 경우이다.

 

오른쪽 변수에 기본값이 있을 때는 왼쪽에 있는 변수에도 기본값을 넣을 수 있다.

 

예를 들어서, int x , int y, int z = 30 처럼 맨 오른쪽만 적용한다거나,

 

int x, int y = 20, int z = 30 처럼 오른쪽 두개에 적용하는 것은 가능하다.

 

그러나 int x, int y = 20, int z 처럼 가운데만 있는 매개변수만 기본값을 주는 것은 불가능하다.

 

 

print 함수는 매개변수가 3개인데, 만약 print(100); 처럼 1개만 값을 지정해준다면, 나머지 2개는 기본값 적용이 되어 100, 20, 30의 결과를 얻게 된다.

 

마찬가지로, print(100, 200); 처럼 2개만 지정해주는 경우에는 100, 200, 30과 같이 맨 마지막 변수만 기본값이 적용된다.

 

 

 

예제 코드 3

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

void print2(std::string str) {}
void print2(char ch = ' ') {}

int main()
{
	print2();
}

 

이번 예제 코드에서는 함수 오버로딩과 매개변수의 기본값이 함께 들어가는 케이스이다.

 

print2라는 이름으로 std::string을 받는 함수가 있고 char type 변수를 받는 함수가 있는데, char type 변수를 받는 함수는 기본값을 가지고 있다. 따라서 이 경우에는 아래쪽에 있는 print2 함수를 사용하게 된다.

 

 

 

예제 코드 4

#include <iostream>
using namespace std;

// 디폴트 값이 함수 오버로딩 하는데도 영향을 준다.
void print3(int x) {}
void print3(int x, int y = 20) {}

int main()
{
	print3(10);
}

 

이번 예제에서는 매개변수의 기본값이 함수 오버로딩에도 영향을 주는 케이스이다.

 

print3이라는 함수가 2가지 있는데, 위 함수는 기본값이 없고 아래 함수는 y 변수의 기본값이 있는 경우이다.

 

위 경우, print3(int x)에 10을 적용할지, 혹은 print3(int x, int y = 20)에 10을 적용해서 x가 10이고 y가 20이 되어야 할지가 모호하다.

 

이런식으로, 매개변수의 기본값이 함수 오버로딩 할 때 모호성을 만들 수 있다.

 

 

 

Chapter 7.9 함수 포인터

 

 

이번 챕터에서는 함수 포인터에 대해서 알아본다. 알고보니 함수도 포인터였다.

 

 

 

예제 코드 1

#include <iostream>
using namespace std;

int func()
{
	return 5;
}

int func2(int x)
{
	return 5;
}

int goo()
{
	return 10;
}

int main()
{
	int(*fcnptr)() = func;
	int(*fcnptr2)(int) = func2; // 매개변수가 있는 경우.

	cout << fcnptr() << endl; // 포인터이지만 뒤에 괄호를 넣어서 함수 포인터의 기능을 수행함.

	fcnptr = goo; // 다른 함수의 주소를 넣어줌. 
	
	cout << fcnptr() << endl;

	cout << func << endl;
}

 

 

 

지금까지는 변수에 대한 포인터만 다뤘지만, 함수도 포인터라고 한다. 즉, 함수도 메모리에 들어가는 것이다.

 

우리가 함수를 호출하게 되면, 이 함수가 어느 메모리 주소에 있는지를 파악한 뒤 그 주소에 있는 프로그램을 가져다가 실행시키는 것이다.

 

일반적인 변수에서는, int *ptr = &value;와 같은 방식으로 사용을 해줬지만 함수 포인터의 경우는 int(*fcnptr)() = func;와 같이 포인터쪽에 괄호를 적어줘야만 함수 포인터로 사용할 수 있다는 점을 알아야한다.

 

함수의 매개변수가 존재하는 경우에는, int(*fcnptr2)(int) = func2;와 같이 포인터 오른쪽에 있는 괄호에 지정해주면 된다.

 

함수의 포인터를 사용할 때는 뒤에 괄호를 넣어서 사용한다. 따라서 fcnptr()와 같은 방식으로 함수를 사용할 수 있다.

 

중간에 함수 포인터 fcnptr에 goo라고 하는 다른 함수의 주소를 넣어주면, 다른 함수를 사용하게 만들 수 있다.

 

그리고 함수 이름을 직접적으로 cout으로 찍으면 함수의 주소를 출력할 수 있다.

 

 

 

예제 코드 2

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

bool isEven(const int& number)
{
	if (number % 2 == 0) return true;
}

bool isOdd(const int& number)
{
	if (number % 2 != 0) return false;
}

// 파라미터일 때는 레퍼런스를 앞에 붙여도 문제 없음.
void printNumbers(const array<int, 10>& my_array, bool print_even)
{
	for (auto element : my_array)
	{
		if (print_even && element % 2 == 0) cout << element;
		if (!print_even && element % 2 == 1) cout << element;
	}
	cout << endl;
}

// 여러번 사용되는 경우 typedef를 이용해서 줄일 수 있음.
//typedef bool(*check_fcn_t)(const int&);

using check_fcn_t = bool(*)(const int&);


// 함수도 기본값 줄 수 있다.
void printNumbers2(const array<int, 10>& my_array, check_fcn_t check_fcn = isEven)
{
	for (auto element : my_array)
	{
		if (check_fcn(element) == true) cout << element;
		if (check_fcn(element) == false) cout << element;
	}
	cout << endl;
}

int main()
{
	std::array<int, 10> my_array = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };

	// true: 짝수, false: 홀수
	printNumbers(my_array, true);
	printNumbers(my_array, false);

	// 함수는 그 이름 자체가 포인터이기 때문에 앞에 & 안 붙여도 됨.
	printNumbers2(my_array, isEven);
	printNumbers2(my_array, isOdd);
}

 

 

 

이번 예제 코드에서는 함수의 인자로 함수를 주는 경우에 대해서 알아본다.

 

main문에서 std::array로 10개짜리 배열을 만들어놓는다.

 

짝수나, 홀수만 출력하는 함수인 printNumbers를 만들어준다.

 

bool이 true면 짝수, false면 홀수를 출력한다.

 

 

printNumbers와 동일한 기능을 하는 함수에 함수를 인자로 주도록 만들어서 printNumbers2를 만들었다.

 

isEven과 isOdd라는 함수는, int형 변수가 들어왔을 때 각각 짝수와 홀수일 때만 return하는 함수이다.

 

이 함수를 printNumbers2의 인자로 전달하고, printNumbers2의 내부에서는 check_fcn라는 이름으로 함수로 사용한다.

 

check_fcn을 돌렸을 때 결과가 true이면 cout을 하도록 만들어준다.

 

printNumbers2의 인자에서 함수 부분은 원래 bool(*check_fcn)(const int&) = isEven으로 작성되어 있는데, 이 부분을 조금 더 간략하게 만들기 위해서 typedef를 사용하거나, using을 사용할 수 있다.

 

기존에 변수의 type 에 대해서 typedef를 사용할 때는, typedef int* my_int;와 같은 방식으로 작성했었는데, 함수 포인터에 대한 typedef를 사용할 때는 typedef bool(*check_fcn_t)(const int&);와 같은 형식으로 사용한다고 한다.

 

 

 

예제 코드 3

#include <functional>
using namespace std;

void printNumbers3(const array<int, 10>& my_array, std::function<bool(const int&)> check_fcn)
{
	for (auto element : my_array)
	{
		if (check_fcn(element) == true) cout << element;
		if (check_fcn(element) == false) cout << element;
	}
	cout << endl;
}

int main()
{
	std::function<bool(const int&)> fcnptr3 = isEven;

	printNumbers3(my_array, fcnptr3);

	fcnptr3 = isOdd;

	printNumbers3(my_array, fcnptr3);
}

 

다음은 std::function을 활용하는 예제이다.

 

std::function<bool(const int&)> (bool을 return하고 const int&를 매개변수로 사용하는 함수)인 함수 포인터 fcnptr3을 정의하고, 여기에 isEven의 주소를 지정한다.

 

그리고 printNumbers3에다가 fcnptr3을 전달해준다.

 

예제 코드 2번에서와 사실상 동일하게 작동하나, std::function 템플릿을 사용하는 케이스이다.

 

이는 C++ 11부터 도입되었고, 현재는 대부분 std::function을 사용하는 것을 더 권장한다고 한다.

 

 

 

 

Chapter 7.10 스택과 힙

 

이번 챕터에서는 스택과 힙에 대해서 알아보고, 왜 동적 할당이 필요한 것이며 메모리 누수가 일어나는 이유가 무엇인지에 대해서 정리한다.

 

 

 

 

우리가 컴퓨터에게 우리가 작성한 프로그램을 실행시켜달라고 요청하면, OS가 메모리를 넘겨주게 된다.

 

이때, 메모리는 Segment라는 영역으로 나뉜다.

 

우리가 작성한 프로그램이 저장되는 segment인 Code,

 

0으로 초기화된 global, static 변수들이 저장되는 BSS segment,

 

초기화된 global, static 변수들이 저장되는 Data segment,

 

그리고 stack, heap segment가 있다.

 

여기서 stack, heap에 대해서 더욱 자세히 알아본다.

 

 

 

Case 1

 

 

앞에서 언급한 대로, Data segment에는 global 변수들이 저장된다. 왼쪽 소스에서 int g_i가 유일한 global 변수로, Data segment에 위치한다.

 

다음으로, 먼저 main()문에 도달하고, main문에 있는 변수인 int a, b를 stack에 쌓는다.

 

main문 안에 first 함수가 있으므로, first 함수와 first 내에 있는 변수인 int x, y를 그 다음 stack에 쌓아준다.

 

first 함수 내부에 second 함수가 있으므로, second 함수와 함수 내 변수인 int x를 그 다음 stack에 쌓아준다.

 

프로그램이 실제로 실행되면서 stack에 쌓아진 것들을 하나씩 사라지게 만들며, 위부터 순차적으로 아래로 내려가면서 사라지게 만든다.

 

Stack은 실행시켜야 하는 것이 가장 위에 있는 구조로, 비교적 속도가 빠르고 local 변수는 더 빠른 속도로 접근할 수 있도록 만든다.

 

하지만 사이즈가 작다라는 단점이 있다. 

 

너무 큰 사이즈의 array를 정의하려고 하거나, 너무 함수 호출을 많이 해서 stack이 넘치는 경우 stack overflow가 발생할 수 있다.

 

 

Case 2

 

 

앞에서도 설명했지만, main()문은 Stack에서 시작된다. main문에 있는 *ptr은 stack에 정의된다.

 

다음으로, 동적 할당을 통해서 ptr에 1000000 사이즈짜리 int형 array를 선언해줬고, 이는 heap 메모리 영역에 위치한다.

 

Heap은 사이즈가 크지만, stack처럼 쌓이는 특성은 없기 때문에 메모리 중 어느 위치에 생길지 예측이 어렵다.

 

os가 메모리 상에서 가장 적당한 위치를 지정해주고, 메모리 공간의 첫 주소를 포인터로 저장해주기 때문이다.

 

 

현재 소스코드에서는 ptr를 delete 해줘서 os에 메모리를 반납하긴 했지만, main문의 ptr 변수에는 기존에 지정되어 있던 heap 메모리의 주소를 아직도 가지고 있다. 따라서 소스 코드가 이후에 더 작업을 할 내용이 남아 있는 경우에는, ptr를 역참조하면 문제가 발생할 소지가 많으므로 ptr = nullptr;로 널 포인터 처리를 해주는 것이 안전하다고 한다.

 

 

 

 

Case 3

 

 

동일하게 main() 문은 Stack에서 실행되는데, 문제는 메모리 동적할당을 한 함수에서 delete를 하지 않아서 ptr2라는 변수가 날라갔다는 것이다.

 

main()문에서 ptr2를 접근할 수 없기 때문에, Heap 메모리 상의 주소를 알 수 없다.

 

이러면 힙에 쓸 수 없는 메모리로 점점 차서 다른 프로그램이 사용할 메모리를 잠식하게 된다.

 

이것이 메모리 누수가 발생하는 이유이고, 문제가 된다.

 

 

 

Chapter 7.11 std::vector를 스택처럼 사용하기

 

 

이번 챕터에서는 std::vector에 대해서 자세히 알아보고, 이를 스택처럼 사용하는 방법에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>
#include <vector>

using namespace std;

int main()
{
	// int *v_ptr = new int[3]{1, 2, 3};과 비슷(내부적으로), 벡터 안에는 메모리에 대한 포인터 외에도 사이즈를 관리하는 변수가 있음.

	std::vector<int> v{ 1, 2, 3 };

	v.resize(2); 

	// 벡터는 size가 있고, capacity가 있음. capacity는 용량.
	// new와 delete는 느림.
	// 내부에서 이루어지는 new와 delete를 어떻게 하면 적게 사용할지 생각하면서 써야 벡터를 잘 사용할 수 있다.
	// 코드 내부에서는 capacity 개수만큼 메모리를 가지고 있음. 사이즈는 그 중에서 몇 개만 사용을 한다 라는 의미.

	for (auto &e : v)
	{
		cout << e << " ";
	}
	cout << endl;

	cout << v.size() << " " << v.capacity() << endl;

	cout << v[0] << " " << v[1] << " " << v[2] << endl;

	//cout << v.at(2) << endl;

	int *ptr = v.data();

	cout << ptr[2] << endl; // 마지막에 하나 더 가지고 있는데 없는척 하는 것. 억지로 가지고 온 경우
}

 

 

 

 

int형 변수를 가지고 있는 std::vector를 정의해준다.

 

이는 int *v_ptr = new int[3]{1, 2, 3};과 내부적으로 비슷하게 작동한다.

 

 

 

std::vector에는 size와 capacity가 같은데, size는 현재 벡터에 실제로 저장된 요소의 개수를 나타내고, capacity는 벡터가 현재 할당받은 메모리 공간에 저장할 수 있는 최대 요소의 개수를 의미한다. 

 

v라는 vector는 원래 3개의 요소로 구성되어 있는데, v.resize(2);를 적용해서 2개로 줄여놓은 상황이다.

 

따라서 for문을 돌려보면 1, 2만 출력이 되는 것을 확인할 수 있다.

 

그런데, v.size()는 정상적으로 2이지만, v.capacity를 출력하면 3이다.

 

즉, resize를 하면 줄어드는 것처럼 보이지만 실제로는 아직도 가지고 있는 것이다.

 

이는, new와 delete 작업이 시간이 오래 걸리기 때문에 실제로 메모리를 할당하고 해제하게 되면 오래 걸리니, 마치 2개만 가지고 있는것처럼 눈속임을 하는 것이다.

 

실제로 v[2]로 접근을 하거나, 혹은 v의 data를 가리키는 포인터를 만들어서 [2]에 접근해보면 3이라는 값을 가지고 있다는 것을 확인할 수 있다.

 

 

 

예제 코드 2

 

#include <iostream>
#include <vector>

using namespace std;

void printStack(const std::vector<int> &stack)
{
	for (auto &e : stack)
	{
		cout << e << " ";
	}
	cout << endl;
}


int main()
{
	
	// 벡터로 stack을 구현하는 이유? 벡터에서 reserve로 capacity를 늘려놓으면
	// push_back할 때 new와 delete를 호출할 필요가 없어서 효율이 좋음.
	// reserve를 너무 크게 해놓으면 메모리가 낭비될 수 있다.
	std::vector<int> stack;

	stack.push_back(3);
	printStack(stack);
	stack.push_back(5);
	printStack(stack);
	stack.push_back(7);
	printStack(stack);
	stack.pop_back();
	printStack(stack);
	stack.pop_back();
	printStack(stack);
}

 

 

std::vector를 이용해서 stack를 구현해보자.

 

앞 챕터에서 언급했듯이 stack은 점차 쌓이는 구조를 가지고 있다.

 

쌓는 역할은 push_back을 이용하면 되고, 맨 위에 쌓여 있는 요소를 없애는 것은 pop_back을 해주면 된다.

 

stack.push_back(3);을 했더니 stack에는 3만 있고, 그 다음 5를 push_back 했더니 3과 5가 있고, 7를 추가로 push_back 했더니 3 5 7가 쌓여 있는 것을 확인할 수 있다.

 

다음에 pop_back을 통해서 3 5만 남게 되고, 한번 더 진행해주면 3만 남는 것을 확인할 수 있다.

 

이렇게 stack에 대해서는 요소를 추가하거나 빼는 작업을 많이 쓰게 되는데, 이 때 capacity가 미리 확보가 되어 있느냐 아니냐에 따라 효율적으로 작동할지 비효율적으로 작동할지가 결정된다.

 

 

 

예를 들어서, size가 2이고, capacity가 3인 경우라면, std::vector는 현재 물리적으로 3개의 요소를 담을 공간을 차지하고 있는데 2개만 저장하고 있는 경우이다. 이 때 push_back을 해준다면 기존에 할당된 메모리 공간에 요소를 추가해주기만 하면 된다.

 

그런데, size가 3이고, capacity가 3인 경우라면, push_back을 하려고 했을 때 더 이상 공간이 없기 때문에 새로운 메모리 공간을 할당해야 한다. 현재의 capacity보다 더 큰 새로운 메모리 블록을 할당받고, 기존의 모든 요소를 새로 할당된 메모리 블록으로 복사한 다음, 이전 메모리 블록을 해제해야 한다. 

 

즉, new를 이용해서 새로운 메모리를 할당 받고, 기존 데이터를 복사한 다음, 기존 메모리 블록을 delete로 지워야한다.

 

따라서, 미리 std::vector의 capacity를 늘려놓는다면 추후 push_back 작업을 할 때 훨씬 빠르게 진행할 수 있다.

 

단, 미리 capacity를 확보해놓는다는 것은, 메모리를 사용하지 않는데도 미리 잡아두고 있다는 것이기 때문에 메모리가 낭비될 수 있다는 단점은 있다.

 

 

 

Chapter 7.12 재귀적 함수 호출

 

 

이번 챕터에서는 함수를 재귀적으로 호출하는 방법에 대해서 알아본다.

 

 

예제 코드 1

#include <iostream>
using namespace std;

void countDown(int count)
{
	cout << count << endl;

	if (count > 0)
	{
		countDown(count - 1);
	}
}

int main()
{
	countDown(5);
}

 

 

재귀적으로 호출한다는 것의 의미는, 함수 안에서 같은 이름의 함수를 부르는 것을 의미한다.

 

countDown 함수를 보면, 함수 내부에서 countDown 함수를 호출한다는 것을 볼 수 있다.

 

countDown(5);를 호출하면, 먼저 cout에서 5를 출력해주고, 다음으로 countDown(4)로 넘어간다.

 

countDown(4)에서는 cout에서 4를 출력해주고, 다음으로 countDown(3)로 넘어간다.

 

이런식으로 5부터 점점 내려가면서 출력하게 되고, count가 0일 때 if문에 의해서 countDown(-1)을 부르지 않으면서 끝이 난다.

 

재귀 호출을 사용할 때는, if문처럼 정지조건을 만들어줘야만 무한 호출에 빠지지 않는다.

 

 

 

예제 코드 2

#include <iostream>
using namespace std;

int sumTo(int sumto)
{
	if (sumto <= 0)
	{
		return 0;
	}
	else if (sumto <= 1)
		return 1;
	else
	{
		const int sum_minus_one = sumTo(sumto - 1);
		return sumTo(sumto - 1) + sumto;
	}
}

int main()
{
	cout << sumTo(10) << endl;

}

 

 

 

다음은, 이전 예제 코드와 반대로 오히려 숫자를 올라가면서 더하는 경우를 살펴본다.

 

sumTo는 sumto라는 변수가 2보다 크거나 같을 때 sumTo(sumto-1) + sumto;를 실행하는 함수이다.

 

sumTo(10)를 호출하면, sumTo(9) + 10을 return하게 된다.

 

sumTo(9)를 호출하면, sumTo(8) + 9를 return하게 된다.

 

이런 방식으로 쭉 내려가다가, sumTo(0)은 0이고, sumTo(1)는 1이고, sumTo(2)는 sumTo(1) + 2이고....

 

점점 올라가다보면 결국 0부터 10까지 더한 결과가 나오게 된다.

 

이 함수도 if문을 통해서 0보다 크거나 같을 때만 재귀가 되도록 설계되어 있다.

 

 

 

 

예제 코드 3

#include <iostream>
using namespace std;

int fibo(int n)
{
	if (n == 0)
		return 1;
	else if (n == 1)
		return 1;
	else
	{
		return fibo(n - 2) + fibo(n - 1);
	}
}

int main()
{
	cout << fibo(4) << endl;
}

 

마지막으로는 수업 시간에 짜보라고 하셨던 피보나치를 짜본 결과이다.

 

n이 0일 때는 1, n이 1일 때는 1이고, n이 2보다 큰 경우는 fibo(n-2) + fibo(n-1)를 연산하도록 해서 전 값과 전전 값을 더해서 결과를 내도록 만들었다.

 

 

 

Chapter 7.13 방어적 프로그래밍의 개념

 

 

이번 챕터에서는 방어적 프로그래밍이 무엇인지, 왜 필요한지, 어떤 방법으로 하는지에 대해서 알아본다.

 

 

예제 코드 1

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

int main()
{
	int x;
	cin >> x;

	// 논리 오류: 프로그래머가 아니면 알기 어려움. 공통적으로 발생할 수 있는 오류.
	if (x >= 5)
	//if (x > 5)
	{
		cout << "x is greater than 5" << endl;
	}
}

 

첫 번째 케이스는 논리 오류가 발생하는 경우이다.

 

현재 코드에서 x가 5보다 크거나 같을 때, cout에 x는 5보다 크다 라고 출력하도록 해두었는데, 코드 상으로는 x > 5로 변경되어야 하는 경우이다.

 

이런 논리 오류의 경우는 해당 코드를 작성한 사람이 아니면 알기가 어렵다. 어떤 프로그래밍 언어를 사용하더라도 공통적으로 발생할 수 있는 오류이다.

 

 

 

예제 코드 2

 

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

int main()
{
	string hello = "Hello, my name is Jack jack";

	// 1. 문구를 추가해서 범위를 주는 방법
	cout << "Input from 0 to " << hello.size() - 1 << endl;

	// 경험하고 관련이 있다. 실패의 시간이 필요하다.
	while (1)
	{
		int ix;
		cin >> ix;

		if (ix >= 0 && ix <= hello.size() - 1)
		{
			cout << hello[ix] << endl;
			break;
		}
		else
		{
			cout << "Please try again" << endl;
		}
	}
	
}

 

두 번째 예제는 사용자가 만약 코드 작성 시 생각했던 가정을 벗어나 값을 줄 때이다.

 

string은 길이가 현재 정해져 있는 상황인데, 만약 string의 길이를 벗어난 ix 값을 주게 되면, out of range 에러가 발생할 수 있다.

 

일차적으로 Input의 값 범위를 알려주는 문구를 추가하는 방법도 있겠으나, 일반적으로는 효과가 없다고 봐야한다.

 

따라서, if문을 이용해 적절한 값 범위 내에 있는지를 확인한 후 범위 내에 있으면 hello[ix]를 출력하고, 범위 밖으로 나갔다면 다시 입력하도록 만들어서 적절한 범위를 사용자가 입력할 수 있도록 설계해야 한다.

 

이는 프로그래밍 경험하고 관련이 있으며, 여러번 실패를 해보면서 익혀야 된다고 한다.

 

 

 

Chapter 7.14 단언하기 assert

 

 

이 챕터에서는 특정 조건을 만족하지 않으면 프로그램이 꺼지도록 만드는 assert를 사용하는 방법에 대해서 알아본다.

 

 

 

예제 코드 1

#include <iostream>
#include <cassert>
#include <array>

void printValue(const std::array<int, 5> &my_array, const int& ix)
{
	// Runtime에 체크
	assert(ix >= 0);
	assert(ix <= my_array.size() - 1);

	std::cout << my_array[ix] << std::endl;
}

int main()
{
	int number = 5;

	// assert 실행시키는게 연산량을 먹어서, release 모드에서는 오래 걸리니까 실행 안함.
	assert(number == 5);

	std::array<int, 5> my_array{ 1, 2, 3, 4, 5 };

	printValue(my_array, 100);

}

 

assert란, 특정 조건을 만족하지 않으면 runtime에서 에러를 발생시키는 코드이다.

 

위 코드에서, my_array는 5개 요소를 가지고 있는 배열인데, 만약 인덱스를 5 이상으로 입력하면 out of range가 발생하기 때문에, 이를 방지하기 위해서 assert에서 ix <= my_array.size() - 1보다 작은지를 확인하도록 코드가 설계되어 있다.

 

단, assert는 실행시키는 것 자체가 연산량이 들어가기 때문에 release 모드에서는 실행하지 않으며 debug 모드에서만 작동하게 된다.

 

 

 

예제 코드 2

#include <iostream>
#include <cassert>
#include <array>

int main()
{
	const int x = 10;

	assert(x == 5);

	// compile 할 때 확인.
	static_assert(x == 5, "x should be 5");
}

 

앞에서 봤던 assert랑 다르게, static_assert는 runtime에서 확인하는 것이 아니라 compile 할 때 확인하게 된다.

 

따라서 위 코드의 경우 x가 10이기 때문에 static_assert 줄에서 에러가 나게 된다.

 

 

 

static_assert는 특이한 점이, 조건 뒤에 문자열을 추가할 수 있어서 상세한 설명을 덧붙일 수 있다는 점도 있다.

 

 

Chapter 7.15 명령줄 인수 (command line arguments)

 

 

이번 챕터에서는 명령줄 인수라는 기능에 대해서 알아본다.

 

 

예제 코드 1

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

int main(int argc, char *argv[])
{
	cout << "argc: " << argc << endl;

	cout << "argv: " << argv[0] << endl;
}

 

원래 int main()에는 괄호 안에 아무것도 들어가지 않았었다.

 

하지만 명령줄 인수 기능에서는, 괄호 안에 추가로 매개변수를 넣어준다.

 

int argc, char *argv[]를 추가하게 되며, argc는 전달된 인수의 개수를 나타낸다. 항상 최소 1이고, 첫 번째 인수는 항상 프로그램의 실행 파일 이름이다.

 

argv는 인수들을 담고 있는 문자열 배열로, 배열의 각 요소가 하나의 인수를 가리킨다.

 

실제 위 코드를 돌려보게 되면, 

 

argc는 1이고, argv[0]는 해당 코드가 실행되는 위치의 exe파일 경로가 출력되게 된다.

 

 

예제 코드 2

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

// main문 괄호 안에 매개변수를 집어 넣는 법!
// 명령줄 인수
int main(int argc, char *argv[])
{
	cout << "argc: " << argc << endl;

	cout << "argc0: " << argv[0] << endl;
	cout << "argc1: " << argv[1] << endl;

	for (int count = 0; count < argc; ++count)
	{
		std::string argv_single = argv[count];

		cout << count << " " << argv_single << endl;

		if (count == 1)
		{
			int input_number = std::stoi(argv_single);
			cout << input_number + 1 << endl;
		}
		else
		{
			cout << argv_single << endl;
		}
	}
    
}

 

이번에는, 명령줄 인수에 명령 인수를 추가해본다.

 

 

 

현재 작업하고 있는 프로젝트에서 오른쪽 마우스 > 속성을 클릭한다.

 

 

구성 속성 > 디버깅 에서 명령 인수에 1024를 입력해본다.

 

이렇게 명령 인수에 추가로 값을 넣어주면, 아까는 1이였던 argc가 2가 되고, argv의 1번째 인수가 1024가 된다.

 

argv[count]를 std::string으로 바꿔주면 string으로 사용할 수 있고,

 

count == 1일때, 즉 1024인 경우는 std::stoi로 int 값으로 만들어준 뒤, +1이 가능한지 확인하였다.

 

 

사실 이런 기능이 왜 필요한지 이해가 확실히 되지 않아서 gemini한테 물어보니, 이런 경우에 필요하다고 한다.

 

 

 

 

프로그램을 돌릴 때, 설정이나 옵션, 경로 등을 지정해야 하는 경우에 사용하는 것으로 이해하면 될 것 같다.

 

 

 

Chapter 7.16 생략부호 Ellipsis

 

 

이번 챕터에서는 함수의 인자를 가변 인자로 받는 방식인 Ellipsis에 대해서 알아본다.

 

 

 

예제 코드 1

#include <iostream>
#include <cstdarg> // for ellipsis

using namespace std;

// 사용하기 위험하고, 디버깅도 어렵고 초보자에게 사용 권장 X
// 생략부호
double findAverage(int count, ...)
{
	double sum = 0;

	va_list list;

	va_start(list, count);

	for (int arg = 0; arg < count; ++arg)
	{
		sum += va_arg(list, int);
	}

	va_end(list);

	return sum / count;
}

int main()
{
	cout << findAverage(1, 1, 2, 3, "Hello", 'c') << endl;
	cout << findAverage(3, 1, 2, 3) << endl;
	cout << findAverage(5, 1, 2, 3, 4, 5) << endl;
	cout << findAverage(10, 1, 2, 3, 4, 5) << endl;
}

 

원래 함수는 당연히 인자가 명확히 정해져있는데, 위 코드에서는 int count만 명확히 적혀있고, 그 뒤는 ... 로 되어 있다.

 

이렇게, 함수의 인자를 가변적으로 받는 방식이 바로 ellipsis이다.

 

반드시 #include <cstdarg>를 선언해야 한다.

 

 

va_list는 char type을 담는 list로 보이고, va_start는 count를 기준으로 가변 인자 목록을 초기화해준다.

 

그리고 va_arg를 이용해서 다음 인자를 int 타입으로 가져온 다음, va_end에서 가변 인자 목록을 정리한다.

 

 

 

사실 코드에 대한 상세한 내용을 알려주시지 않아서, gemini의 힘을 조금 빌렸다.

 

상세하게 설명해주시지 않은 이유는, 각각이 중요하다기 보다는 이런 문법도 있다 정도만 알려주시려고 했던 것으로 보인다.

 

 

 

사용하기 위험하고, 디버깅도 어려우며 초보자가 사용하기에는 권장하지 않는 방식이라고 한다.

 

 

 

 

이번 챕터도 워낙 분량이 많아서, 내용을 정리하는데 오랜 시간이 걸렸다.

 

다음에는 Chapter 8을 정리해보겠다.

 

 

 

 

 

+ Recent posts