The C++ Programming Language - 클래스 심화(2)

an_yan_yang·2025년 2월 20일
post-thumbnail

본 글을 개인적 학습을 위한 글입니다. 틀린 내용이 있을 시 마구 지적해주시면 감사합니다.

 이번 글에서는 지난 시간에 다루었던 클래스 활용 심화에 대한 내용을 이어서 알아보겠습니다. 오늘 내용은 특히 더 중요하기 때문에 글을 자세히 읽어보시는 것을 추천드립니다.


Static

  객체 지향의 목표라 함은 전역 변수와 전역 함수를 사용하지 않는다에 있습니다. 그룹 개발이 주가 되는 현재에서 타인의 코드와 묶이면 오류가 날 확률이 올라가기 때문입니다. 이 목적을 달성하기 위해서는 캡슐화에 중점을 두고 지키기 위해 노력해야 합니다.

  하지만, 전역 변수와 전역 함수를 어쩔 수 없이 사용해야 하는 경우가 발생합니다. 그래서 우리는 이를 위해 static 키워드를 사용합니다.


static 멤버와 non-static 멤버의 특성

  • static
    • 변수와 함수에 대한 기억 부류의 한 종류
      • 생명 주기 - 프로그램이 시작될 때 생성, 프로그램이 종료 시 소멸
      • 사용 범위 - 선언된 범위, 접근 지정에 따름
  • 클래스의 멤버
    • static 멤버
      • 프로그램이 시작할 때 생성
      • 클래스 당 하나만 생성, 클래스 멤버라고 불림
      • 클래스의 모든 Instance들이 공유하는 멤버
    • non-static 멤버
      • 객체가 생성될 때 함께 생성

      • 객체마다 객체 내에 함께 생성

      • instance 멤버라고 불림


static 멤버 선언

  • 전역 변수로 생성
  • 전체 프로그램 내에 한 번만 생성
  • static 멤버 변수에 대한 외부 선언이 없으면 링크 오류 발생

static 멤버는 하나만 생성되고 모든 객체들에 의해 공유된다.
→ 당연한 말이지만 가장 중요한 핵심입니다.


static 멤버 사용

  • 객체의 이름이나 객체 포인터로 접근
  • 클래스 명과 범위 지정 연산자(::)로 접근 → static 멤버는 클래스마다 오직 한 개만 생성되기에 가능

static 변수는 웬만하면 클래스 이름을 통해서 접근하자.

완전히 전역 변수와 동일한 역할을 하기 때문에, 위 방법을 권장한다. 또한 초기화는 전역 범위에서 해야 한다.

MyClass{
 static int staticNum;
}

int MyClass::staticNum = 0; // 전역 범위에서 초기화

int main(){
	MyClass a;
	cout << a.staticNum; // 객체를 통한 접근
	cout << MyClass::staticNum; // 클래스 이름과 접근 지정 연산자를 통한 접근
}

static의 활용

  • static의 주요 활용
    • 전역 변수나 전역 함수를 클래스에 캡슐화
      • 전역 변수나 전역 함수를 가능한 사용하지 않도록 함
      • 전역 변수나 전역 함수를 static으로 선언하여 클래스 멤버로 선언
    • 객체 사이에 공유 변수를 만들고자 할 때
      • static 멤버를 선언하여 모든 객체들이 공유

static 멤버 함수가 접근할 수 있는 것

  • static 멤버 함수
  • static 멤버 변수
  • 함수 내의 지역 변수

⇒ static 함수는 전역 함수와 동일하기 때문

!Important

  만일 소스 코드가 여러 개이고 클래스를 헤더 파일을 통해 받는다면, static 변수를 반드시 소스코드에서 초기화 시켜야 합니다. 헤더 파일에서 초기화한다면, 링킹 중 중복 초기화되어 컴파일 오류가 발생할 수 있기 때문입니다.

  또한 static은 접근 지정자의 영향을 받습니다. 보호하고 싶다면, getter나 setter 함수를 생성하여 이용할 수 있도록 합니다.



Friend 키워드


