본문 바로가기
PM으로 성장하기/개발 공부

[컴과] C++ 언어: 개요, 구성요소, 상속, 템플릿, 예외처리

by 고양이 고씨 2022. 11. 20.

C++ 언어 소개

 

1. C의 확장, 그러나 별개의 언어

C++는 C언어를 확장하여 만든 언어로, 객체지향 개념(클래스)과 일반화 프로그래밍(템플릿)을 추가로 지원한다.

나무위키에 따르면, C++의 객체지향은 다른 객체지향과는 성격이 다르다고 한다. C++에서는 많은 것을 컴파일 타임에 처리하고 메모리를 프로그래머가 직접 관리하는 반면, 다른 언어에서는 많은 부분을 런타임에 처리하고 메모리를 자동으로 관리한다고 한다.

또한 C++에서는 일반화 프로그래밍의 개념으로 템플릿이라는 개념을 사용한다. 일반화 프로그래밍이란 공통된 개념을 묶어 일반화함으로써, 여러 데이터 형식에 맞추어 동작할 수 있도록 한다. 일반화 프로그래밍으로 코드의 생산성을 높일 수 있다.

 

2. 프로그램의 작성 및 빌드 방식

C++ 프로그램을 작성할 때에는 헤더 파일(.h)과 소스 프로그램 파일(.cpp)을 만들게 된다. 헤더 파일에는 클래스, 함수의 원형, 전역변수 등 여러 소스 파일에 공통적으로 선언되는 내용을 담는다. 소스 프로그램 파일에는 앞서 작성한 헤더 파일을 #incldue하고, 처리하고 싶은 명령어들을 담는다. C++는 컴파일러를 사용하기 때문에 컴파일을 하면 목적파일로 변환되고, 이 목적파일은 라이브러리 등과 결합(링크)하여 실행 프로그램으로 바뀐다.


C++ 언어의 구성요소

 

1. 주석

여느 프로그래밍 언어와 동일하게 주석 기능이 있다. /* */ 혹은 // 를 통하여 주석을 작성한다.

 

 

2. 선행처리기 지시어

컴파일러가 컴파일 하기 전에 처리하는 기능이다. # 으로 시작한다.

- 헤더파일 삽입: #include

- 매크로 선언 및 해제: #define, #undef

- 조건부 컴파일: #if ~ #endif, #ifdef, #ifndef

 

 

3. 문장

단어, 연산자, 숫자, 문자, 문자열, 문장부호 등으로 구성되는 명령어이다. 세미콜론(;)으로 문장을 구분한다. 문장들을 묶은 것을 블록이라고 하며 { } 안에 나열하여 묶어놓는다. 여러 개의 문장을 하나의 문장처럼 취급할 수 있다.

 

 

4. 키워드

C++에서 미리 용도를 정해놓은 단어이다. namespace, int 등이 있으며 반드시 정해진 용도로만 사용해야 한다.

 

 

5. 식별자

프로그래머가 대상을 구분하기 위해 임의로 만든 이름이다.

 

 

6. 자료형

자료형은 1개의 값만을 저장할 수 있는 기본 자료형과, 여러 개의 값을 저장할 수 있는 복합자료형으로 구성된다.

- 기본 자료형은 문자표현 char, 정수표현 int, 참/거짓표현 bool, 실수자료형 float, double 등이 있다.

- 복합 자료형은 구조체 struct, 클래스 class, 열거형 enum, 공용체 union가 있다.

 

(1) 구조체

구조체는 여러 가지 자료형의 데이터 집합이다.

struct structName (
	Type1 item1;
	Type2 item2;
}

(2) 클래스

클래스는 객체의 속성(데이터멤버)과 행위(메소드, 멤버함수)를 묶어서 선언한 것이다. 객체는 시스템 안의 어떠한 대상이며, 클래스는 객체의 설계도라고 할 수 있다. 아래와 같이 private, public과 같은 가시성 지시어와, 데이터 멤버와 멤버함수로 구성된다.

Class Cat {
private:
	char name;
    int age;
public:
	void PrintName() { cout << name << endl; }
	void PrintAge() { cout << age << endl; }
};

 

가시성 지시어

객체 지향에는 캡슐화라는 개념이 있다. 캡슐화란 내부와 외부 관점을 분리하여, 직접적으로 사용하지 못하도록 막을 수도 있고 아예 공개하여 자유롭게 사용할 수 있도록 할 수도 있다. 이러한 캡슐화를 가능하게 하는 것이 가시성 지시어이다. 즉, 가시성 지시어는 클래스의 공개범위를 말한다. private는 지정하지 않을 경우 기본으로 설정되며, 소속 클래스와 친구 클래스에게만 공개된다. public은 전 범위로 공개된다. 여기서 친구 클래스란, 클래스와 클래스 사이에서 멤버를 자유롭게 사용할 수 있도록 앞에 friend를 붙인 것이다.

 

static 데이터멤버, static 멤버함수

