Search
Duplicate

전문가를 위한 C++/ C++ 소프트웨어 공학

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

성능과 효율성에 대하여

프로그램에서 성능(performance)는 속도, 메모리 사용량, 디스크 접근 횟수, 네트워크 사용량 등과 같이 문맥에 따라 가리키는 대상이 다르다. 이 장에서는 주로 속도를 의미한다. 한편 프로그램에서 효율성(efficiency)이란 낭비 없이 실행된다는 것을 의미한다. 효율적인 프로그램은 주어진 상황에서 작업을 최대한 빠르게 처리한다. 물론 프로그램이 다루는 영역에 따라 빠르지 않으면서 효율적으로 만들 수도 있다.

효율성을 확보하기 위한 두 가지 접근 방법

효율성을 높이는 두 가지 방식이 있다. 하나는 언어 차원의 효율성(language-level efficiency) 으로서 언어를 최대한 효율적으로 사용하는 것이다. 예컨대 객체를 값이 아닌 레퍼런스로 전달하는 것이다. 하지만 여기에는 한계가 있다.
또 다른 접근 방식인 디자인 차원의 효율성(design-level efficiency)을 적용하면 성능을 더욱 향상시킬 여지가 훨씬 많다. 예컨대 효율적인 알고리즘을 선정하고, 불필요한 단계나 연산을 제거하고, 적절히 디자인을 최적화하는 것이다. 흔히 비효율적인 알고리즘이나 데이터 구조를 더 나은 것으로 교체하는 방식으로 코드의 성능을 최적화한다.

두 가지 프로그램

애플리케이션이 다루는 영역에 관계 없이 효율성은 중요하다.
(이하 설명 생략)

C++는 비효율적인 언어인가?

C 프로그래머는 고성능 애플리케이션을 개발하는데 C++ 사용을 꺼리는 성향이 있다. C++는 C와 같은 절차형 언어에 비해 비효율적이라는 이유에서다. C++에는 익셉션이나 virtual 메서드와 같은 하이레벨 개념이 담겨 있기 때문이다. 하지만 이런 주장에는 몇 가지 오류가 있다.
언어의 효율성을 따질 때는 반드시 언어 자체의 성능과 컴파일러의 최적화 효율성을 구분해야 한다. 다시 말해 컴파일러의 효율성을 무시하면 안 된다. 개발자가 작성한 C나 C++ 코드와 실제로 컴퓨터에서 실행하는 코드는 엄밀히 말해 다르다.
컴파일러는 개발자가 작성한 소스 코드를 기계어로 변환하는 과정에 최적화 작업을 수행한다. 그래서 C 프로그램과 C++ 프로그램을 단순히 벤치마크 테스트 하는 것만으로는 비교하기 힘들다. 언어를 비교하는 것이 아니라 그 언어의 컴파일러에서 제공하는 쵲ㄱ화 기능을 비교해야 한다.
C++ 컴파일러는 C++에서 제공하는 여러 가지 하이레벨 구문을 최대한 기계어 코드에 가깝게 최적화 한다. 간혹 같은 기능을 C 언어로 작성할 때보다 성능이 뛰어날 때도 있다. 최근에는 C 컴파일러보다 C++ 컴파일러의 최적화에 대한 연구가 훨씬 많다. 그래서 C 코드보다 C++ 코드의 최적화가 더 잘돼서 실행 속도가 빠른 경우가 많다.
하지만 C++의 기능 중 최적화 할 수 없는 부분이 여전히 남아 있다고 반박하는 사람도 있다. 예컨대 10장에서 설명한 virtual 메서드를 구현하려면 vtable이 있어야 할 뿐만 아니라 실행 시간에 호출 단계가 늘어나기 때문에 non-virtual 함수를 호출할 때보다 실행 속도가 떨어질 수 있다.
하지만 좀 더 깊이 생각해 보면 이 주장은 설득력이 떨어진다. virtual 메서드는 단순 함수 호출 이상의 기능을 제공한다. 예컨대 실행 시간에 호출할 함수를 선택하는 기능도 제공한다. 이 기능을 non-virtual이면서 비교 가능한 (comparable) 함수로 구현하면 실제로 호출할 함수를 선택하는 조건문을 추가해야 한다.  선택 기능이 필요 없다면 non-virtual 방식으로 구현하면 된다.
C++는 ‘필요 없는 기능은 굳이 사용할 필요가 없다’는 기본 디자인 원칙을 따른다. virtual 메서드를 사용하지 않으면 이를 사용할 때 발생하는 오버헤드를 피할 수 있따. 따라서 C++에서 non-virtual 함수를 사용하는 부분의 성능은 C에서 함수 호출을 구현한 코드와 같다. 하지만 virtual 함수를 사용함으로써 발생하는 오버헤드가 그리 크지 않기 때문에 final 클래스가 아니라면 모든 메서드(소멸자는 포함, 생성자는 제외)를 virtual로 구현하는 것이 좋다.
이보다 더 중요한 점이 있다. C++에서 제공하는 하이레벨 구문을 활용하면 디자인 측면에서 훨씬 효율적이고, 가독성이 높고, 유지보수하기 좋고, 불필요한 부분이 없는 코드를 만들 수 있다.
C와 같은 절차형 언어를 사용할 때보다 C++ 언어를 사용하면 개발 과정, 성능, 유지보수 측면에서 훨씬 유리하다.
C#이나 자바와 같은 다른 하이레벨 객체지향 언어도 있다. 둘 다 가상 머신에서 구동한다. 반면 C++로 작성한 코드는 CPU에서 곧바로 구동된다. 코드를 실행하는데 가상 머신 같은 중간 단계를 거치지 않는다. 그래서 C++ 프로그램은 거의 하드웨어 수준으로 실행된다. 다시 말해 C#이나 자바보다 훨씬 실행 속도가 빠를 때가 많다.