C++ Friend

  이 “friend” 키워드는 클래스의 멤버 함수가 아닌 외부 함수를 사용하고 싶을 때 작성합니다. 여기서 외부 함수란 전역 함수나 다른 클래스의 멤버 함수를 일컫습니다. 사실 friend 대신 static을 활용해도 좋습니다. 그러나 번거로운 점이 존재할 수 있습니다. 예를 들어, main()에서 함수를 사용할 때 앞에 클래스 명을 적어주어야 하는 등의 번거로운 문제가 생길 수 있습니다. 물론 이것이 나쁘다는 것은 아닙니다. 하지만 이외에도 friend 키워드를 통해 클래스의 멤버로 선언하기에는 무리가 있지만 클래스의 모든 멤버를 자유롭게 접근할 수 있는 외부 함수를 구현하거나 동시에 여러 가지 클래스에 접근할 수 있는 함수를 구현할 수 있다는 것에 이점이 더 많다고 할 수 있습니다.

  • friend 키워드로 클래스 내에 선언된 함수
    • 클래스의 모든 멤버를 접근할 수 있는 권한 부여
    • friend 함수라고 불리움
  • friend로 초대하는 3가지 유형
    • 전역 함수 : 클래스 외부에 선언된 전역 함수
    • 다른 클래스의 멤버 함수 : 다른 클래스의 특정 함수
    • 다른 클래스 전체 : 다른 클래스의 모든 멤버 함수
class Person {
private:
    string name;
    int age;

public:
    Person(string n, int a) : name(n), age(a) {}

    // friend 함수 선언
    friend void showPersonInfo(const Person& p);
};

// friend 함수 정의 (Person의 private 멤버 접근 가능)
void showPersonInfo(const Person& p) {
    cout << "이름: " << p.name << ", 나이: " << p.age << endl;
}

int main() {
    Person person("Alice", 25);
    showPersonInfo(person); // private 멤버에 접근 가능

    return 0;
}


About Operator


연산자 중복 (Operator Overloading)

  연산자 중복은 사실 C를 해보았던 사람이라면 생소하게 들릴 것입니다. 함수가 중복되는 것은 알겠지만, 연산자가 중복된다는 것은 사실 받아들이기 쉽지 않을 수 있죠. 어찌보면 연산자도 함수로써 볼 수 있다는 것이 C++이 가지는 큰 장점 중 하나입니다. 이는 본래부터 있던 연산자에 새로운 의미를 정의하여, 높은 프로그램 가독성을 확보함과 동시에 다형성을 쟁취할 수 있는 강력한 기능입니다.

  • 특징
    • C++에 본래 있는 연산자만 중복 가능
    • 피 연산자(operand) 타입이 다른 새로운 연산 정의
    • 연산자는 함수 형태로 구현 - 연산자 함수(Operator Function)
    • 반드시 클래스와 관계를 가짐
    • 피 연산자의 개수를 바꿀 수는 없음
    • 연산의 우선 순위 변경 안됨
    • 모든 연산자가 중복 가능하지는 않음


연산자 함수

연산자 중복에 대해서 조금 더 자세히 이해하려면 연산자에 대한 내용을 필수적으로 다루어야 할 것 같습니다.

Operator 종류

  • Unary Operator : Operand가 1개인 경우
  • Binary Operator : Operand가 2개인 경우
  • Trinary Operator : Operand가 3개인 경우

 그렇다면 연산자 중복은 어떤 방식으로 일어나느냐를 묻는다면, 연산자 함수를 이용한다고 할 수 있습니다. Overload하고 싶은 연산자의 함수 외부에 선언하고, 이를 클래스에 friend 함수로 선언하는 것이죠. 혹은 클래스의 멤버 함수로 구현하는 방법도 존재합니다. 연산자를 Overload할 때는 어떤 방법도 상관 없지만, 둘 중 한 가지 방법만 택해서 해야 합니다. 이는 연산자가 중복되어 자칫해서 컴파일러가 바인딩을 못할 수 있기 때문이죠. 맞습니다. 바로 모호성이 발생한다는 것입니다.

그러나, 전역 함수 보다는 멤버 함수를 추천합니다.

  C++의 궁극적 목적 중 하나인 캡슐화를 위해서죠. 무조건 전역 함수가 안 좋은 것이라고 하는 것은 아닙니다. 가급적으로 피하는 것을 권장한다 수준입니다. 하지만 전역 함수를 사용해야만 하는 경우가 존재합니다.

  예를 들어, ‘+’ 연산의 경우에는 멤버 함수로 구현하기에는 한계가 존재합니다. Binary Operator같은 경우 오른쪽 인자만을 받기 때문에, 상수가 왼쪽에 오는 경우에는 제작이 불가하기 때문입니다. 즉, 왼쪽에 오는 operand가 사용자 정의 타입이 아닐 때, 문제가 발생하는 것입니다. 이럴 때, 전역 함수를 사용해서 구현합니다.