static 데이터 멤버는 클래스의 모든 객체들이 공유하는 데이터 멤버이다. 객체 생성과 상관없이 프로그램이 시작되면, static 데이터 멤버를 위한 메모리 공간이 할당된다. 단, static 멤버는 클래스 선언문에서만 선언하고, 클래스 외부에서 별도로 정의해야 한다.

static 멤버함수는 클래스 단위의 작업을 수행하는 함수이다.

 

구현

보통 헤더파일(.h)에 클래스 멤버를 선언하고, 소스파일(.cpp)에 구현 부분을 작성한다. 이후 클래스를 사용하기 위해 헤더 파일을 include한다.

 

생성자

생성자란 객체가 생성될 때 바로 수행하는 작업(초기화 작업이라고 부름)을 정의하는 멤버함수이다. 클래스 이름을 동일하게 사용하여 선언하며, 반환 자료형을 표시하지 않는다. 또한 생성자는 다중정의될 수 있으며, public으로 선언해야 외부에서 객체를 생성할 수 있다. 아래 예시 중 두번째 처럼 초기화리스트(이니셜라이저)를 이용하여 "데이터멤버(초기값)" 형태로 초기화 값을 나열할 수도 있다. 즉 value1이라는 멤버 변수를 매개변수로 들어온 a로 초기화하라는 뜻이다.

class Counter {
	int value;
public:
	Counter () { value = 0; } // 생성자
	void reset () {value = 0; }
};
class Counter {
	int value1;
    int value2;
public:
	Counter(int a, int b) : value1(a), value(b); // 생성자
	void reset () { value = 0; }
};

만약 생성자를 선언하지 않는다면 컴파일러는 묵시적으로 디폴트 생성자를 정의한다.

동일 클래스의 객체를 복사하여 객체를 만드는 생성자도 있는데, 이를 복사 생성자라고 한다. 

class Cat {
private:
	int age1;
    int age2;
public
	Cat(const Cat& copycat) : age1(copycat.age1), age2(copycat.age2)
    ....
}

값을 임시로 사용한다면 값을 복사하지 않고 값을 이동시키면 효율적으로 동작할 수 있는데, 이를 r-value 참조라고 한다. 이동생성자는 r-value 참조로 전달된 같은 클래스의 객체 내용을 이동하여 객체를 만든다.

class Vec {
	int n;
	float* arr;
public:
	Vec(int d, const float* a=nullptr) {  
		arr = new float[d];
		memcpy(arr, a, sizeof(float) * n); // memcpy는 메모리복사함수
        // memcpy(복사받을메모리, 복사할메모리, 복사할데이터의길이)
		}
	
	Vec(Vec&& fv) : n{fv.n}, arr{fv.arr} {
		fv.arr = nullptr;  // fv가 이동되었으니 가지고 있는 자원을 끊어주기 위함
		fv.n = 0;
	 }
};

 

위임 생성자란, 클래스의 다른 생성자를 이용하여 선언되는 생성자이다. 생성자에서 다른 생성자를 호출하여, 코드의 중복을 방지할 수 있다. 이를 사용하려면 초기화 리스트에서 생성자를 호출한다.

class Vec {
	int n;
	float *arr;
public:
	Vec(int d, const float* a=nullptr) : n{ d } {
		arr = new float[d];
		if (a) memcpy(arr, a, sizeof(float) * n);
	}
	Vec(const Vec& fv) : n{ fv.n } {
		arr = new float[n];
		memcpy(arr, fv.arr, sizeof(float)*n);
	}
	.....
};
class Vec {
	int n;
	float *arr;
public:
	Vec(int d, const float* a=nullptr) : n{ d } {  // 타겟생성자
		arr = new float[d];
		if (a) memcpy(arr, a, sizeof(float) * n);
	}
	Vec(const Vec& fv) : Vec(fv.n, fv.arr) {} // 위임생성자
	······
};

데이터를 초기화값으로 지정할 때 데이터 개수가 다른 경우라면, initializer_list를 활용할 수 있다.

class Vec {
	int n;
	float *arr;
public:
	Vec(initializer_list<float> lst) : n{ static_cast<int>(lst.size()) } {
		arr = new float[n];
		copy(lst.begin(), lst.end(), arr);
	}
};

int main()
{
	Vec v2{2.0f, 4.0f, 6.0f, 8.0f};
	······
}

 

소멸자

소멸자란 객체가 소멸할 때 수행할 작업을 정의한 멤버함수이다. 보통은 클래스명 앞에 ~ 를 붙인다. 마찬가지로 자료형을 표시하지 않으며 매개변수가 없다. 클래스 하나에는 하나의 소멸자만 정의할 수 있고 public 으로 선언하는 것이 일반적이다. 상속을 통하여 파생 클래스를 정의한다면 virtual 을 지정하여 가상함수가 되도록 지정하는 것이 좋다.

class Person {
	char* name;
	char* addr;
public:
	Person(const char* name, const char* addr); // 생성자의 원형
	~Person();  // 소멸자의 원형
   	void print() const;  // 이름과 주소 출력하는 멤버함수의 원형
};
#include <iostream>
#include <cstring>
#include "Person.h" // 헤더파일 가져오기
using namespace std;