언어 차원의 효율성

언어 차원의 최적화를 과도하게 강조하는 책이나 블로그를 많이 볼 수 있다. 이런 기법도 분명 도움 되고 실제로 특정한 경우에 프로그램의 성능을 크게 향상시킬 수 있다. 하지만 프로그램의 디자인과 알고리즘이 성능에 미치는 영향이 훨씬 크다. 아무리 레퍼런스로 전달하더라도 쓸데 없이 디스크 접근하는 횟수가 많다면 효과가 없다. 레퍼런스나 포인터에 집착하다가 큰 그림을 놓치기 쉽다.
심지어 언어 차원의 최적화를 컴파일러에서 기본으로 제공하기도 한다. 따라서 뒤에서 실행할 프로파일러에서 찾아낸 문제가 아니라면 굳이 최적화하는데 시간을 낭비할 필요가 없다.
물론 레퍼런스 전달 방식과 같은 일부 언어 차원의 최적화 기법을 적용하면 코딩 스타일 측면에서 도움 되기도 한다.

객체를 효율적으로 다루는 방법

레퍼런스로 전달하기

값 전달 방식을 적용하면 레퍼런스 전달 방식에서는 볼 수 없었던 복제로 인한 오버헤드가 발생한다.
(예시 생략)
Note) 함수 안에서 객체를 수정해야 한다면 레퍼런스로 전달한다. 그렇지 않다면 const 레퍼런스로 전달한다.
Note) 포인터 전달 방식(pass-by-pointer)는 가능하면 사용하지 않는 것이 좋다. 요즘은 레퍼런스 전달 방식으로 대체하는 분위기다. 이 방식을 적용하면 C 언어를 사용하는 셈이기 떄문에 C++에는 더더욱 맞지 않다. (단, 디자인 측면에서 nullptr을 전달해야 할 경우는 제외한다)

레퍼런스로 리턴하기

