The C++ Programming Language - 추가 (2)

an_yan_yang·2025년 3월 12일

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

  이번 글은 이전 글들에 비해 C++에 대한 실전적인 기술과 내부 동작 원리 등을 다룹니다. C++을 사용하기 위해선 반드시 알아야 하는 항목들이기에 자세히 살펴보기를 ‘강요’드립니다.


가상 함수

클래스를 사용할 때 가장 많이 사용하는 가상 함수부터 알아보도록 하겠습니다.

Virtual Function

  기본적으로 가상 함수는 virtual 키워드를 사용하여 선언된 멤버 함수를 말합니다. 이 virtual 키워드는 동적 바인딩을 지시하는 지시어로 컴파일러에게 함수의 대한 호출 바인딩은 Run Time으로 미루도록 지시합니다. 이 경우 Up-Casting을 하더라도 동적 바인딩이 되기에 클래스에서 Overriding된 함수가 호출됩니다. 또 클래스 내에서 사용되기에 접근 지정자도 자유롭게 지정할 수 있습니다. 단, Derived 클래스와 Base 클래스의 접근 지정을 동일하게 지정해주어야 합니다. 이 바인딩에 대한 이야기는 잠시 후에 더 자세히 알아 보겠습니다.

이것은 알아야한다!

  1. Overriding의 유무에 상관 없이 반드시 소멸자는 virtual로 선언한다.
  2. virtual 키워드는 파생 클래스에도 명시해주는 것이 좋다. (권장)
  3. 상속의 이유 중 하나는 Up-Casting을 이용해서 다형성을 이루어내는 것에 있다.
  4. Up-Casting과 별개로 Down-Casting을 사용하는 코드는 좋은 코드가 아니다.
  5. 함수를 만드는 것은 “알고리즘”, 클래스를 만드는 것은 “디자인”

  다음과 같은 경우에 가상 함수를 사용해야 하는 경우가 발생합니다. Up-Casting을 사용할 때, 자식의 함수가 불려야 하는 경우에 컴파일러가 이를 동적 바인딩이 아닌 정적 바인딩으로 함수를 호출하는 것입니다. 이 경우에도 virtual 키워드를 사용하면 컴파일러가 동적 바인딩을 할 수 있도록 명시적으로 선언할 수 있습니다.

class Base{
	virtual void move(){
		std::cout << "go Base!";
	}
}

class Derived : public Base{
	virtual void move(){
		std::cout << "go Derived!";
	}
}

Pure Virtual Function

  위에서 보면 알 수 있듯이, 클래스를 잘 만들기 위해선 클래스를 어떻게 디자인할 것인가에 대해 고민해볼 필요가 있습니다. 프로그래밍을 하다 보면 상속의 차원에서 반드시 이 기능을 구현해야 한다고 사용자에게 알려 주어야 하는 경우가 발생합니다. 이때, virtual 키워드를 사용하면 본 클래스를 상속받는 클래스를 만들 때, 함수 구현에 대해 강제할 수 있습니다. 하지만 위의 경우와 차이를 두기 위해 순수 가상 함수 라는 개념이 존재합니다. 이에 대한 구현은 아래와 같습니다.

class Base{
	virtual void move() = 0;
}

class Derived : public Base{
	virtual void move(){
		// 이 경우 구현하지 않으면 컴파일 에러 발생
		std::cout << "go Derived!";
	}
}

추상 클래스

위의 순수 가상 함수를 하나라도 가진 경우 이를 추상 클래스 라 칭합니다. 이는 온전한 클래스가 아니므로 객체 생성이 불가능합니다. 이 기능은 자바의 interface와 유사한 기능을 한다고 보면 조금 더 이해하기 쉬울 것이라고 생각합니다. 물론 interface와 마찬가지로 포인터로써 사용은 가능합니다.