Person::Person(const char* name, const char* addr)
{
	....
}

Person::~Person()  // 소멸자
{
	delete [] name;  // 이름 저장공간 반납
	delete [] addr;  // 주소 저장공간 반납
}

void Person::print() const // 이름과 주소를 출력하는 멤버함수
{
	cout << addr << "에 사는 " << name << "입니다" << endl;
}

 

(3) 배열

배열은 동일한 자료형의 데이터 집합이다. 데이터에 대한 일괄적인 처리를 할 때 유용하게 사용된다.

int CatNo[3] = (1, 2, 3);

 

자료형에서는 묵시적 형 변환과 명시적 형 변환이라는 두 가지의 형 변환을 지원한다. 묵시적 형 변환이란 컴파일러에 의해 자동으로 형 변환이 이루어지는 것이다. 명시적 형 변환은 형변환 연산자를 통해 형 변환을 처리하는 것을 말한다.

- static_cast : 관련 있는 자료형(예: int를 short으로) 간의 형변환을 처리하며, 컴파일 단계에서 이루어진다

- dynamic_cast: 기초 클래스와 파생클래스 간 포인터 또는 참조 형 변환이 프로그램 실행 중에 일어나도록 한다

- reinterpret_cast: 관련이 없는 자료형 간의 형변환을 처리하기 위해 비트 단위를 재해석하여 변환한다

- const_cast: 값을 변환시킬 수 없다고 지정하는 const를 일시적으로 해제한다

 

 

7. 상수

상수는 변하지 않는 변수이며, 리터럴은 데이터 값 그 자체를 말한다. 만약 const a = 1 이라고 선언되었다면 a는 상수이고 리터럴은 1이다.

- 정수형 리터럴: 숫자를 표현하는 문자와 부호이다. (예: 0x9f)

- 문자 리터럴: 작은 따옴표 안에 문자를 표기하거나 8진수나 16진수의 문자 코드로 표현한다. (예: A는 65)

- 실수형 리터럴: 지수기호 e 등으로 표현한다 (예: 12e2f)

 

 

8. 변수

변수는 프로그램이 실행되는 동안 기억하고 있어야 하는 값을 저장하는 메모리 영역이다. 변수는 자료형과 이름이 지정되어야 하며, 사용 전에 미리 선언되어야 한다. 변수는 어디에서 선언되었느냐에 따라 사용영역(scope)이 정해진다.

- 비지역변수: 프로그램의 전체 영역 혹은 소속 파일 내에서 사용 가능한 변수이다. 해당 변수는 정적 변수로, 프로그램의 시작~종료 시점에 유효하다. 

- 지역변수: 선언된 블록에서만 사용 가능한 변수이다. 해당 변수는 자동 변수로, 블록의 시작~종료 시점에만 유효하다.

- stati 지역변수: 사용영역(scope)이 종료되어도 값을 유지한다

만약 비지역변수에 선언된 변수를 지역변수에서 같은 이름으로 선언한다면 이름 가리기(name hiding) 문제가 발생할 수 있다.

 

변수 앞에 아래와 같은 키워드를 사용하기도 한다.

- extern int a : a 라는 변수는 다른 파일에서 이미 이름이 같은 변수가 선언되었으며, 파일 간 변수를 공유하고 있음을 의미한다.

- static int a : a 라는 변수는 다른 소스 파일의 변수와 이름이 동일하다고 하더라도 다른 변수임을 의미한다.

※ 참고로 static은 사용 위치에 따라 의미가 달라진다. 만약 블록 내부에 쓰였다면 블록 내부에서만 사용가능하되, 값이 유지되는 기능을 한다. 만약 블록 밖에 사용되었다면 파일 내부에서는 전역변수이지만 다른 파일에서는 참조할 수 없다. 따라서 다른 파일의 전역변수와 이름을 같게 되는 문제를 피할 수 있다.

 

변수에 대해 아래와 같이 한정어를 지정할 수 있다.

- const int a : a 라는 변수를 수정할 수 없으며, 초기화를 통해서만 값을 수정할 수 있다

- constexpr int a : 컴파일 할 때 a의 값을 알 수 있다

 

포인터와 참조

또한 변수의 종류에는 일반적인 변수 외에도 포인터와 참조가 있다.

포인터(*)는 어떤 대상이 위치한 곳, 즉 주소를 가리키는 변수이다. 아래 예시를 보면, a가 선언된 후 a의 주소를 가리키는 곳을 Ptr 이라는 변수에 넣었다. 이후 Ptr에 *를 붙이면 주소를 액세스할 수 있으며, *Ptr이 1로 대입되었으므로, a가 1로 바뀌게 된다.

int a = 1;
int* Ptr = &a;
*Ptr = 2;