함수에 객체를 레퍼런스로 전달하는 것처럼 함수의 객체를 레퍼런스로 리턴하면 불필요한 복제 연산을 피할 수 있다. 하지만 때로는 함수의 객체를 레퍼런스로 리턴할 수 없을 때가 있다. operatr+와 같은 연산자를 오버로딩했을 때가 그렇다. 또한 함수의 로컬 객체는 레퍼런스나 포인터로 리턴하면 안 된다. 로컬 객체는 함수 호출이 종료되면 사라지기 때문이다.
C++ 11부터 추가된 이동 의미론을 적용하면 객체를 레퍼런스가 아닌 값으로 전달할 때도 효율적으로 처리할 수 있다.

익셉션을 레퍼런스로 받기

슬라이싱과 불필요한 복제 연산을 피하려면 익셉션을 레퍼런스로 받는 것이 좋다. 익셉션을 던지는 과정에서 성능이 떨어진다. 따라서 이 과정에서 부담을 조금이라도 줄이는 것이 성능 향상에 도움 된다.

이동 의미론 적용하기

클래스를 정의할 떄 이동 생성자와 이동 대입 연산자를 추가하면 C++ 컴파일러가 그 클래스의 객체에 대해 이동 의미론을 적용한다. 영의 규칙(9장 참조)에 따르면 컴파일러가 생성한 복제 및 이동 생성자와 복제 및 이동 대입 연산자만으로도 충분하도록 클래스를 디자인 해야 한다.
컴파일러가 이런 생성자나 대입 연산자를 알아서 정의해주지 못한다면 명싲거으로 디폴트로 설정한다. 이렇게 할 수 없다면 직접 구현한다. 정의한 클래스의 객체에 이동 의미론을 적용하면 함수에서 그 객체를 값으로 리턴해도 복제 연산이 발생하지 않기 때문에 효율적으로 처리할 수 있다.

임시 객체 생성 피하기

컴파일러는 다양한 상황에서 이름 없는 임시 객체를 생성한다.
(예시 생략)
일반적으로 컴파일러가 임시 객체를 만들지 않도록 작성하는 것이 좋다. 어쩔 수 없이 임시 객체가 생성되게 작성하더라도 컴파일러가 내부적으로 임시 객체를 생성한다는 사실을 알고 있어야 나중에 실행 성능이나 프로파일링 결과를 보고 놀라지 않는다.
컴파일러는 임시 객체를 보다 효율적으로 처리하도록 이동 의미론을 적용한다. 이러한 점만 고려하더라도 클래스를 정의할 때 이동 의미론을 지원하는 것이 좋다.

리턴값 최적화

객체를 값으로 리턴하는 함수는 임시 객체를 생성한다.
(예시 생략)
그런데 임시 객체에 대해 신경 쓸 일은 그리 많지 않다. 컴파일러가 최적화 과정에서 불필요한 복제나 이동 연산을 최소화하기 위해 임시 변수를 제거하기 때문이다. createPerson() 예제와 같은 경우에 수행하는 최적화를 NRVO(named return value optimization, 이름 있는 리턴값 최적화)라 부른다. 이 함수의 리턴문에서 이름 있는 변수를 리턴하기 때문이다.
리턴문의 인수로 이름 없는 임시값을 전달할 때 수행하는 최적화를 RVO(리턴값 최적화)라 부른다. 이러한 최적화 작업은 대부분 릴리스 모드로 빌드할 때만 수행한다. NRVO를 적용하려면 리턴문의 인수로 로컬 변수 하나만 지정해야 한다. 예컨대 다음과 같이 작성하면 NRVO를 수행하지 않는다.
Person createPerson() { Person person1; Person person2; return getRandomBool() ? person1 : person2; }
C++
NRVO와 RVO를 적용할 수 없다면 복제나 이동 연산이 발생한다. 함수에서 리턴할 객체가 이동 의미론을 지원한다면 복제가 아닌 이동 방식으로 전달된다.

미리 할당된 메모리