목적

  1. 위에서 설명했듯이, 추상 클래스의 인스턴스를 생성할 목적은 아니다.
  2. 상속에서 기본 클래스의 역할을 하기 위해 존재(Interface 역할)
  • 하지만 추상 클래스의 모든 멤버 함수를 모두 순수 가상 함수로 선언할 필요는 없다.
  • 추상 클래스를 상속 받은 파생 클래스에서는 “구체화”의 과정을 갖는다.

가상 소멸자

소멸자를 virtual 키워드로 선언하고, 호출 시 동적 바인딩을 실행하는 것입니다. 만일 가상 함수로 선언하지 않으면 Up-Casting인 경우 Base 클래스의 소멸자만 실행시키는 문제가 발생할 수 있습니다.

호출 순서 (항상 파생이 먼저)

~Base() 소멸자 호출 → ~Derived() 실행 → ~Base() 실행



함수 오버라이딩

Function Overriding

  함수 재정의라고 불리우는 Overriding은 다형성을 실현하기 위해 사용됩니다. 쉽게 설명하자면, 함수를 덮어쓰기 하는 것이라고 생각하면 좋을 것입니다. 이는 기본 클래스의 가상 함수의 존재감을 상실시킬 수 있습니다. 즉, 파생 클래스의 오버라이딩한 함수가 호출되도록 동적 바인딩하게 선언하는 것입니다.

  함수 오버라이딩의 목적은 파생 클래스에서 구현할 함수 인터페이스를 제공하는 것입니다. 위에서 살펴 본 부모 클래스에 가상 함수를 선언하는 것이 파생 클래스에서 재정의할 함수를 알려주는 것이라고 보면 될 것입니다.

성공 조건

  Function Overriding을 성공하려면 조건이 있습니다. Overloading(중복)과 다르게 가상 함수 이름, 매개 변수 타입과 개수, 리턴 타입이 모두 일치해야 한다는 것입니다.


Dynamic Binding

  Binding(바인딩)은 묶는다는 의미를 가지고 있습니다. 크게 컴파일 시점에서 바인딩하는 Static Binding과 런타임 시점에서 바인딩하는 Dynamic Binding으로 나뉩니다. 그 중 우리는 런타임의 바인딩인 동적 바인딩에 중요성을 두어야 할 것입니다. 기본적으로, 파생 클래스에서 기본 클래스에 대한 포인터(Up-Casting)로 가상 함수를 호출하는 경우에 객체 내에 오버라이딩한 파생 클래스의 함수를 찾아서 실행합니다. 또 다른 말로 실행 시간 바인딩, 런타임 바인딩, 늦은 바인딩이라고 불리기도 합니다.


범위 지정 연산자 (::)

  범위 지정 연산자인 :: 를 이용해서 정적 바인딩을 지시할 수 있습니다. BaseClass::VirtualFunc() 의 형태로 기본 클래스의 가상 함수를 정적 바인딩으로 호출할 수 있습니다.



제네릭 프로그래밍

  다형성을 사용하기 위해서, 우리는 함수 중복(Function Overloading)을 많이 사용합니다. 하지만 이의 약점으로 꼽히는 것은 중복 함수의 코드 중복일 것입니다. 즉, 코드는 동일하지만 자료형만 상이한 경우가 다수라는 것이죠. 이를 해결하기 위한 대안책으로 나온 것이 일반적 프로그래밍, 바로 제네릭 프로그래밍입니다.


제네릭(Generic)

  사전적 정의는 일반화를 뜻하는 단어입니다. 함수나 클래스를 일반화시키고, 매개 변수 타입만 따로 지정하여 틀에서 찍어 내듯이 함수나 클래스 코드를 생성하는 기법입니다.


템플릿(Template)

  C++에서는 제네릭 프로그래밍을 실현하기 위해서 함수나 클래스를 일반화하는 C++ 도구입니다. template 키워드로 함수나 클래스를 선언하므로써 사용이 가능합니다. 이 때 일반화를 위한 데이터 타입을 Generic Type(제네릭 타입)이라고 칭합니다. 아래의 예시로 한 번 살펴 보죠.

template <class T> // T는 Generic Type
void mySwap(T & a, T & b){
	T tmp;
	tmp = a;
	a = b;
	b = tmp;
}

