프로그래밍 이야기

A Tour of C++ : 3장 모듈화

원소랑 2019. 10. 8. 02:14

.

.

3. 모듈화

3.1 소개

C++ 프로그램은 독립적으로 개발된 여러 부분으로 구성.

함수, 사용자 정의 타입, 클래스 계층 구조, 템플릿 등.

핵심 = 구송 요소들의 상호작용을 명확하게 정의하는 것.

첫 단계 = 인터페이스와 구현을 분리

3.2 분할 컴파일

사용할 타입과 함수의 선 : 사용할 타입과 함수의 정의

각각 분리된 파일에 존재, 따로 컴파일할 수 있어 프로그램을 반독립적(semiindependent) 코드 조각 집할들로 조직화.

이런 분리는 컴파일 시간 최소화, 논리적 구분/분리를 강제 (에러 소지도 줄어듬)

분할 컴파일된 코드 조각을 흔히 라이브러리라고 부르기도

vector.h

vector.cpp, user.cpp

vector.h의 정의와 인터페이스는 공유하지만, 두 cpp 파일은 따로 컴파일. 이 단위를 “변환 단위”(translation unit)로 부르고, 수천 개의(혹은 더 많은) 변환 단위가 한 프로그램을 구성.

3.3 모듈 (C++ 10)

#include 는 매우 오래되고, 에러의 소지가 크고 비용도 큼. include 는 횟수만큼 텍스트 처리를 해야하고, 순서에도 민감하고 영향을 줌. C에서 이 방식을 채용한 1972년 이후로 이슈가 돼왔음.

module 언어 기능, 아직 표준은 아니지만, 기술명세에 포함됨. 세부 사항은 바뀔 수 있고, 제품 수준의 코드에서 사용하려면 오래걸릴 수 있지만 추천.

===== vector.cpp (pseudo)

module;

export module Vector;

export class Vector {

public:

// methods

}

rettype Vector::DoSomething() {

// Do something

}

export int size(const Vector& v) { return v.size(); }

===== user.cpp

Import Vector;

double sqrt_sum(Vector& v)

{

// Do something

return result;

}

=====

표준 라이브러리 수학 함수도 import 할 수 있다.

#include, module 차이점.

- 모듈은 한 번만 컴파일 된다 (모든 변환 단위마다 컴파일 되지는 않는다)

- 두 모듈을 import 하는 순서가 코드 의미에 영향을 주지 않는다.

- 임포트한 것에 대한 암묵적인 접근 권한을 갖거나 조작할 수 없다. import 에는 전이성이 없다.

(? 전이성이 없다는 것이 무슨 뜻인지 잘 이해가 안된다)

3.4 네임스페이스

namespace { ... } / using ...; / using namespace ...;

3.5 에러 처리

방대하고 복잡한 주제.

( 언어의 기능, 기법, 도구 등을 아우름)

C++ 의 타입 시스템, 실수를 줄이고 컴파일러 에러탐지 가능성 높임.

추상화를 통해 런타임 에러 탐지 지점과 에러 처리 지점을 분리 가능. 특히, 라이브러리를 많이 사용할수록 에러 처리 표준이 중요, 개발 초기에 에러 처리 전략 고려 필요.

RAII ( Resource Acquisition Is Initialization ) = 자원 획득이 곧 초기화

3.5.1 예외

throw 는 현재 함수를 호출하는 임의의 함수에 존재하는 예외 핸들러에 제어권 넘김. 호출자의 컨텍스트에 다다를 때까지 함수 호출 스택을 거슬러 올라감.

try { ... (throw Exception();) }

catch ( Exception& e ) // Exception 객체 복사를 피하기 위한 참조 선언

{

// Do something

}

try구문을 지나치게 많이 사용하기보다는, 체계적인 에러 처리가 필요.

함수에 noexcept 키워드를 붙여서, 절대 예외를 던지지 못하도록 선언할 수 있고, 이 경우엔 std::terminate() 가 호출돼 프로그램이 즉시 종료됨.

3.5.2 불변 조건

out of range 예외 사용방식은, 사전 조건(precondition)이 성립하지 않으면 작동을 거부하는 방식의 한 예. [a:b) 는 반개구간(half-open range). a는 포함되지만 b는 구간에 포함되지 않음을 의미. 함수 정의는 사전 조건이 무엇이며, 그 조건을 체크해야 하는지를 고려해야 함.

클래스 수준에서 보장돼야 할 조건 = 클래스 불변 조건(class invariant). 멤버 함수가 의지할 수 있게 불변 조건을 보장하는 것은 생성자의 역할, 멤버 함수는 스스로가 종료될 때 조건 성립 확인 필요. 전달되는 인자가 유의미한지 확인.

try {

// Do Something

} catch ( std::length_error& err ) {

// Do Something

throw; // 예외 다시 던지기도 가능.

} catch ( std::bad_alloc& err ) {

// Do Something

}

// 하지만, 잘 설계된 코드에서는 try 블록을 드물게 사용, RAII 기법을 체계적으로 사용.

불변 조건은 클래스 설계의 핵심적 역할. 함수의 사전 조건과 비슷.

- 스스로 정확히 무엇을 원하는지 이해를 돕는다

- 좀 더 구체적이 되도록 강제, 올바른 코드를 유지하는 데 도움

3.5.3 여러 가지 에러 처리 방식

모든 소프트웨어 설계의 에러 처리는 중요한 주제, 다양하다.