class Number {
public:
    int value;

    Number(int v) : value(v) {}

    // 멤버 함수로 구현하는 경우 (오류 발생)
    Number operator+(int num) const {
        return Number(value + num);
    }
  
	  // 오류 해결
    // 전역 함수로 operator+ 오버로딩 (좌항이 int일 수도 있음)
    friend Number operator+(int num, const Number& n) {
        return Number(num + n.value);
    }

    friend Number operator+(const Number& n, int num) {
        return Number(n.value + num);
    }
};

int main() {
    Number n(10);

    cout << (5 + n).value << endl; // 오류 발생! (멤버 함수로는 5 + n 불가능)

    return 0;
}

Overloading the assignment operator vs Copy Constructor

  연산자 함수 중복에서 가장 중요한 부분이라고 이야기할 수 있는 파트입니다. 바로 대입 연산자를 Overload하는 것입니다. 먼저 대입 연산자와 복사 연산자를 구분해야 합니다.

  먼저 복사 연산자는 사실 상 복사 생성자를 부르는 것을 뜻합니다. 대입 연산자 기호인 ‘=’를 사용하지만 새로운 객체가 초기화될 때, 즉 새로운 메모리가 잡히고 이를 동일한 타입인 객체로 초기화할 때 복사 연산자로 취급됩니다.

  반면에, 대입 연산자는 이미 존재하는 객체를 동일 타입 다른 객체로 초기화(대입)시킬 때 사용되는 연산자입니다. 아래 코드를 보면 둘의 차이를 확인할 수 있습니다.

MyClass{
...

	MyClass& operator=(const MyClass & other){ ... }
}

int main(void){
	MyClass a;
	MyClass b = a; // 복사 연산
	
	a = b; // 대입 연산
}

  대입 연산자를 Overload할 때는 주의할 점이 있습니다. 첫째로 복사 생성자와 동일하게 동적 메모리가 존재하는 경우입니다. 그저 얕은 복사를 하게 되면, 동적 할당된 메모리를 피복사 객체와 공유하게 되는 문제가 발생할 수 있습니다. 따라서 깊은 복사를 구현하면 됩니다. 둘째로 자기 자신을 복사하는 것입니다. 만일 자기 자신을 복사하게 된다면, 불필요한 연산은 물론 새로운 메모리 할당으로 인해 메모리 누수가 발생할 수 있습니다. 그렇기에 대입 연산자를 구현할 때 자기 자신을 복사하는 경우를 제한하는 것을 추천합니다.

  그런데, “어, 저는 객체 여러 개가 동일한 메모리를 공유하게 하고 싶어요!” 라고 할 수 있습니다. 그래서 대입 연산이나 복사 연산을 얕은 복사로 구현하는 경우가 생길 수 있게 되는 것입니다. 하지만 절대로 하면 안됩니다. 절대로. 왜냐하면 만일 객체를 삭제하는 경우 이미 해제된 메모리가 다른 객체에서 다시 할당 해제되면 더블 삭제(Double Delete)가 발생하게 됩니다. 이는 아주 취약한 코드를 만드는 것이므로, 만일 객체 여러 개가 동일한 메모리를 공유하게 하려면 static을 사용하는 것을 추천합니다.


스트림 연산자(Stream Operator)

언어 공부를 할 때 가장 먼저 배우는 것인 입출력을 C++에서도 해보셨을 겁니다. 다음과 같은 형식으로 C++에서 입출력을 할 것이죠.

std::cout << "Hello, World"; // 출력
std::cin >> num; // 입력

  C에서는 printf()나 scanf()라는 함수를 이용해서 입출력을 구현했지만, C++은 그 형식이 다름을 알 수 있습니다. 이때 사용되는 “<<”, “>>”가 바로 연산자입니다. C에서는 비트 연산으로 사용되었지만, C++에서는 스트림 연산자로 불리우며, 입출력을 담당하는 연산자로 사용됩니다. 추가적으로 cout과 cin도 함수가 아닌 “변수”임을 확인할 수 있을 것입니다. 당연히 스트림 연산자도 연산자이기에 Overloading이 가능합니다. 굳이 왜 헤야 하나라는 생각을 가질 수 있습니다. 하지만 역시나 편리성 덕분이겠죠. 다음과 같이 사용자 정의 타입에서 구현 가능합니다.

