[Rookiss C++] 클래스 그 외

황교선·2023년 3월 30일
0

cpp

목록 보기
16/19

const 멤버

const 멤버상수

변수 선언 시 지정한 초기값이 영원히 바뀌지 않아 상수인 변수

const 자료형 변수명 = 초기값;
  • 반드시 초기값을 주어야함
  • 클래스의 생성자에서도 초기화할 수 있음

const 멤버함수

멤버함수 내에서 어떤 멤버변수 값도 변경하지 못하도록 할 때 사용

// 클래스 내에 선언할 때도 const 붙어주어야함
반환형 클래스이름::함수이름(매개변수) const
{ 
    // 문장들;
    // 멤버변수 = 어떤값; // 불가능
    // cout << 멤버변수; // 가능
}

static 멤버

const를 포함하여 static도 변수 글에서 설명하였지만 클래스 내부에서 사용하는 static 멤버는 추가적인 설명이 필요하다.

static 멤버변수

  • 클래스의 모든 인스턴스에 의해 공유됨
  • 정적(static) 멤버 변수에 접근하기 위해서는 해당 클래스명으로 접근해야함
  • 클래스 내부에 선언해야함
  • 클래스 외부에서 초기화해야함

일반 멤버 변수는 각 인스턴스마다 갖고 있기 때문에 메모리에 인스턴스 갯수와 그 인스턴스의 멤버변수 자료형 크기의 합이 차지하게 된다. 하지만 정적 멤버 변수는 모든 인스턴스가 하나의 변수를 공유하는 것이기 때문에 그렇지 않다. 저장되는 메모리 공간은 data 영역이다.

static 멤버함수

  • 객체를 생성하지 않아, 클래스 이름만으로 호출
  • 객체를 생성하지 않아, this 포인터가 없음
  • 객체를 생성하지 않아, 정적 멤버 변수만 사용 가능
class Specialty
{
public:
    void Initialize()
    {
        m_posX = 0;
        m_posY = 0;
        spawnCount++;
    }
    void Print() const; // const 멤버함수면 선언할 때 붙여줘야함
    void Move(int x, int y);
    static void InitSpawnCount();
private:
    bool IsSafePosition(int x, int y);
protected:
    int m_posX;
    int m_posY;
    static int spawnCount; // static 변수인 spawnCount 선언
};

int Specialty::spawnCount = 0; // static 변수인 spawnCount 초기화
void Specialty::InitSpawnCount()
{
    spawnCount = 0;
    cout << "spawnCount Initalized" << endl;
}

void Specialty::Print() const // const 멤버함수로 멤버변수의 값 변경을 막아줌
{
    cout << "Total : " << spawnCount << "\tPos : " << m_posX << ",\t" << m_posY << endl;
}

int main() 
{
    Specialty player1;
    player1.Initialize();
    Specialty player2;
    player2.Initialize();

    player1.Print();
    player2.Print();

    Specialty::InitSpawnCount();
    player1.Print();
    player2.Print();
}
// 출력 결과
// Total : 2       Pos : 0,        0
// Total : 2       Pos : 2,        3
// spawnCount Initalized
// Total : 0       Pos : 0,        0
// Total : 0       Pos : 2,        3

프렌드 함수

일반 함수이며 클래스의 private 변수를 사용할 수 있는 함수

  • 접근하고자하는 private 멤버를 갖는 클래스 내부에 프렌드 함수를 선언
  • 프렌드 함수를 선언할 때는 함수명 앞에 friend 예약어를 붙임
  • 함수의 정의에서는 friend 예약어를 사용하지 않음
  • 데이터 은닉에 위배되므로 예외적인 상황에서만 사용할 것
  • 순서가 중요한 이항 연산자 오버로딩에서 활용하기 좋음
class 클래스이름
{
public: // 어떤 접근 지정자이든 상관 없음
    friend 반환형 프렌드함수명(매개변수);
};

연산자 오버로딩에서 예제를 적도록 하겠다.

함수 오버로딩과 기본 매개변수