C++ 에서는 동적 메모리 할당이라는 개념이 있다. 프로그램이 동작에 의해 메모리가 결정되어야 하는 경우가 있는데, 이 때는 변수를 선언하는 것 보다는 동적 메모리 할당을 통해 공간을 할당한다. 보통 변수들은 지역변수, 비지역변수처럼 생성과 소멸시점이 정해져있지만, 동적 메모리 할당은 생성시점과 소멸시점을 명령을 통해 정해준다. 이 때 new 연산자와 delete 연산자를 활용한다. 동적으로 할당한 메모리는 이름이 없기 때문에, 이 때 사용하는 것이 포인터 변수이다.

 

참조는 한 객체와 다른 객체를 연결하는 수단으로서, 다른 객체의 별명으로 사용된다고 볼 수 있다. 크기가 큰 객체를 함수에 인수로 전달할 때 사용된다. 참조에는 l-value 참조와 r-value 참조가 있다.

l-value참조(&)는 어떠한 대상을 가리키는 값으로 포인터와 유사하다. l-value 참조는 실체가 있는 대상을 참조한다. 다만 포인터는 초기화를 안해도 되지만, 참조는 반드시 초기화 작업이 필요하다. 또한 포인터는 가리키는 대상을 변경할 수 있으나, 참조는 참조 대상을 바꿀 수 없다.

참조 앞에 const를 붙이면 참조 변수가 참조하는 대상을 읽을 수만 있고 값을 바꿀 수는 없다.

int a = 1;
int& Ref = a;
Ref = 2;

r-value 참조(&&)는 사용 후에는 값을 더 이상 사용하지 않는 대상을 참조할 때 사용한다. 더 이상 값을 갖고 있지 않으므로, 객체의 값을 다른 객체로 이동할 때 사용한다.

 

 

9. 연산자

이항 연산자(사칙연산, 나머지 연산), 단항 연산자, 대입 연산자, 관계연산자, 논리연산자, 비트단위 논리 연산자, 조건 연산자를 제공한다. 몇가지만 살펴보면,

- 비트 이동 연산자 중 좌측이동(<<) : 우측 피연산자에 지정된 비트 수 만큼 좌측 피연산자를 좌측으로 이동한다

- 조건 연산자(조건? 값1 : 값2): 조건이 참이면 수식의 값은 값1이 되고 거짓이면 값2가 된다

 

 

10. 흐름제어구문

보통을 문장이 나열된 순서에 따라 차례대로 수행이 된다. 하지만 조건제어와 반복제어를 만나면 다르게 수행될 수 있다.

조건 제어 구조는 조건에 따라 흐름을 제어하는 것으로, if문과 switch문이 있다.

if (조건)
    문장1;       // 조건이 참일때 실행할 문장
else
    문장2;       // 조건이 거짓일 때 실행할 문장
switch (수식) {
case 값1 :
    문장1;      // 수식==값1일 때 실행할 문장들 나열
    break;      // switch문을 빠져나감
case 값2 :
    문장2;
    break;
default :      // 수식과 일치하는 case 값이 없을 때
    문장n;     // 실행할 문장들 나열
}

 

반복 제어 구조는 정해진 문장을 반복적으로 실행하는 것으로, for문, while문, do...while문이 있다.

for (초기화_문장 ; 반복조건 ; 증감 문장)
		반복할_문장;
while (반복조건)
		반복할 문장;
do
	반복할문장;
while (조건);

 

 

11. 함수

함수란 특정 작업을 수행하는 문장들을 모아놓은 것이다. 함수는 매개변수를 통해 데이터(인수)를 전달받고, 함수는 특정 작업을 수행하며, 정해진 처리 결과를 결과값으로 반환하며(반환하지 않을 수도 있음), 이후 호출한 곳으로 다시 복귀한다. 아래와 같이 함수를 정의할 수 있고, 함수를 호출할 수 있다.

반환값의자료형 함수명(형식매개변수)
{
	자료형 지역변수
    자료형 지역변수
    ....
    작업수행문장
    작업수행문장
    ....
    return 결과반환값
}
함수명(형식매개변수); // void형 사용 시(값을 리턴받지 않는 함수)
변수명 = 함수명(형식매개변수);

 

여기서 형식매개변수는 인수를 전달받기 위해 함수에 선언된 매개변수이며, 형식 매개변수에 전달할 인수는 실매개변수라고 한다. 인수를 전달하는 방법으로는 값 호출과 참조호출 방식이 있다. 값 호출이란 실매개변수의 값을 형식매개변수에 복사하는 것이다. 따라서 형식매개변수를 변경해도 실매개변수는 영향을 받지 않지만, 데이터가 많다면 복사량이 많아질 수 있다.

int Cat(int age) {
	return age ;
}
int main() {
	int cAge
    cTemp = Cat(cAge);
    cout << cTemp << endl;
    return 0;
}

반면 참조 호출방식은 실매개변수의 참조를 형식매개변수에 전달하는 것이다. 이 경우 형식매개변수를 변경하면 실매개변수도 변경된다. 많은 양의 데이터를 전달할 때 효율적이다. 만약 실매개변수의 값을 변경하지 않도록 하고 싶다면, 형식 매개변수에 const 를 붙인다.

struct SalesRec {
	char dID[10];
	int dYear, dMonth, dDate;
};