C++ 표준 라이브러리에서 제공하는 컨테이너의 대표적인 장점은 메모리를 알아서 관리해준다는 것이다. 컨테이너에 원소를 추가하면 크기를 자동으로 조절한다. 하지만 이로 인해 성능 저하가 발생하기도 한다. 예컨대 std::vector 컨테이너는 원소를 메모리 공간에 연달아 저장한다. 그러다 크기가 커져서 메모리가 부족하면 다시 전체 블록을 새로 할당해서 기존 원소를 모두 새 블록으로 이동하거나 복제한다. 이때 반복문 안에서 push_back()으로 vector에 추가되는 원소가 수백만 개나 된다면 성능이 눈에 띄게 떨어진다.
vector에 추가할 원소 수를 미리 알고 있거나 어느 정도 예측할 수 있다면 메모리 공간을 미리 할당한 뒤 원소를 추가하는 것이 좋다. vector에는 용량(capacity)와 크기(size)란 속성이 있는데, 용량은 재할당 없이 추가할 수 있는 원소 수를 의미하고, 크기는 컨테이너에 실제로 담긴 원소 수를 의미한다. reserve() 메서드로 용량을 지정하면 메모리 공간을 미리 할당할 수 있다. vector의 크기는 resize() 메서드로 조절할 수 있다.

inline 메서드와 inline 함수 활용하기

컴파일할 떄 inline 메서드나 inline 함수로 작성한 내용은 이를 호출하는 코드에 그대로 들어간다. 그래서 함수 호출 오버헤드가 발생하지 않는다. 이런 식으로 처리 하는 것이 유리한 함수나 메서드는 항상 inline으로 지정하는 것이 좋다. 하지만 이 기능을 남용하면 안 된다.
인터페이스와 구현을 반드시 구분해야 한다는 기본 디자인 원칙에 어긋나기 때문이다. 다시 말해 인터페이스를 변경하지 않고도 구현 코드를 독립적으로 개선할 수 있어야 한다는 원칙에 위배된다. inline 키워드는 자주 사용하는 핵심 클래스에 적용하는 것이 좋다. 또한 컴파일러는 inline 키워드를 단순히 참고만 할 뿐 인라인으로 처리하지 않을 수도 있다.
때로는 컴파일러가 최적화를 수행하는 동안에 inline 키워드를 지정하지 않은 함수나 메서드를 인라인으로 만들기도 한다. 심지어 그 함수가 헤더 파일이 아닌 소스 팡리에 구현돼 있더라도 그렇게 한다. 따라서 함수를 인라인으로 만들기 전에 반드시 컴파일러 매뉴얼에 이런 기능을 제공하는지 확인한다.

디자인 차원의 효율성

프로그램의 디자인 방식이 성능에 미치는 영향은 레퍼런스 전달 방식과 같은 언어 차원의 영향보다 훨씬 크다. (프로그램 병목의 80%는 전체 코드의 20% 부분이 차지한다는 것과도 맥락이 같다.) 예컨대 애플리케이션에서 가장 핵심적인 작업을 성능이 O(n)인 알고리즘 대신 O(n2) 알고리즘으로 처리하면 작업 시간이 제곱으로 늘어난다.
디자인 차원의 효율성에 관련된 기법은 다양하게 나와 있다. 또한 알고리즘을 잘 골라야 한다는 원칙보다 훨씬 구체적인 것도 많다. 데이터 구조나 알고리즘을 직접 구현하지 말고 C++ 표준 라이브러리나 부스트 라이브러리에서 제공하는 것처럼 전문가가 잘 만들어둔 것을 활용하는 것이 좋다.

최대한 캐싱하기