이 함수는 Reference를 이용한 Swap 함수입니다. Swap을 할 데이터의 자료형이 정해지지 않은 경우에 이렇게 작성할 수 있습니다. 또한 두 가지 제네릭 타입을 동시에 사용할 수도 있는데 아래 예시를 보겠습니다.

template <class T1, class T2>
void mCopy(T1 src[], T2 dst[], int n) { // src[]의 n개의 원소를 dst[]에 복사
	for(int i = 0; i < n ; i++){
		dst[i] = (T2)src[i] // T1 타입의 값을 T2 타입으로 변환한다.
	}
}

템플릿으로부터의 구체화

  이는 템플릿의 제네릭 타입에 구체적인 타입을 지정하는 것을 말합니다. 보통 컴파일러가 함수의 인자를 보고 이 타입을 지정합니다. 하지만 구체화 중 오류가 발생할 수 있는데, 제네릭 타입의 구체적 타입 지정 시 주의해야 합니다. 제네릭 타입이 하나로만 이루어진 경우 여러 가지 자료형이 동시에 들어가게 되면, 당연히 문제가 발생하게 될 것입니다.


템플릿의 장점과 제네릭 프로그래밍

  대표적인 장점은 함수 코드의 재사용일 것입니다. 이를 통해 높은 소프트웨어 생산성과 유용성을 가질 수 있게 힙니다. 반대로 대표적인 단점은 포팅에 취약하다는 것입니다. 다른 말로, 컴파일러가 지원하지 않을 수도 있다는 것이죠. 더하여 컴파일 오류 메시지가 빈약합니다. 이는 개발자가 디버깅에 어려움을 겪을 수 있다는 것입니다.

사용 시 주의

  템플릿을 사용할 때 주의할 점은 또 있습니다. 템플릿 함수와 이에 중복된 함수가 존재할 때, 반드시 템플릿 함수보다 중복 함수가 우선적으로 불려온다는 말입니다. 이 중복 함수는 템플릿 함수가 아닌 구체화가 이루어진 함수이기에 컴파일러는 이 구체적인 함수를 먼저 불러오는 것을 원칙으로 잡게 됩니다.


제네릭 클래스

  제네릭 클래스, 제네릭 함수.. 자칫 하면 헷갈릴 수 있는 개념들입니다. 이 두 가지 개념을 반드시 구분하여 사용해야 하며, 특히 제네릭 클래스는 제네릭 함수를 선언하는 것과 차이가 조금 있기에 반드시 숙지해야 합니다.

[선언]

template <class T>
class MyStack{
	int top;
	T data[100]; // T 타입의 배열
	
public : 
	MyStack();
	void push(T in);
	T pop();
};

[구현]

template <class T>
void MyStack<T>::push(T in){
	...
}

template <class T>
T MyStack<T>::pop(){
	...
}

[객체 활용]

MyStack<int> iStack; // int 타입을 다루는 스택 객체 생성
MyStack<double> dStack; // double 타입을 다루는 스택 객체 생성

iStack.push(3);
int n = iStack.pop();

dStack.push(3.5);
double d = dStack.pop();

주의 사항

  • 제네릭 타입을 사용하는 “묶음(블럭)”이 있을 때마다 template 키워드를 사용해야 한다.
  • 또한 각각의 객체를 선언할 때마다, 자료형을 명시해주어야 한다.
  • 마찬가지로, 두 개의 제네릭 타입을 가질 수 있다.


  오늘은 실전적인 C++ 클래스 활용을 다루어 보았습니다. 이처럼 다양한 C++의 기술을 이용하면 개발자의 편의성을 고려한 다양한 라이브러리를 개발하고 사용할 수 있습니다. C++의 가장 강력한 기능으로 꼽히는 STL(Standard Template Library)도 이 제네릭 등의 기술을 사용하죠. 다음 시간에는 이 STL에 대해서 다루어보려고 합니다. 긴 글 읽어주셔서 감사합니다.


참고 자료

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

0개의 댓글