함수의 활용과 값 전달이라는 글에서 함수의 오버로딩과 함수의 디폴트 매개변수를 배웠는데, 이는 클래스 내의 멤버함수에도 적용이 되는 개념이다. 그렇기 때문에 따로 설명하지 않는다. 이 둘의 활용은 뒷 개념에서 많이 활용된다. 생성자에서 오버로딩을 많이 사용하고, 기본 매개변수는 멤버함수에 적용할 수 있다. 생성자도 멤버함수이므로 기본 매개변수를 적용할 수 있다.

연산자 오버로딩

적다보니 생성자와 관련된 내용이 좀 많아서, 이후 글에 있는 생성자 개념을 보고 오면 좋을 것 같다.

기본 자료형들은 각 여러 연산에 대해서 정의가 되어 있었기 때문에 사용이 가능했지만, 우리는 사용자 지정 자료형을 만든 것이기 때문에 이 자료형에 연산을 새롭게 정의해야한다. 그래서 연산자 오버로딩이 있다. 연산자를 재정의하여 객체의 연산에 사용하는 것이다.

  • C++에서 함수로 취급됨
  • 함수로 취급되기에 함수와 동일한 방법으로 오버로딩 가능
  • 오버로딩된 연산자를 연산자 함수라고도 함
  • 연산자를 정의할 때 연산에 참여하는 피연산자는 매개변수로 구현
  • 연산자를 정의할 때 매개변수의 자료형에 의해 그 연산자를 사용할 수 있는 자료형이 결정됨