ostream operator<< (ostream& o, const MyVec3& in){ // 반환 타입은 ostream이라는 클래스
	o << "(" << in.x << " ," << in.y << " ," << in.z << ")" << endl;
	
	return o;
}


상속(Inheritance)


C++에서의 상속

  상속은 C에는 존재하지 않는 클래스 간의 관계를 정의하는 기능입니다. 주의할 점은 객체 사이에 존재하는 관계가 아닌 클래스 사이에서의 관계를 정의한다는 점입니다. 상속의 기본적인 기능은 기본 클래스의 속성과 기능을 파생 클래스에 물려주는 것입니다. 기본 클래스에서 파생 클래스로 갈수록 클래스의 개념이 구체화됩니다. 또한 C++에서는 다중 상속을 지원합니다. 이 다중 상속을 통한 클래스의 재활용성을 높일 수 있습니다. 물론 추천하지는 않지만요. 이는 이후에 자세히 다뤄보겠습니다.

  • 기본 클래스(Base Class) : 상속해주는 클래스 (부모 클래스)
  • 파생 클래스(Derived Class) : 상속받는 클래스 (자식 클래스)
class B : public A { // A를 상속 받는 B, 이 때 상속하는 클래스를 public으로 작성해야 함
 ...
}

상속의 목적 및 장점

  상속을 통해 얻을 수 있는 장점들은 아주 많습니다. 그만큼 강력한 기능인 것이죠. 그 중 대표적인 3가지를 알아보겠습니다.

  1. 간결한 클래스 작성
    • 기본 클래스의 기능을 물려 받아 파생 클래스를 간결하게 작성
  2. 클래스 간의 계층적 분류 및 관리의 용이함
    • 상속은 클래스들의 구조적 관계 파악 용이
  3. 클래스 재사용과 확장을 통한 소프트웨어 생산성 향상
    • 빠른 소프트웨어 생산 필요
    • 기존에 작성한 클래스의 재사용
    • 앞으로 있을 상속에 대비한 클래스의 객체 지향적 설계 필요

상속의 다른 말은 “구체화”


protected 접근 지정자

  이전 글에서 얘기했듯이, protected 키워드를 이용해서 접근 지정을 행할 수 있습니다. 이는 자기 자신과 파생된 클래스에서 멤버를 접근할 수 있도록 하는 것이 주 목적인 키워드입니다. 그 외의 클래스는 멤버에 대한 접근 권한을 가지지 않습니다.


상속 관계에서 생성자와 소멸자 실행

  기본 클래스와 파생 클래스 모두 클래스의 생성자가 존재합니다. 하지만 이 생성자가 어떤 것이 어떻게 불리우는지에 대해서 알아야 정확하게 상속을 사용할 수 있습니다. 먼저 파생 클래스의 객체가 생성될 때, 파생 클래스와 기본 클래스의 생성자가 모두 실행됩니다. 순서는 기본 클래스의 생성자가 먼저 실행 후, 파생 클래스의 생성자가 실행됩니다. 반대로 소멸자는 파생 클래스의 소멸자가 먼저 실행된 후, 기본 클래스의 소멸자가 실행됩니다. 즉, 생성과 소멸이 반대로 진행된다는 원칙에 부합하는 것이죠.

  하지만 주의할 점이 있습니다. 만일 파생 클래스의 생성자가 명시적으로 작성되지 않았다면, 묵시적으로 기본 클래스의 기본 생성자를 호출합니다. 여기서 포인트는 기본 클래스의 기본 생성자를 호출한다는 것입니다. 그렇다는 것은 당연히 기본 생성자가 존재하지 않으면 컴파일 오류가 발생하게 된다는 것입니다. 이를 해결하는 방법은 여러 가지입니다. 아래를 한 번 살펴 보시죠.

  • 기본 클래스의 기본 생성자를 만들기
  • 파생 클래스의 기본 생성자를 만들기
  • 둘 다 아니라면, 명시적으로 매개변수를 포함하는 생성자를 호출하기
class B : public A {
 public :
  B(){ // A()를 호출하도록 컴파일
    cout << "생성자 B" << endl;
  }
  