캐싱(caching)이란 자주 사용할 항목으 매번 가져오거나 다시 계산하지 않도록 저장해두는 것을 발견한다. 컴퓨터 하드웨어도 이 기법을 사용한다. 최신 컴퓨터 프로세서는 최근에 사용했거나 자주 사용하는 메모리값을 저장하는 캐시 메모리를 갖추고 있어서 메인 메모리보다 빠르게 접근할 수 있다. 한번 접근한 메모리는 조만간 다시 사용할 확률이 높다. 그래서 하드웨어 캐시를 이용하면 연산 속도를 크게 높일 수 있다.
소프트웨어적으로 제공하는 캐시도 효과는 비슷하다. 처리하는데 오래 걸리는 작업은 적을수록 좋다. 이런 작업을 수행한 결과를 메모리에 저장해두면 나중에 다시 활용할 떄 시간을 절약할 수 있다. 처리하는데 오래 걸리는 작업으로 다음과 같은 것들이 있다.
디스크 접근
프로그램에서 같은 파일을 여러 번 열거나 읽지 않도록 작성한다. 메모리가 넉넉하다면 자주 사용할 파일의 내용을 RAM에 저장해둔다.
네트워크 통신
네트워크로 통신하는 동안 발생하는 오버헤드는 예측할 수 없다. 네트워크 접근 연산도 파일 접근처럼 자주 사용하고 변하지 않는 내용은 최대한 캐시에 저장하는 것이 좋다.
수학 연산
매우 복잡한 연산을 수행한 결과를 여러 곳에서 사용한다면 계산은 한 번만 하고 그 결과를 공유하도록 구성한다. 하지만 계산이 그리 복잡하지 않다면 캐시에 넣어두고 쓰기보다 그냥 그때그때 계산하는 것이 나을 수 있다. 불분명하면 프로파일러로 확인해 본다.
객체 할당
수명이 짧은 객체를 많이 생성해서 사용해야 한다면 뒤에서 설명하는 객체 풀을 활용하는게 낫다.
스레드 생성
스레드 생성 과정은 오래 걸린다. 객체를 객체 풀에 캐싱하듯이 스레드도 스레드 풀(thread pool)에 캐싱하는 것이 좋다.
캐싱할 때 발생하는 대표적인 문제는 저장하는 데이터가 원본을 단순히 복제한 것이 많다는 것이다. 캐시에 저장된 원본 데이터는 변경될 수 있다. 예컨대 설정 파일의 내용을 매번 읽지 않도록 캐시에 저장할 수 있다. 그런데 프로그램을 구동하는 동안 사용자가 설정 파일을 변경할 수 있는데, 그러면 캐시에 담긴 내용이 무효가 된다. 이럴 때는 캐시 무효화(cache invalidation) 메커니즘을 적용해야 한다. 원본 데이터가 변겨오디면 캐시에 담긴 정보를 더는 사용하지 말거나 캐시 내용을 새로 업데이트 해야 한다.
캐시 무효화를 구현하는 한 가지 방법은 데이터가 변경됐다는 사실을 원본 데이터를 관리하는 모듈이 프로그램에 알려주도록 요청하는 것이다. 이는 현재 프로그램이 캐시 관리자에 콜백을 등록해 두면 된다. 아니면 캐시를 업데이트하는 이벤트를 주기적으로 보내게 할 수도 있다. 캐시 무효화 기법을 어떻게 구현하든 프로그램에서 캐시를 본격적으로 활용하기 전에 이러한 사항을 반드시 고려해야 한다.
Note) 캐시를 관리하기 위해서는 코드, 메모리, 처리 시간이 소요된다는 점을 명심한다. 게다가 캐시로 인해 찾기 힘든 버그가 발생할 수도 있다. 프로파일러에서 성능 병목점이라고 지적한 부분만 캐시에 담아야 한다. 무엇보다 코드를 간결하고 정확하게 작성한 뒤 프로파일러로 분석해서 필요한 부분만 최적화 한다.

객체 풀 활용하기

객체 풀의 종류는 다양하다. 그중 하나는 큰 메모리 영역을 한 번에 할당한 다음 그 안에 조그만 객체를 담는 객체 풀을 구성하는 것이다. 그래서 매번 객체를 할당하고 해제할 때마다 메모리 관리자를 일일이 호출할 필요 없이 객체 풀에 있는 객체를 프로그램에 제공하거나 다 쓴 객체를 재사용한다.
이 절에서는 객체 풀의 예를 소개한다. 수명이 짧고 타입이 같으면서 개수가 많은 객체를 사용하고 데이터를 저장할 공간을 크게 잡아둔 vector를 생성할 때처럼 그 객체의 생성자를 실행하는데 부담이 많으면서 프로파일러에서도 이 객체를 할당하고 해제하는 작업이 병목점이라고 지적한다면 객체를 풀 또는 캐시로 구성한다. 그래서 이러한 객체가 필요할 때마다 풀에서 하나씩 가져온다. 객체를 사용하고 나면 풀로 반환한다. 이렇게 사용하는 객체는 단 한 번만 생성된다. 그래서 객체를 사용할 때마다 생성자를 호출하지 않고 프로그램이 구동할 때 단 한 번만 호출된다.
따라서 객체에 필요한 설정 작업을 생성자에서 수행하고, 나중에 생성자가 아닌 메서드 호출을 통해 그 객체에 대한 매개변수를 설정할 수 있도록 구성된 경우에 객체 풀을 활용하면 적합하다.