void PrSalesRec(const SalesRec &srec)
{
	cout << "주문번호:" << srec.dID << endl;
	cout << "배달일자:" << srec.dYear << "년";
	cout << srec.dMonth << "월";
	cout << srec.dDate << "일" << endl;
}

만약 디폴트 값을 지정해주고 싶다면 함수 원형에 선언한다.

void f(int x, int y=10, int z=20);

f(5);  // x=5, y=10, z=20 전달

함수는 동일한 이름을 가질 수 있는데, 이를 다중정의(overloadding)이라고 한다. 아래의 예시처럼 함수 plus를 다중정의하였다. 동일한 개념을 각각의 대상에 맞게 처리하고자 할 때 사용된다. 이 때 함수는 인수의 개수나 자료형으로 함수를 구분하여 처리한다.

int plus(int a) {
	return a + a;
}

int plus(int a, int b) {
	return a + b;
}

int main() {
	cout << plus(1) << endl;
    cout << plus(2,3) << endl;
 	return 0;
}

 

보통 함수의 호출 절차를 따르지 않고, 함수 호출 위치에서 함수의 처리 문장을 삽입할 수 있는데 이를 inline 함수라고 한다. 아래에 plus 함수에 inline을 붙이면, main 함수에서 plus(1,2)를 컴파일 할 때 num=1+2로 치환되어 들어간다. 따라서 매우 빈번하게 호출되고, 빠르게 실행되어야 하는 함수를 inline 함수로 선언하면 성능을 높일 수 있다.

inline int plus(int a, int b){
	return a + b ;
}

int main(void) {
	int num;
    num = plus(1,2);
    return 0;
}

 

그러나 함수가 너무 크거나, 순환 호출을 하거나, 프로그램 내에서 그 함수에 대한 포인터를 사용하는 경우라면 inline 선언을 무시하고 일반 함수로 번역될 수 있다.

 

 

12. 명칭공간

동일한 이름을 사용할 경우 서로 다른 명칭 공간에 정의한다면 별개의 것으로 인식될 수 있도록 도와준다. 만약 여러 프로그래머가 작성한 파일을 결합할 경우, 명칭 공간을 활용하면 명칭을 독립적으로 사용할 수 있어 충돌을 막을 수 있다.

실제로 사용할 때에는 어떤 명칭 공간의 변수인지를 표기해주어야 한다. 이때, 명칭공간 표기할 수도 있고, 너무 자주 사용된다면 using 을 활용할 수 있다. 또한 특정 명칭공간에 속하지 않고 모든 프로그래머가 동일하게 사용하는 이름은 전역 명칭공간이라고 하며, ::a 형태로 표기한다.

#include <iostream>
namespace MyNameSpace { int a = 1; }
using namespace std;

int main()
{
	cout << MyNameSpace::a << endl; // 원래 std::cout 라고 적어야 하나 using을 사용하여 불필요
}

 


상속

 

여러 클래스들 중에 공통적인 부분이 있을 수 있다. 공통적인 부분을 일반화한 것이 기초 클래스이며, 기초클래스의 내용을 공유하면서 각각의 특수한 정보를 갖는 클래스를 파생 클래스라고 한다. 또한 기초 클래스에서 파생클래스로 이어받도록 하는 것을 상속이라고 한다.

 

이러한 상속 관계를 표현하기 위해서는 파생 클래스를 선언할 때, 기초 클래스를 함께 명시한다.

class 파생클래스 : 가시성지시어 기초클래스 {
가시성지시어:
	데이터 멤버 또는 멤버함수 리스트;
가시성지시어:
	데이터 멤버 또는 멤버함수 리스트;
	······
};

만약 기초클래스에 있는 멤버함수와 파생클레스에 있는 멤버함수 이름이 같다면, 파생클래스의 멤버함수로 덮어쓰기 된다. 이를 재정의(overridding)이라고 한다.

 

기초 클래스와 파생 클래스도 각각 생성자와 소멸자를 선언할 수 있다. 이 때 생성자는 기초 클래스의 내용을 먼저 초기화한 후 파생 클래스의 내용을 초기화하고, 소멸자는 파생 클래스를 먼저 제거한 후 기초 클래스를 제거한다.

 

기초 클래스 앞에 붙는 가시성 지시어는 기존 가시성 지시어와 유사하다. 다만, 기초 클래스 앞에 붙는 가시성 지시어는 상한선이 된다. 우선 private는 소속클래스와 친구클래스의 멤버함수에 공개되며, protected는 private의 공개범위와 더불어 파생클래스와 파생클래스의 친구클래스의 멤버함수에 공개된다. public는 전 범위이다.

만약 가시성 지시어와 기초 클래스 내에 정의된 가시성 지시어는  private라고 지정했다면, 기초클래스의 public 멤버는 private이 되고, protected의 멤버는 private이 된다. 가시성 지시어를 public으로 지정했다면, 기초클래스의 protected 멤버는 protected가 된다.

 

만약 파생클래스를 더이상 선언하고 싶지 않다면 final을 붙여주면 된다.

class A { ······ };
class B : public A { ······ };
class C final : public B { ······ };

 