에러의 국지적 처리가 어렵다면, 호출자와 문제에 대해 소통해야함. 일반적으론 예외 던지기. C++의 예외 던지기는 작업 완료를 위해 실패 보고 용도로 사용하게 설계. 예외 던지기보다는 값으로 반환하는 비용이 더 작도록 컴파일러 설계.

에러 코드(지시자)를 반환하는 경우

- 실패가 흔하고 예상 가능한 경우. (파일 열기 등)

- 직접적인 호출자의 실패 처리에 대한 기대가 합리적인 경우

예외를 던지를 경우

- 매우 드물게 발생해서 프로그래머가 에러 확인 가능성이 낮은 경우. (printf()의 반환값 확인은 거의 안 하지)

- 호출자가 에러 처리가 어려운 경우, 상위 호출자에게 에러 전달 필요. (할당 실패, 네트워크 실패를 모든 함수에서 처리하긴 어렵다)

- 하위 모듈에 새로운 종류 에러 추가 가능성이 있을 경우

- 에러 코드 반환 경로가 적당하지 않은 경우. (생성자는 반환이 없으니)

- 값과 에러값 모두 반환해야 하는 경우 (리턴 데이터를 별도로 구성하려면 복잡해짐)

- 호출 체인을 통해 에러를 최상위 호출자에게 전달해야하는 경우

- 에러 복구가 여러 함수 호출 결과에 의존하는 경우 (제어 구도가 복잡해 수 있으니)

- 에러 탐지한 함수가 콜백인 경우, 직접 호출자를 알기 어려울 때

- 에러에 대해 ‘작업 취소’가 필요한 경우

프로그램을 종료하는 경우

- 복구할 수 없는 경우 (메모리 고갈)

- 심각한 에러

미심쩍다면 예외를 사용하라, 확장성 높고 모든 에러 처리가 간편. 예외 처리가 느리다는 잘못된 믿음도 버리자. 에러 조건/에러 코드를 반복 확인하는 것보다 따른 경우도 많다. 간단하고 효과적인 에러 처리를 위해서는 RAII 필수적. try 블록으로 코드를 더럽히는 것도 지양.

3.5.4 계약

현재 C++표준엔 불변 조건과 사전 조건을 실행 시간에 선택적 테스트 방법이 없다.

C++20 에 계약(Contract) 메커니즘 제안됨. 테스트 시에는 엄격하게, 배포 시에는 최소한의 확인만 하도록. 현재로선 assert 나 체크용 매크로가 임시 방편.

3.5.5 정적 어써션

컴파일 시간에 찾을 수 있는 에러는 그렇게 해야함.

static_assert 는 상수 표현식으로 표현할 수 있는 모든 것을 사용 가능.

static_assert( 4 <= sizeof(int), “정수가 너무 작음” );

조건이 false일 경우, 컴파일러 에러 메시지로 출력.

제네릭 프로그래밍의 파라미터로 사용되는 타입에 대한 어써션을 static_assert 로 검사하면 유용

3.6 함수 인자와 반환 값

함수간 정보 전달

함수 인자로 전달, 전역 변수, 포인터와 참조 파라미너, 클래스 멤버변수 등. 전역변수는 에러의 주요 원인, 사용 피해야.

3.6.1 인자 전달

기본적으론 복사 (값에 의한 전달), 참조하고싶으면 참조전달. 성능상 작은 객체는 값으로, 큰 객체는 참조로. 작다는 의미는 머신 아키텍처 따라 다름. 일반적으론 ‘포인터 두세 개 정도 크기 이하’ 정도.

참조 전달이더라도, 변경 필요가 없다면 const 참조로. 빠르고 에러 소지 없다. 기본 함수 인자를 사용해서 오버로딩을 줄일 수도 있다.

3.6.2 값 반환

크기가 매우 큰 Matrix의 경우, operator+ 에 이동 생성자(move constructor)를 정의해서 복사가 발생하지 않도록 처리. 동적 생성한 포인터 리턴은 찾기 어려운 에러를 만드는 주요 원인.

auto 반환은 타입을 유추하라는 의미, 안정적인 인터페이스 제공이 아니으로 유의. 제네릭 함수와 람다에선 유용.

3.6.3 구조화된 바인딩

Entry MyFunction() {

// Blah

return { s, i };

}

auto e = MyFunctio();

auto [n,v] = MyFunction(); // 구조화된 바인딩(structured binding)

map<string, int> m;

for (const auto& [key, value] : m)

Do Something;

const 나 &도 붙일 수 있음. private멤버가 없고, 멤버 수가 같아야 함.

코드의 문서화 측면에선 명명된 반환 타입을 이용하는 것이 더 낫다.

3.7 조언

[1] 선언과 정의를 구분하라. 3.1

[9] 주어진 작업을 수행할 수 없다면 예외를 던지자. 3.5

[10] 예외는 에러 처리 목적으로만 사용하자. 3.5.3

[11] 직접적인 호출자가 에러처리 할 것이라면 예외코드 사용. 3.5.3

[12] 에러로 인해 많은 콜을 거슬러 올라가야 한다면 예외처리 3.5.3

[13] 예외와 에러코드 중 고민될 때는 예외 3.5.3

[14] 설계 초기부터 에러 처리 전략 수립 3.5

[15] 목적에 맞게 설계된 사용자 정의 타입을 예외로 사용 3.5.1

[16] 모든 함수에서 에러를 잡으려 하지 말자

[20] 불변 조건을 토대로 에러 처리 전략 설계

[23] 그냥 참조 전달보단 const 참조 전달

카페에서 책 읽으며 정리할 때 눈 앞 모습

.

.

728x90
반응형