  B(int x) : A(x+3){ // A(x+3)을 호출하도록 컴파일
   cout << "매개변수 생성자 B" << x << endl;
  }
}

정리하자면,

부모 생성자를 명시적으로 부르지 않는다면, 자식 생성자는 항상 부모의 default 생성자를 불러온다. 이것은 생성자가 생성자를 불러올 수 있기 때문에 가능한 것이다. 그렇기에 부모 생성자를 명시적으로 부르지 않는다면 반드시 부모의 default 생성자를 선언해야 한다.


다중 상속

class MusicPhone : public MP3, public MobilePhone { // 다중 상속 선언
	public :
		void dial();
}

  위와 같은 경우를 다중 상속이라고 합니다. 당연히 다형성 측면에서는 이득을 얻을 수 있게 되는 설계 방식입니다. 하지만 문제가 될 수 있는 부분이 많아, 보통 추천하지는 않는 방법입니다. 자바 등의 언어에서도 같은 이유로 지원하지 않는 것으로 알려져 있습니다. 가장 큰 문제점은 기본 클래스 멤버의 중복 상속입니다. 즉, 서로 다른 기본 클래스의 멤버끼리 중복될 수 있다는 것입니다. 이렇게 되면 컴파일 타임에서 컴파일러가 바인딩 하는 과정에서 호출할 멤버를 결정하지 못하는 문제가 발생할 수 있습니다. 말 그대로 모호성이 발생하게 되는 것이죠.


상속과 포인터 캐스팅

  포인터 캐스팅이라는 말이 아마 잘 와닿지는 않을 것입니다. 타입 캐스팅은 많이 들어보셨어도 말이죠. 포인터는 어떤 데이터 타입이던 간에 4바이트의 메모리 공간을 한다는 특징이 있습니다.

int * p; // 4바이트
void * pp; // 4바이트
MyClass * ppp; // 4바이트

  이렇기에 캐스팅 즉, 타입 변환에 있어서 나름 자유롭다는 특징이 있습니다. 상속에서 활용할 수 있는 이 포인터 캐스팅은 업캐스팅(Up-Casting)과 다운캐스팅(Down-Casting)이 있습니다. 이 캐스팅들을 보통 다른 캐스팅과 다르게 “치환”이라는 개념을 생각하면 편할 것 같습니다. 상속과 캐스팅을 활용하면 데이터 관리, 다형성 등 아주 많은 이점을 취할 수 있습니다. 당연히 편리성에 가장 큰 기반을 하고 있죠. 각각의 특징에 대해 아래에서 알아 보겠습니다.

  • Up-Casting
    • 파생 클래스의 포인터가 기본 클래스 포인터에 치환되는 것
    • 포인터 뿐만 아니라 레퍼런스를 이용해서도 가능
BaseClass * A;
DerivedClass * B = new DerviedClass();

A = B;
  • Down-Casting
    • 기본 클래스 포인터가 파생 클래스의 포인터에 치환되는 것
BaseClass * A = new BaseClass();
DerivedClass * B;

B = A;

// 사실 위의 코드는 안됩니다.

→ 하지만, 다운 캐스팅은 그냥은 불가능합니다. 역시나 예외가 존재합니다.

→ 업캐스팅이 되었을 경우 + 내가 가능하다고 할 때(개발자의 우기기) + 강제형 변환을 하면 가능합니다.

BaseClass * A;
DerivedClass * B = new DerviedClass();

A = B;

DerivedClass * C;
// Down-Casting
C = (DerivedClass *)A;

위의 경우에만 가능하다고 할 수 있습니다. 오류가 발생해도 개발자가 그 책임을 다 지어야 하기 때문에, 저희 교수님께서는 “그냥 쓰지마 친구야”라는 농담을 하시기도 했습니다.

사용할거면, Up-Casting만 사용하도록 하자.

사실 이 포인터 캐스팅을 사용하는 법과 원리에 대해서 알려면, 바인딩(Binding)에 대해서 알 필요가 있습니다. 이 관련 내용은 다음 글에서 다루어 보도록 하겠습니다.



  오늘은 클래스 활용 심화에 대해서 나머지 부분을 다루어 보았습니다. 다음 시간에는 미처 다루지 못했던 더 심화적인 내용들을 알아보겠습니다.


참고 자료

profile
개발자가 되고 싶은 공대생

0개의 댓글