가상 함수

기초 클래스와 파생 클래스는 포인터를 사용할 수 있다. 포인터를 사용할 때에 주의할 점이 정적 연결과 동적 연결이다. 보통의 경우 기초 클래스형 포인터로 멤버 함수를 호출할 때에, 컴파일러는 정적 타입으로 판단하여 이 타입에 맞는 멤버함수를 호출한다. 따라서 기초 클래스 포인터로 선언했으나, 파생 클래스의 함수를 적용하고 싶을 때에는 virtual 이라는 키워드를 사용하여, 가상함수로 선언해주어야 한다. 가상함수로 선언하면, 포인터 타입이 아닌 포인터가 실제로 가리키는 객체에 맞춘 멤버 함수를 선택할 수 있다.

아래 예시를 보면,  Person 클래스에도 print가 있고 Student 클래스에도 print가 있다. 메인함수에서 Lee는 Person의 객체이고 Kim은 Student의 객체이다. 만약 virtual 함수를 지정해주지 않았다면 Lee와 Kim 모두 PrintPerson의 print()는 Person의 멤버함수로 적용된다.

class Person {
	string name;
public:
	Person(const string& n) : name(n) { }
	void print() const { cout << name; }
};

class Student : public Person {
	string school;
public:
	Student(const string& n, const string& s) : Person(n), school(s) { }
	void print() const {
		Person::print();
		cout << " goes to " << school;
	}
};

void PrintPerson(const Person * const p[], int n)
{
		for (int i=0 ; i < n ; i++) {
			p[i]->print();
			cout << endl;
		}
}

int main()
{
	Person Lee ("lee");
	Student Kim("kim", "Seoul National University");

	Person *pPerson[2];
	pPerson[0] = &Lee;
	pPerson[1] = &Kim;
	PrintPerson(pPerson, 2);
    
	return 0;
 }

하지만 virtual 함수를 붙인다면 함수 이름이 아닌, 포인터에 연결된 멤버함수를 식별한다. 따라서 Lee는 Person의 print 멤버함수가, Kim은 Student의 print 멤버함수가 적용된다. virtual은 기초 클래스에서만 붙이면 된다.

class Person {
	string name;
public:
	Person(const string& n) : name(n) { }
	virtual void print() const { cout << name; }
};

class Student : public Person {
	string school;
public:
	Student(const string& n, const string& s) : Person(n), school(s) { }
	void print() const {
		Person::print();
		cout << " goes to " << school;
	}
};

void PrintPerson(const Person * const p[], int n)
{
		for (int i=0 ; i < n ; i++) {
			p[i]->print();
			cout << endl;
		}
}

int main()
{
	Person Lee ("lee");
	Student Kim("kim", "Seoul National University");

	Person *pPerson[2];
	pPerson[0] = &Lee;
	pPerson[1] = &Kim;
	PrintPerson(pPerson, 2);
    
	return 0;
 }

위와 같이 기초 클래스의 포인터로 연결한 파생 클래스 객체를 제거할 대에는 기초 클래스의 소멸자만 동작하고, 파생 클래스의 소멸자는 동작하지 않는다. 따라서 소멸자를 가상함수로 선언해주어야 한다.

 

또한 파생클래스의 포인터와 기초 클래스의 포인터를 서로 변환할 수 있는데, 이를 업캐스팅과 다운캐스팅이라고 한다. 파생 클래스 포인터를 기초 클래스 포인터로 변환하는 것을 업캐스팅이라고 하며, 묵시적 형 변환이 된다. 반대의 경우는 다운 캐스팅이며, 이 때는 묵시적 형변환이 불가하므로 형 변환 연산자로 명시적 형 변환을 해야 한다.

Person *pPrsn1 = new Person("Dudley");
Student *pStdnt1 = new Student("Harry", "Hogwarts");
Person *pPrsn2 = pStdnt1; // upcasting
Student *pStdnt2 = pPrsn2; // downcasting으로 오류 발생(묵시적 형 변환 불가)

만약 파생클래스에서도 가상함수를 지정해주어야 한다면, 기초 클래스로부터 정의된 가상함수와 파생클래스의 가상함수를 구분하기 위해 override를 지정한다.

class A {
	······
	virtual void f() { ······ }
};

class B : public A {
	······
	void f() override { ······ } 	// f는 기초클래스에서 정의된 가상함수, g와 구분하기 위해 override 표기
	virtual void g() { ······ } 	// g는 파생클래스에서 정의된 가상함수
};

만약 가상함수를 더이상 사용하지 못하게 하려면 final을 지정한다.

class A {
	······
	virtual void f() { ······ }
};

class B : public A {
	······
	void f() override final { ······ } // f 함수는 B에서 끝 
};

 

추상 클래스