객체 풀 구현 방법

이 절에서는 프로그램에 객체 풀을 제공하도록 객체 풀 클래스 템플릿을 구현하는 방법을 소개한다. 여기서 만들 객체 풀은 acquireObject() 메서드로 객체를 제공한다. acquireObject()를 호출했는데, 놀고 있는 객체가 하나도 없다면 객체를 새로 생성한다. acquireObject()는 커스텀 딜리터(deleter, 제거자)가 있는 std::shared_ptr 타입의 객체를 리턴한다. 여기에 있는 커스텀 딜리터는 실제로 메모리를 제거하지 않고, 객체를 다시 풀에 되돌려 놓기만 한다.
객체 풀을 구현할 때 가장 까다로운 부분은 현재 사용 중인 객체와 그렇지 않은 객체를 관리하는 것이다. 여기서는 사용 가능한 객체를 큐에 저장한다. 그래서 클라이언트가 객체를 요청할 때마다 풀은 큐의 최상단 객체를 제공한다.
이때 큐는 표준 라이브러리의 std::queue 클래스로 구현하는데, 표준 라이브러리의 데이터 구조를 사용하기 때문에 스레드에 안전하지 않다. 스레드에 안전하게 만드는 한 가지 방법은 락-프리 동시성 큐를 사용하는 것이다. 그런데 표준 라이브러리는 동시성을 지원하는 데이터 구조를 제공하지 않기 때문에 서드파티 라이브러리를 사용할 수 밖에 없다.
#include <queue> #include <memory> // 디폴트 생성자가 있는 모든 클래스에서 사용할 수 있는 객체 풀 // acquireObject()는 사용 가능 상태에 있는 객체를 리턴한다. // 사용할 객체가 남아 있지 않다면 새 인스턴스를 생성한다. 풀은 커지기만 할 뿐 줄어들지 않는다. // 다시 말해 풀이 제거되기 전에는 그 안에 있는 객체를 제거하지 않는다. // acquireObject()는 커스텀 딜리터가 있는 std::shared_ptr 객체를 리턴한다. // 그래서 shared_ptr가 제거될 때 레퍼런스 카운트가 0에 도달하면 자동으로 풀에 반환된다. // 풀에 있는 객체의 생성자와 소멸자는 객체를 사용할 때마다 호출되지 않고, 풀이 생성돼 제거될 때까지 단 한 번씩만 호출된다. // 객체 풀은 주로 여러 객체를 반복적으로 생성하고 삭제하지 않게 하는 용도로 사용된다. // 여기서 제공하는 객체 풀은 객체의 생성자를 짧은 기간에 실행하기에는 부담이 많으면서 // 프로파일러도 이런 객체를 생성하고 삭제하는 부분이 성능 병목점이라고 지적한 경우에 적용하면 좋다. template<typename T> class ObjectPool { public: ObjectPool() = default; virtual ~ObjectPool() = default; // 대입과 값 전달 방식을 막는다. ObjectPool(const ObjectPool<T>& src) = delete; ObjectPool<T>& operator=(const ObjectPool<T>& rhs) = delete; // acquireObject()가 리턴하는 스마트 포인터 타입 using Object = std::shared_ptr<T>; // 사용할 객체를 보관하고 제공한다. Object acquireObject(); private: // 현재 사용되지 않는 객체를 저장한다. std::queue<std::unique_ptr<T>> mFreeList; };
C++
이렇게 정의한 객체 풀을 사용할 때 반드시 객체 풀 자체의 수명이 그 안에 있는 객체보다 길어야 한다. 객체 풀 사용자는 생성할 객체의 클래스 이름을 템플릿 매개변수로 지정한다.
aquireObject()는 다음과 같이 가용 리스트의 최상단 객체를 리턴하도록 구현한다. 사용할 수 있는 객체가 없으면 새로 할당한다.
template<typename T> typename ObjectPool<T>::Object ObjectPool<T>::acquireObject() { if (mFreeList.empty()) { mFreeList.emplace(std::make_unique<T>()); } // 다음 차례에 제공할 가용 객체를 큐에서 빼서 로컬 unique_ptr로 옮긴다. std::unique_ptr<T> obj(std::move(mFreeList.front())); mFreeList.pop(); // 객체 포인터를 Object(커스텀 딜리터를 갖춘 shared_ptr)로 변환한다. Object smartObject(obj.release(), [this](T* t) { // 커스텀 딜리터는 메모리를 직접 해제하지 않고, 객체를 풀에 반환하기만 한다. mFreeList.emplace(t); }); // 객체를 리턴한다. return smartObject; }
C++

객체 풀 사용 방법

수명이 짧고 생성자의 실행 부담이 큰 객체를 많이 사용하는 애플리케이션의 예를 보자. 여기서 사용할 ExpensiveObject 클래스를 다음과 같이 정의한다.
class ExpensiveObject { public: ExpensiveObject() { /* ExpensiveObject construction ... */ } virtual ~ExpensiveObject() = default; // 객체에 특정한 정보를 담는 메서드 // 객체 데이터를 가져오는 메서드 // 코드 생략 private: // 데이터 멤버 };
C++
이렇게 정의한 수많은 객체를 프로그램을 실행하는 과정에서 수시로 생성하고 삭제하지 말고, 앞절에서 구현한 객체 풀을 활용하도록 다음과 같이 구현한다.
ObjectPool<ExpensiveObject>::Object getExpensiveObject(ObjectPool<ExpensiveObject>& pool) { // ExpensiveObject 객체를 풀에서 가져온다. auto object = pool.acquireObject(); // 객체의 내용을 채운다. 코드 생략 return object; } void processExpensiveObject(ObjectPool<ExpensiveObject>::Object& object) { // 객체를 적절히 처리한다. 코드 생략 } int main() { ObjectPool<ExpensiveObject> requestPool; { vector<ObjectPool<ExpensiveObject>::Object> objects; for (size_t i = 0; i < 10; ++i) { objects.push_back(getExpensiveObject(requestPool)); } } for (size_t i = 0; i < 100; ++i) { auto req = getExpensiveObject(requestPool); processExpensiveObject(req); } return 0; }
C++
여기서 main() 함수의 앞부분을 보면 ExpensiveObject 객체 열개를 생성해서 objects 컨테이너에 저장하는 작업을 내부 코드 블록으로 감쌌다. 생성된 객체를 모두 vector에 저장해서 유지하도록 구성했기 때문에 객체 풀에서 무조건 열 개의 ExpensiveObject 인스턴스를 생성해야 한다. 내부 코드 블록의 마지막 부분에 다다르면 vector의 스코프를 벗어나게 되면서 여기 담긴 객체는 자동으로 객체 풀에 반환된다.
두 번째 for 문을 보면 매번 for의 끝에 다다르면 getExpensiveObject()가 리턴하는 Object(=shared_ptr)가 스코프를 벗어나기 때문에 이 객체도 자동으로 풀로 반환된다. ExpensiveObject 클래스의 생성자에 콘솔 출력문을 추가해보면 main()에 있는 for 문이 100번 반복해도 프로그램을 구동하는 전체 기간 동안 각 객체의 생성자는 단 한 번씩 총 10번만 호출된다는 것을 확인할 수 있다.

프로파일링

(생략)