Template 과 Generic 그리고 C++ STL

    //template <typename T>
    MyStack<T>::MyStack() {
    	tos = -1;
    }

    1. 단순 함수 오버로딩 

    #include <iostream>
    using namespace std;
    
    void myswap(int& a, int& b) {
    	int tmp;
    	tmp = a;
    	a = b;
    	b = tmp;
    }
    
    void myswap(double& a, double& b) {
    	double tmp;
    	tmp = a;
    	a = b;
    	b = tmp;
    }
    
    int main() {
    	int a = 4, b = 5;
    	myswap(a, b);
    	cout << a << '\t' << b << endl;
    
    	double c = 0.3, d = 12.5;
    	myswap(c, d); 
    	cout << c << '\t' << d << endl;
    }
    5       4
    12.5    0.3

    함수 Overloading을 이용한 중복 함수 작성 방법이다.

    동일한 내용의 함수를 매개변수, 반환형의 타입에 따라 일일히 재작성해야 하는 불편함이 있다.

    이런 불편함을 해소하기 위해 템플릿을 이용하여 '일반화된' 제네릭 함수를 작성한다. 아래의 예시를 보자.

     

    2. 템플릿을 활용한 제네릭함수 작성

    #include <iostream>
    using namespace std;
    
    template <typename T>
    void myswap(T& a, T& b) {
    	T tmp;
    	tmp = a;
    	a = b;
    	b = tmp;
    }
    
    int main() { 
    	int a = 4, b = 5;
    	myswap(a, b);
    	cout << a << '\t' << b << endl;
    
    	double c = 0.3, d = 12.5;
    	myswap(c, d);
    	cout << c << '\t' << d << endl;
    }
    5       4
    12.5    0.3

    템플릿, 제네릭 타입을 이용해서 일반화 된 제네릭 함수를 작성했다. 이처럼 동일한 내용의 함수가 타입이 다를 경우, 템플릿을 이용하여 일반화된 함수를 작성하면, 동일한 함수를 여러번 작성하는 번거로움을 피할 수 있다.

     

    템플릿의 특징.

    템플릿이 마냥 좋은 것만은 아니다.

    우선 디버깅 단계에서 버그를 찾기가 어렵다. 템플릿을 이용해 작성된 제네릭함수는 에러가 생격도, 에러가 나타난 부분을 알기 어렵다. 따라서 디버깅 단계가 어려워진다.. 또한, 포팅에 취약하여 컴파일러에 따라 지원하지 않을 수도 있다. 

    반며, 반복되는 함수를 제네릭 함수로 작성하게되면 코드가 간결해지고 유지보수가 비교적 간단해진다., (생산성이 향상된다.) 최근에는 템플릿과 제네릭함수를 이용하는 제네릭 프로그래밍이 보편화되는 추세이다.

     

    C++과 자바의 제네릭 함수(메소드)

    C++의 Template은 실행전, 컴파일 단계에서 구체화된다. JAVA의 경우 템플릿 메소드는 구체화되지 않은 상태에서 '공유'하고 있기 때문에 실행단계에서 C++의 제네릭함수 속도가 더 빠르다.

     

    템플릿, 제네릭 함수 작성 시, 발생할 수 있는 문제
    #include <iostream>
    using namespace std;
    
    template <typename T>
    void print(T array[], int n) {
    	for (int i = 0; i < n; i++) {
    		cout << array[i] << '\t';
    	}
    	cout << endl;
    }
    
    int main() {
    
    	int x[] = { 1,2,3,4,5 };
    	double d[5] = { 1.1,2.2,3.3,4.4,5.5 };
    	print(x, 5);
    	print(d, 5);
    
    	char c[5] = { 1,2,3,4,5 };
    	print(c, 5);
    }
    1       2       3       4       5
    1.1     2.2     3.3     4.4     5.5
                                

    제네릭함수를 작성하여 다음과 배열의 원소를 출력하는 print()함수를 만들었다.

    char로 구체화되면 숫자대신 문자가 출력되는 문제가 발생한다.

    이를 해결하기 위해 템플릿 함수(제네릭 함수)와 Overloading되는 함수를 선언했다.

     

    #include <iostream>
    using namespace std;
    
    template <typename T>
    void print(T array[], int n) {
    	for (int i = 0; i < n; i++) {
    		cout << array[i] << '\t';
    	}
    	cout << endl;
    }
    
    void print(char array[], int n) {
    	for (int i = 0; i < n; i++) {
    		cout << (int)array[i] << '\t';
    	}
    	cout << endl;
    }
    
    int main() {
    
    	int x[] = { 1,2,3,4,5 };
    	double d[5] = { 1.1,2.2,3.3,4.4,5.5 };
    	print(x, 5);
    	print(d, 5);
    
    	char c[5] = { 1,2,3,4,5 };
    	print(c, 5);
    }
    1       2       3       4       5
    1.1     2.2     3.3     4.4     5.5
    1       2       3       4       5

    실행 결과, Overloading된 함수가 제네릭 함수보다 호출 우선순위가 높다는 것을 알 수 있다.

    (사실 char 배열 c를 선언할 때, 배열의 원소들을 {'1', '2', '3', '4', '5'}로 선언하면 되는 문제기는 하다.)

     

    제네릭 클래스

    클래스도 또한, 함수와 같이 템플릿을 이용하여 일반화시킬 수 있다.

    이렇게 일반화시킨 클래스를 제네릭 클래스라고 한다.

    제네릭 클래스를  선언하는 것은 제네릭 함수와 크게 다르지 않다.

    다만 주의해야할 점은, 템플릿을 이용해 클래스를 선언할 경우 멤버함수의 구현부 마다 해당 템플릿을 재선언해야 한다는 것이다. 아래 코드를 살펴보자.

     

    #include <iostream>
    using namespace std;
    
    template <typename T>
    class MyStack {
    	int tos; //top of stack
    	T data[100];
    public:
    	MyStack();
    	void push(T elemnet);
    	T pop();
    };
    
    template <typename T>
    MyStack<T>::MyStack() {
    	tos = -1;
    }
    
    template <typename T>
    void MyStack<T>::push(T element) {
    	if (tos == 99) {
    		cout << "stack is full";
    		return;
    	}
    	tos++;
    	data[tos] = element;
    }
    
    template <typename T>
    T MyStack<T> ::pop() {
    	T retData;
    	if (tos == -1) {
    		cout << "stack is empty";
    		return 0;
    	}
    	retData = data[tos--];
    	return retData;
    }
    
    int main() {
    	MyStack<int> iStack;	//정적생성
    	iStack.push(3);
    	cout << iStack.pop() << endl;
    
    	MyStack<double> dStack;	//정적생성
    	dStack.push(3.5);
    	cout << dStack.pop() << endl;
    
    	MyStack<char>* p = new MyStack<char>(); //동적생성
    	p->push('a');
    	cout << p->pop() << endl;
    	delete p;
    }

    제네릭 클래스를 선언하고, 멤버함수의 선언과 구현을 나눠 클래스 외부에서 멤버함수를 구현했다.

    제네릭 클래스인 MyStack의 멤버함수 push, pop과 생성자를 구현할 때에는, 위와 같이 템플릿을 재선언해준다.

    또한, 클래스의 이름뒤에 템플릿을 명시해줘야 한다.

     

    정의되어 있는 제네릭 클래스의 객체를 생성하기 위해선, 우선 클래스를 구체화시켜야 한다.

    main함수에서 각  단락에 첫 줄에는 클래스를 구체화 시키는 코드를 적었다.

    제네릭으로 작성된 클래스는 일반 클래스와 같이 정적생성과 동적생성 모두 가능하다.

     

    템플릿을 재선언하지 않을 경우
    //template <typename T>
    void MyStack<T>::push(T element) {
    	if (tos == 99) {
    		cout << "stack is full";
    		return;
    	}
    	tos++;
    	data[tos] = element;
    }

     

    클래스 뒤 템플릿 명시하지 않았을 경우
    template <typename T>
    T MyStack ::pop() {
    	T retData;
    	if (tos == -1) {
    		cout << "stack is empty";
    		return 0;
    	}
    	retData = data[tos--];
    	return retData;
    }

     

     

    C++ 표준 템플릿 라이브러리, STL

    위에서 스택 자료구조를 단순화하여 제네릭 클래스를 이용해 구현해봤다.

    표준 템플릿 라이브러리에는 개발자들이 자주 이용하는 많은 제네릭 클래스와 제네릭 함수가 포함되어 있다.

    위에서는 제네릭 클래스를 소개하기 위해 직접 정의해서 사용했지만, 일반적인 상황에서는 이미 만들어진 표준을 불러와 사용한다. 

     

    C++에서 제공하는 STL에는 자료구조를 표현한 클래스 템플릿인 컨테이너, 컨테이너 원소에 대한 포인터를 나타내는 iterator, 컨테이너 원소에 대한 기능을 구현한 템플릿 함수인 알고리즘이 포함되어 있다.

    템플릿함수-알고리즘의 경우 컨테이너의 원소에 대한 기능을 구현한 함수이기 때문에, 컨테이너의 멤버 함수라고 오해할 수 있지만 실상은 그렇지않다. 

    컨테이너를 사용하기 위해 헤더파일을 include한 후, 알고리즘을 사용하기 위해서는 <algorithm> 헤더파일을 따로 include 해주어야 한다.

     

    vecotr 컨테이너와 iterator 사용

    vector는 가변 길이를 갖는 배열(array)이다. 원소의 추가, 삭제가 일어나는 경우 배열의 길이를 알아서 조절해주기 때문에, 개발자가 벡터의 길이에 대한 고민을 할 필요가 없다. 

    vector 클래스에서는 원소의 저장, 삭제, 검색, 교체 등 다양한 멤버 함수를 지원한다. 

    또한, 벡터에 저장된 원소는 array와 같이 인덱스를 통해 접근이 가능하며 반복자, iterator를 사용하는 방법으로도 원소에 대한 접근이 가능하다.

    #include <iostream>
    #include <vector>
    using namespace std;
    
    int main() {
    	vector<int> v;
    	v.push_back(1);
    	v.push_back(2);
    	v.push_back(3);
    
    	vector<int>::iterator it;
    
    	for (it = v.begin(); it != v.end(); it++) {
    		int n = *it;
    		n = n * 2;
    		*it = n;
    	}
    
    	for (it = v.begin(); it != v.end(); it++) {
    		cout << *it << ' ';
    		cout << endl;
    	}
    }

    위 코드에서는 vector 컨테이너의 iterator it를 선언해서 사용한다. for문의 초기화식에서, 벡터 v의 처음 원소의 주소를 저장하여 사용한다. it는 원소의 주소를 가르키는 포인터이기 때문에 증감식 it++를 이용해 다음 원소를 가리킬 수 있다.

     

    map 컨테이너

    map은 데이터를 key:value의 쌍으로 표현한다. array보다 빠른 성능으로 응용되어 많은 곳에서 사용된다. 

    대부분의 언어에서 지원하지만 언어에 따라 dictionary, hash, hashmap 등 많은 이름을 갖고 있다.

    따라서 동일한 key를 가진 원소가 중복 저장되는 경우 오류가 발생하며, 이와 같은 경우를 허용하는 multimap이 있다.

     

    #include <iostream>
    #include <string>
    #include <map>
    using namespace std;
    
    int main() {
    	map<string, string> dic;
    
    	dic.insert(make_pair("love", "사랑"));
    	dic.insert(pair<string, string>("apple", "사과"));
    	dic["cherry"] = "체리";
    
    	cout << "저장된 단어 수" << dic.size() << endl;
    
    	string eng;
    	while (true) {
    		cout << "찾고 싶은 단어 >> ";
    		getline(cin, eng);
    		if (eng == "exit") {
    			break;
    		}
    
    		if (dic.find(eng) == dic.end()) {
    			cout << "없음" << endl;
    		}
    		else {
    			cout << dic[eng] << endl;
    		}
    	}
    	cout << "종료합니다.." << endl;
    }

    위 코드에서는 map 자료구조에 <string, string> 쌍(pair)를 저장하는 3가지 방법을 보여준다.

     

    STL 알고리즘

    STL에서 지원하는 알고리즘(템플릿) 함수는 전역함수로, 컨테이너 클래스의 멤버 함수가 아니다.

    따라서 알고리즘 함수를 사용하기 위해선 따로 <algorithm> 헤더를 include 해주어야 한다.

     

    아래는 algorithm 클래스에서 지원하는 sort() 함수를 이용해 vector<int>의 원소들을 정렬하는 코드이다.

    sort()는 첫번쨰 파라미터의 원소번호부터 두번째 파라미터의 원소번호 - 1 범위를 정렬한다.

    #include <iostream>
    #include <vector>
    #include <algorithm>
    using namespace std;
    
    int main() {
    	vector<int> v;
    	
    	for (int i = 0; i < 5; i++) {
    		cout << "5개의 정수를 입력하세요 >>";
    		int num;
    		cin >> num;
    		v.push_back(num);
    	}
    
    	sort(v.begin(), v.end());
    
    	vector<int>::iterator it;
    
    	for (it = v.begin(); it != v.end(); it++) {
    		cout << *it << ' ';
    	}
    	cout << endl;
    }

     

     

    변수 선언 시, auto의 활용

    C++에서 auto는 컴파일러에게 변수의 타입 결정을 떠넘기는 키워드다.

    auto로 설정한 변수는 컴파일 단계에서 컴파일러가 변수선언무을 추론하여 자동으로 타입을 선언한다.

    auto를 이용하면 복잡한 변수 선언을 간소화 시킬 수 있다. 아래 코드를 살펴보자.

    #include <iostream>
    #include <vector>
    using namespace std;
    
    int square(int x) { return x * x; }
    
    int main() {
    	auto c = 'a';
    	auto pi = 3.14;
    	auto ten = 10;
    	auto* p = &ten;
    	cout << c << " " << pi << " " << ten << " " << *p << endl;
    
    	auto ret = square(3);
    	cout << *p << " " << ret << endl;
    
    	vector<int> v = { 1,2,3,4,5 };
    	vector<int>::iterator it;
    	for (it = v.begin(); it != v.end(); it++) {
    		cout << *it << " ";
    	}
    	cout << endl;
    
    	for (auto it = v.begin(); it != v.end(); it++) {
    		cout << *it << " ";
    	}
    }

    c, pi, ten 등은 기본 자료형에 auto를 선언한 것이다. p의 경우는 int * 가 되며 ret은 square()함수가 반환하는 int형이 된다.

    또한, auto를 사용하여 iterator의 선언을 간소화시킬 수도 있는데, 두번재 for문에서 그 방법을 보여주고 있다.

     

    C++ 람다

    프로그래밍 언어에서 람다는 흔히 이름이 없는 함수, 익명 함수로 인식된다.

    C++ 람다식은 아래와 같다.

    캡쳐리스트는 외부에서 변수를 가져오는 절이다. 

    캡쳐리스트는 아래와 같이 사용한다.

    [=] 전부 call-by-value   [&] 전부 call-by-ref

    [&pi] pi를 call-by-ref     [pi] pi를 call-by-value

     

    매개변수 리스트는 람다식 바디에서 사용하는 매개변수이다.

    리턴타입은 생략이 가능하며, 리턴타입을 명시할 경우 -> 를 같이 작성해줘야 한다.

    함수 바디에는 람다식이 호출되면 실행할 코드를 적는다.

    #include <iostream>
    using namespace std;
    
    int main() {
    	double pi = 3.14;
    
    	auto calc = [pi](int r) -> double { return pi * r * r; };
    
    	cout << "면적은 " << calc(3);
    }

    캡쳐리스트와 리턴값이 있는 람다식을 auto를 이용해 calc이라는 이름을 붙여 사용한다.

     

    #include <iostream>
    using namespace std;
    
    int main() {
    	int sum = 0;
    
    	[&sum](int x, int y) { sum = x + y; } (2, 3);
    
    	cout << "합은 " << sum;
    }

    캡쳐리스트를 참조형으로 사용한다.

     

     

    람다를 이용하는 이유는 첫째로 함수의 파라미터나 반환값으로 사용할 수 있다. 람다는 함수를 하나의 표현식(expression)으로 만든 것이기 때문에 변수처럼 파라미터나 반환값, 비교연산 등에 사용될 수 있다.

    또한, 람다를 이용하면 함수를 인라인 함수로 만들 수 있다. main에서 일반적인 함수를 호출할 경우, 함수를 호출하기 전 status를 저장하고 함수를 호출해서 실행하고 다시 main의 작업 state로 돌아오는 일련의 과정이 일어난다. 호출이 잦은 함수의 경우 함수호출로 인한 오버헤드가 심각해질 것이다. 우리는 이를 해결하는 방법으로 앞서 인라인 함수를 배웠다. 여기 또 한가지 방법이 있다. 충분히 짧고 호출이 잦은 함수를 람다식으로 정의하고 auto를 이용해 이름을 붙여 사용하면 마치 inline과 같이 작동해 외부 함수호출로 인한 오버헤드를 줄일 수 있다.

    댓글