그동안 살펴본 기초 클래스와 파생 클래스 외에 추상 클래스라는 것이 있다. 추상 클래스는 유사한 성격을 가진 클래스의 공통 요소를 사용한 것은 맞지만 너무 추상화되어 구체적인 구현이 없는 경우를 말한다. 추상 클래스가 사용되려면 파생 클래스에 의해 구현되어야 한다. 추상 클래스를 사용하는 이유는 파생 클래스가 반드시 가져야 하는 행위를 지정해줌으로써, 정의하는 것을 누락하지 않도록 돕는다. 추상 클래스는 아래와 같이 순수가상함수(=0으로 끝나는 함수)를 포함하는 클래스로 선언한다.

class A { // 추상 클래스
public:
	virtual void f() const = 0; // 순수가상함수
};

이러한 순수가상함수를 파생 클래스가 상속받아 클래스의 모든 요소가 구체적으로 구현되는 것을 상세 클래스라고 한다.

 

다중 상속

2개 이상의 기초 클래스로부터 상속 받는 것을 다중 상속이라고 한다. 이 경우에는 기초 클래스가 동일한 멤버함수를 갖고 있다면 과연 어떤 멤버함수로 처리해야하는지 모호한 부분이 생긴다. 이 때에 구체적으로 명시해주는 방안이 있다. 혹은 또 다른 공통의 기초 클래스를 가상으로 상속하여 중복 상속을 방지하는 방안이 있다. 


템플릿

만약 동일한 코드인데 자료형만 다른 경우, 자료형 마다 동일 코드를 작성해야 한다. 이러한 코드 중복을 방지하기 위해서는 템플릿을 이용할 수 있다. 템플릿이란 클래스와 함수를 선언하기 위한 형판이며, 매개변수를 통해 템플릿에 전달되면 클래스나 함수가 자동으로 선언되는 것을 말한다. 템플릿은 일반화 프로그래밍(generic programming)의 방법이다.

 

클래스 템플릿은 아래와 같이 생성한다. 클래스 템플릿의 경우 멤버함수의 구현까지도 모두 동일한 헤더파일에 만들어야 한다. 이 점이 일반 클래스 선언과 다른 점이다.

template <템플릿파라미터>
class 클래스템플릿명 {
	....
}
template <typename T>
class Stack {
	T *buf;
	int top;
	int size;
public:
	Stack(int s); 
   	....
	void push(T&& a);
    ....
};

template <typename T> Stack<T>::Stack(int s) : size(s), top(s)
{
	buf = new T[s];
}
....
template <typename T> void Stack<T>::push(T&& a)
{ 
	buf[--top] = move(a);
}
....

그리고 아래와 같이 사용한다.

#include "StackT.h"
using namespace std

int main()
{
	Stack<char> sc(100);
	sc.push('a');
	....
}

 

함수 템플릿은 아래와 같이 선언된다.

tmeplate <템플릿파라미터>
리턴타입 함수명(함수파라미터) {
	....
}
#include <utility>
using namespace std;

template <typename ANY>
void swapFT(ANY &a, ANY &b)
{
	ANY temp = move(a);
	a = move(b);
	b = move(temp);
}
#endif

그리고 아래와 같이 사용한다.

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

int main()
{
	int x = 10, y = 20;
	swapFT(x, y);
}

 

표준 템플릿 라이브러리

C++에서는 이러한 컨테이너 클래스 템플릿 라이브러리를 제공하는데, 이를 표준 템플릿 라이브러리(STL)이라고 한다. STL은 #include로 선언해주어야 하며, std 명칭공간에 있다. STL은 아래의 세 가지 요소로 구성된다.

 

(1) 컨테이너: 데이터를 저장하는 역할을 담당한다

- 순차 컨테이너: vector, list, deque

- 연상 컨테이너: set, multiset, map, multimap

- 무순서 연상 컨테이너: unordered_set, undordered_multiset, undordered_map, undordered_multimap

- 컨테이너 어뎁터: queue, priority_queue, stack 등

 

(2) 반복자: 포인터의 역할로, 컨테이너 안의 요소를 가리킨다. C++에서는 자료를 포인터로 관리하기 때문에, 자료구조를 읽는 데 필요한 도구로서 사용된다.

- begin(): 컨테이너의 첫 번째 위치를 가리키는 반복자를 반환한다

- end(): 컨테이너의 마지막 위치를 가리키는 반복자를 반환한다

 

(3) 알고리즘: 검색, 정렬, 수정 등의 데이터를 처리할 수 있는 함수이다. 아래는 일부만 작성하였다.

- swap: 컨테이너 안의 값을 교환

- unique: 인접 위치의 중복 값 제거

- replace: 지정된 값을 다른 값으로 대체 등

- merge: 동일한 기준으로 정렬된 두 개의 집합을 하나의 데이터 집합으로 연결한다.

- sort: 랜덤 액세스 반복자에 의해 지정된 범위의 값을 정렬한다.


예외처리

예외란, 프로그램 실행 도중 발생할 수 있는 비정상적인 사건을 말한다. 이를 대비하기 위해 예외가 발생했을 때 특정 절차를 수행할 수 있도록 예외처리를 지정한다. 예외 처리 구문으로는 try ~ catch 구문, throw 문장 등이 사용된다.

 