주의사항

  • C++에서 사용하는 연산자만 오버로딩 가능($, # 등 불가능)
  • 연산자를 오버로딩하기 위해선 피연산자가 하나라도 사용자 정의 자료형이어야함
  • 단항 연산자와 이항 연산자 각 형태에 맞춰 피연산자 갯수를 맞춰야함
  • 멤버 선택 연산자(.), 스코프 연산자(::), 조건 연산자(?:), 멤버 포인터 선택 연산자(.*)는 오버로딩 불가능
  • 대입 연산자(=), 함수 호출 연산자(()), 첨자 지정 연산자([]), 객체 포인터 멤버 참조 연산자(->)는 프렌드 함수로 오버로딩 불가능
    • 멤버함수로 가능
    • 그 외는 프렌드 함수로 오버로딩 가능
반환형 operator연산자(매개변수)
{
    // 문장들; // return 포함
}

동적할당과 얕은 복사와 깊은 복사

대입 연산자의 코드를 보기 전에 우선 동적할당과 얕은 복사와 깊은 복사에 대해서 알아보도록 한다.

정적, 전역, 지역 변수들은 각각 생성되는 시점이나 크기가 정해져있다. 정적과 전역 변수는 개수가 변하지 않을 것이고 지역 변수는 코드에서 어떤 함수에서 생성되고 그 함수의 영역이 끝나면 해제되기 때문에 데이터 영역과 스택 영역에 할당되는 메모리의 크기는 컴파일 타임에 결정할 수 있다.

하지만 정해진 크기의 배열이 아닌 내가 원하는 크기만큼 할당하고 싶을 때는 어떻게 해야할까?

이 때 힙 영역을 사용하면 된다. 정해진 크기가 아닌, 프로그램이 실행되는 동안 그 크기를 결정하며 사용자가 직접 관리해야한다. 힙 영역에 변수를 할당하는 것을 동적 할당이라 한다.

동적할당

new, 힙 영역에 할당
delete, 힙 영역에서 해제

자료형 변수이름 = new 자료형;

얕은 복사와 깊은 복사

함수의 주소 전달을 통해서 포인터 변수를 통해 실제 값을 바꾸는 것을 알았다. 이를 다른 시각으로 접근했을 때, 한 포인터 변수와 그 포인터 변수의 주소를 찾아가 있는 값이 있을 때, 다른 포인터 변수에 포인터 값만 넣는다고 하면 어떻게 되는가? 두 포인터 변수의 주소를 각각 찾아가면 같은 주소이므로 결국 하나의 값만이 존재할 것이다. 이처럼 값까지 복사하는 것이 아닌 주소만을 복사하게 되는 것을 얕은 복사라고 한다. 그럼 한 변수의 값을 바꿀 때 다른 변수의 값도 영향을 받게 되는 일이 벌어진다.

아예 주소를 찾아가면 있는 값 자체도 새로 생성하기 위해서는, 즉 깊은 복사를 하기 위해서는 포인터 주소를 일일이 찾아가 복사를 하면 된다. 클래스에서 깊은 복사를 하는 방법은 복사 생성자와 복사 대입 연산자를 사용하는 것이다. 복사 생성자는 생성자 파트에서 설명할 것이고 여기에서는 복사 대입 연산자를 포함한 대입 연산자 오버로딩을 한다.

friend 함수를 이용한 cout << 연산자 오버로딩

우선 다른 연산자 오버로딩을 편하게 보기 위해 cout 출력 연산자 오버로딩부터 보도록 하겠다. cout 으로 객체를 출력하기 위해서는 << 연산자를 오버로딩해야한다. 하지만 멤버함수로는 오버로딩할 수 없다. 연산자는 함수처럼 작동하기 때문에 a op b라면 a.op(b) 와 같이 호출되는 것이다. 그렇다면 왼쪽 피연산자에 연산자가 오버로딩되어 있어야하는데 우리는 현재 cout << 객체처럼 이용할 것이니 cout.<<(객체) 형식이 되어버린다. cout 객체의 클래스에 들어가 멤버함수를 추가할 수 있지만 기존에 잘 사용하는 클래스를 수정하는 것은 좋지 않은 행동이기에 우리는 일반 함수로 만들어야하는 것이다. 하지만 출력하고 싶은 것은 외부에서 접근하지 못하는 private 멤버변수이다. 그래서 friend 키워드를 붙여주어 일반 함수도 private 멤버변수에 접근할 수 있게 한다.

friend ostream& operator<<(ostream& os, const 클래스& temp);
ostream& operator<<(ostream& os, const 클래스& temp)
{
    // cout << 출력하고 싶은 것;
    return os;
}

class NewString
{
public:
    // 생성자와 나머지 멤버함수 생략
    friend ostream& operator<<(ostream& os, const NewString& right); // << 연산자 오버로딩
private:
    char* m_name;
    int m_nameLen;
};

ostream& operator<<(ostream& os, const NewString& temp) // << 연산자 오버로딩
{
    cout << temp.m_name << " " << &(temp.m_name) << " " << (int*)(temp.m_name);
    // 담고 있는 내용 / 포인터 변수의 주소 / 포인터 변수가 담고 있는 주소값
    return os;
}

int main()
{
    NewString lion("lion");
    NewString cat = lion; // lion 문자열이 복사됨
    NewString tiger(lion); // lion 문자열이 복사됨

    cout << lion << endl; // 오버로딩한 << 연산자를 사용함
    cout << cat << endl; // 오버로딩한 << 연산자를 사용함
    cout << tiger << endl; // 오버로딩한 << 연산자를 사용함
}
// 출력결과
// lion 0x16bc2f250 0x156606a70
// lion 0x16bc2f230 0x156606a80
// lion 0x16bc2f220 0x156606a90

일반 함수를 보면 cout을 통해서 클래스 멤버 중 원하는 것인 m_name 변수에 대한 것을 출력하는 것을 볼 수 있다. 주석에도 적어두었지만 차례대로 얘기를 해보면 담고 있는 내용, 포인터 변수의 주소, 포인터 변수가 담고 있는 주소값이다. 주소값 두 가지를 출력하는 이유는 대입 연산자의 유무에 따라 대입했을 때의 차이를 보기 위해 적어두었다.

출력 결과를 보면 lion, cat, tiger 세 객체의 문자열이 lion으로 다 같은 것을 볼 수 있다.

대입 연산자 오버로딩

클래스& operator=(const 클래스& arg); // 복사 대입 연산자, 다른 객체를 대입할 때
클래스& operator=(int arg); // 대입 연산자, 객체를 대입하는 것이 아닌 정수를 대입할 수 있다고 만들 때

우선 반환형이 참조 객체인 이유는 체인 대입(chain of assignment)에 대응하기 위해서이다.

매개변수로 const를 붙여주는 이유는 대입만을 위한 것이기 때문에 매개변수로 들어오는 참조 변수를 건들지말라는 의미로 해석하면되고, 참조(&) 변수인 이유는 메모리의 효율성을 올리기 위해서이다. 역순으로 다시 설명하자면 메모리의 효율성을 올리기 위해 참조 변수를 선언했고, 그 참조 변수를 건들이면 안 되기 때문에 const 키워드를 붙였다.

&를 쓰지 않는다면 매개변수에 들어가는 변수는 지역 변수로 매개변수에 값을 집어넣을 때 복사 생성자가 호출된다.

  • 선언과 동시에 대입 연산을 하면 복사 생성자 호출
  • 매개변수를 참조 변수로 쓰지 않으면 매개변수로 복사 생성자 호출
class NewString
{
public:
    // 생성자와 나머지 멤버함수 생략
    NewString& operator=(const NewString& right) // 복사 대입 연산자
    {
        if(this == &right)
        {
            return *this;
        }

        delete []m_name;
        m_nameLen = right.m_nameLen;
        m_name = new char[m_nameLen];
        strcpy(m_name, right.m_name);
        return *this;
    }
    NewString& operator=(const char* const charArr) // 대입 연산자
    {
        delete []m_name;
        m_nameLen = strlen(charArr) + 1;
        m_name = new char[m_nameLen];
        strcpy(m_name, charArr);
        return *this;
    }
private:
    char* m_name;
    int m_nameLen;
};

int main()
{
    NewString lion("lion"); // 멤버변수로 lion 이라는 char*가 만들어짐
    NewString tiger;
    tiger = lion; // 복사 대입 연산자
}

첫 번째 멤버함수가 복사 대입 연산자이고 두 번째 멤버함수가 대입 연산자이다. 둘의 차이점은 매개변수로 받는 자료형이 같은 타입이라는 점과 다른 타입이라는 점이다.

복사 대입 생성자의 코드는 다음과 같다. 기존에 할당되어 있는 문자배열을 할당해제하고, 매개변수에 있는 객체의 내용을 복사하기위해 그 크기만큼 자신의 멤버변수에 동적할당한 후 strcpy 함수로 문자열을 자신의 멤버변수에 복사한다.

tiger = lion; 이라는 문장에서 복사 대입 연산자가 호출이 되고, 새로운 문자열을 만들어서 이를 tiger 객체에 집어넣는다.

현재는 복사 대입 연산자로 깊은 복사를 직접적으로 수행했지만, 만약 복사 대입 연산자를 오버로딩하지 않고 컴파일러가 만드는 복사 대입 연산자로 얕은 복사를 하면 어떻게 되는지 보자.

int main()
{
    NewString lion("lion");
    NewString tiger;
    tiger = lion; // 복사 대입 연산자
    
    cout << lion << endl;
    cout << tiger << endl;
    tiger[0] = 'd'; // tiger의 첫 문자만 수정, [] 연산자 오버로딩해서 사용한 것
    cout << lion << endl;
    cout << tiger << endl;
}
// 출력 결과
// lion 0x16b327260 0x11d606b00
// lion 0x16b327250 0x11d606b00
// dion 0x16b327260 0x11d606b00
// dion 0x16b327250 0x11d606b00

오버로딩한 복사 대입 연산자를 주석 처리한 후 tiger의 값을 변경하고 출력하는 코드를 추가하였다. 출력 결과의 맨 마지막 두 줄이 각각 lion, tiger 값인데, lion 객체는 변경하지 않았지만 변경된 tiger의 값과 같은 것을 수 있다. 위의 ‘얕은 복사와 깊은 복사’ 소제목에서 설명한 것처럼 값 자체를 복사한 것이 아닌 주소만을 복사하였기 때문이다.

증감 연산자 오버로딩

증감 연산자는 전위와 후위로 총 두 가지가 있고 단항 연산이지만 후위 연산자는 매개변수로 int를 넣어주면 된다.

반환형& operator++(); // 전위 증가 연산자
반환형& operator--(); // 전위 감소 연산자
반환형 operator++(int value); // 후위 증가 연산자
반환형 operator--(int value); // 후위 증가 연산자

전위 증감 연산자의 반환형으로 참조 변수인 것은 우선 이 연산자가 먼저 계산이 된 후의 그 값을 넘겨주는 것이기 때문에 변경된 현재 값을 넘겨주어도 되기 때문이다. 반면에 후위 증감 연산자는 모든 계산을 마친 후에 증감이 된 것처럼 행해져야하기 때문에 변경되기 전 값을 담은 임시 변수를 건네준다. 후위 증감 연산자가 끝날 때에는 실제 객체의 값이 변경되어 있는 상태이기 때문에 이 값을 주지는 못하기 때문이다.

class NewString
{
public:
    // 생성자와 나머지 멤버함수 생략
    NewString& operator++() // 전위 증가 연산자, ++T
    {
        char* tempCharArr = m_name;

        m_nameLen++;
        m_name = new char[m_nameLen];
        strcpy(m_name, tempCharArr);
        strcat(m_name, "!");
        delete[] tempCharArr;

        return *this;
    }
    NewString operator--(int value) // 후위 감소 연산자, T--
    {
        NewString temp = *this;

        if (m_nameLen == 1) // 간단한 예외처리
        {
            return temp;
        }
        m_nameLen--;
        m_name[m_nameLen - 1] = '\0';
        char* tempCharArr = m_name;
        m_name = new char[m_nameLen];
        strcpy(m_name, tempCharArr);
        delete[] tempCharArr;

        return temp;
    }
private:
    char* m_name;
    int m_nameLen;
};

int main()
{
    NewString lion("lion"); // char* 생성자

    cout << lion << endl;
    cout << ++lion << endl;
    cout << lion-- << endl;
    cout << lion << endl;
}
// 출력 결과
// lion 0x16b24b260 0x123f04100
// lion! 0x16b24b260 0x123f04110
// lion! 0x16b24b240 0x123f04100
// lion 0x16b24b260 0x123f04120

우선 증가 연산자부터 보도록 한다. 기존 포인터를 우선 보관해둔 후, 문자열의 길이를 하나 늘리고 그만큼 동적할당하여 m_name에다 포인터를 저장한다. 임시 보관해둔 기존 문자열을 새로 할당한 m_name이 갖고 있는 주소에 복사한 후, 뒤에 !를 붙인다. 임시 저장한 이전 문자열은 동적해제 한 후 현재 객체를 참조 반환한다.

후위 감소 연산자도 크게 다르지 않다. 증가 부분이 감소가 된 것 뿐이고, 길이 자체가 1 미만으로 떨어지면 안 되기 때문에 예외처리를 해주었다. 마지막으로 후위 연산이기 때문에 변경된 값이 아닌 변경되기 전 값을 주어야하므로 맨 처음에 temp 객체를 만들어 맨 처음 값을 복사해주었다. 그리고 이를 반환한다.

const 참조 객체와 지역 변수인 객체

반환형 함수이름(const 클래스& 객체이름)

위와 같이 객체를 참조하지만 변경하지 않겠다라는 형식으로 매개변수를 만들었을 때 지역 변수 자체를 이에 대응시킬 수 있게 된다. 그래서 후위 감소 연산자에서 반환된 지역 변수가 cout <<와 활용되어도 잘 적용되는 것을 볼 수 있다.

객체 포인터

이전에 구조체를 가지고 구조체 포인터를 만들어서 실습을 했었다. 구조체도 사용자 지정 자료형이고 클래스도 사용자 지정 자료형이기에 포인터를 사용하여 다룰 수 있다.

클래스* 객체이름;
int main()
{
    Specialty player1;
    player1.Initialize();

    Specialty* ptr = &player1;
    ptr->Move(1,2);
    ptr->Print();
}
// 출력 결과
// Total : 1       Pos : 1,        2

객체 배열

포인터를 만들 수 있는 것처럼 배열 또한 사용이 가능하다.

profile
성장과 성공, 그 사이 어딘가

0개의 댓글