아래 예시를 보면, 두 수를 입력받고 mean 함수를 실행할 때 만약 if 값의 예외사항이 발생하면 throw가 작동하며, 이를 catch가 받아서 처리한다. 이 때 "계산할 수 없음!"은 문자열형이므로 catch의 괄호 안의 s가 이를 받는다.

#include <iostream>
using namespace std;

double mean(double a, double b)
{
	if (a == -b)
		throw "계산할 수 없음!";
	return 2.0 * a * b / (a + b);
}

int main()
{
	double x, y, z;
	char cFlag = 'y';
	while (cFlag != 'n') {
		cout << "\n두 수를 입력하시오 : ";
		cin >> x >> y;
		try {
			z = mean(x, y);
			cout << "평균 = " << z << endl;
		}
		catch (const char* s) {
			cout << s << endl;
		}
		cout << "계속 할까요? (y/n) : ";
		cin >> cFlag;
	}
}

스마트 포인터

예외상황이 발행하면 return 문을 실행하지 않고 넘어가기 때문에, 동적으로 할당된 메모리의 경우 자원을 반납하지 않는다. 이 때문에 메모리 손실이 일어나므로, 스마트 포인터를 활용하여 해당 문제를 해결한다. 스마트포인터는 포인터가 제거되거나 nullptr이 대입된다면, 메모리를 반납한다.

- unique_ptr은 할당된 메모리를 1개의 포인터만 가리킬 수 있고,

- shared_ptr은 할당된 메모리를 여러 개의 포인터가 가리킬 수 있다. 가리키는 포인터가 없으면 그 때 메모리를 반납한다.

 

exception 클래스

C++언어에서는 예외처리를 담당하는 클래스 exception을 제공한다. exception은 가상함수 what()을 멤버함수로 가지고 있다. what()은 예외의 종류를 char* 형태로 변환하여 예외에 해당하는 메시지를 가지고 나올 수 있도록 만들어져있다. 따라서 사용자는 exception의 파생 클래스에서 what()을 재정의하여 사용하면 된다.

#include <exception>
······
class Array {
	······
public:
	······
	class BadIndex : public exception {
	public:
		int wrongIndex;
		BadIndex(int n) : wrongIndex(n), exception() {}
		const char* what() const { return "Array Exception::"; }
	};
};

 

예외 객체의 다시 던지기

예외처리를 하던 중 후속 처리가 추가로 필요한 경우, throw를 또 다시 사용할 수 있다. 아래 예시에서는 f 함수에서 발생한 예외를 g에서 처리하고, 후속처리를 h에서 하는 경우 이다.

int f(int a) {
	if (a < 0)
		throw ExceptionClass();
	······
};

int g(int x) {
	try {
		f(x);
		······
	}
	catch (ExceptionClass e) {
		······ // 현 단계의 예외처리
	throw;
	}
}

int h(int c) {
	try {
		g(c);
		······
	}
	catch (ExceptionClass e) {
		······ // 후속 예외처리
	}
}

 


후기

처음에 컴퓨터과학을 전공하면서 나는 개발자가 아니니까 가급적 프로그래밍 언어를 듣지 않으려고 했다. 하지만 직접 해봐야 알 수 있다라는 이야기를 들어서 1학기에는 C를, 2학기에는 C++를 수강하게 되었다. 확실히 언어를 배우고 나니 보이는 것들이 있다. 예를 들면, 기획 문서에서 흔하게 작성했던 "예외 처리"가 프로그래밍 용어였고, 어떻게 처리되는지를 이해할 수 있었다. 언어를 배웠다고 해서 당장 개발을 할 수 있는 것은 아니다. 그래도 개발자들의 노고를 이해하고, 백 단에서 어떤 처리과정이 이루어지는지 일부는 이해할 수 있을 것 같다. 그것만으로도 C++ 언어를 공부하는 시간은 가치 있었다. (그래도 솔직히 너무 어렵다 ㅜㅜ)

 

도움 받은 글

1. 한국방송통신대학교 컴퓨터과학과 강의록

2. [C#] 일반화 프로그래밍, https://nomad-programmer.tistory.com/185

3. C++ 나무위키, https://namu.wiki/w/C%2B%2B

4. 리터럴(Literal)이란?, https://velog.io/@pjeeyoung/%EB%A6%AC%ED%84%B0%EB%9F%B4

5. C++ 04.05 - static, 정적변수, https://boycoding.tistory.com/169

6. C auto, static, extern 키워드의 의미, https://www.letmecompile.com/c-auto-static-extern-%ED%82%A4%EC%9B%8C%EB%93%9C-%EC%9D%98%EB%AF%B8/

7. C++ 전역변수의 static과 extern 키워드, mr-dingo.github.io/c/c++뽀개기/2019/01/10/static&extern.html

8. [C++ 기본공부정리] 12.참조변수(reference variable), https://min-zero.tistory.com/37

9. C++ inline 함수란?, https://hwan-shell.tistory.com/85

10. [C++] 가상함수(virtual) 사용법 총정리, https://coding-factory.tistory.com/699

11. C++ STL이란? https://wiserloner.tistory.com/435

 

 

728x90

댓글