19.1 Modules
C(또는 다른 언어로 된) 프로그램을 디자인할 때, 프로그램을 독립적인 모듈 여러개로 보는 것은 유용한 시각.
module: '서비스'의 모음으로, 그 중 일부는 프로그램의 다른 부분(the clients)에서 사용 가능함. 각각의 모듈은 interface(사용 가능한 서비스를 설명)를 가지고 있다. 모듈의 자세한 사항 - 서비스의 소스 코드 포함 - 은 모듈의 implementation 내에 저장된다.
C의 맥락에서는..
service: 함수들
interface: clients(source files)에서 사용하게 될 함수들의 prototype이 담긴 헤더 파일
implementation: 모듈에 있는 함수의 정의가 담긴 소스 파일
15.1, 15.2에서 다룬 계산기 프로그램을 예로 들면, (calc.c, stack.h, stack.c를 포함) calc.c는 stack 모듈의 client이고, stack.h는 모듈의 interface, stack.c는 모듈의 implementation
C 라이브러리는 모듈의 집합이다. 각각의 헤더는 모듈의 interface로 기능함. <stdio.h>는 I/O 함수들이 포함된 모듈의 interface, <string.h>는 문자열을 다루는 함수들이 담긴 모듈의 interface
프로그램을 모듈로 분할하는 것의 장점:
Abstraction: 모듈이 적절하게 디자인되었다면, 그것을 abstractions(추상화)로 간주할 수 있다. 우리는 그것이 어떤 기능을 하는지는 알지만, 그 자세한 부분까지 알아야 할 필요가 없다. 추상화 덕분에, 한 부분을 바꾸기 위해서 프로그램의 모든 전체 부분을 이해하지는 않아도 된다. 추상화는 팀이 한 프로그램을 작업할 때도 도움이 된다. 모듈의 interface들에 대한 합의가 있다면, 각각의 모듈의 implement를 만드는 책임은 특정 개인에게 위임된다. 그렇기 때문에 팀의 멤버들은 서로에게서 독립되어 일할 수 있다.
Reusability: 서비스를 제공하는 모든 모듈은 잠재적으로 다른 프로그램에서 재사용될 수 있다. 예를 들어 stack 모듈도 재사용할 수 있다. 모듈을 처음 만들 때 재사용성을 염두에 두면 좋다.
Maintainability: 작은 버그는 보통 한개의 module implmentation에만 영향을 미치므로, 버그를 찾아서 고치기 쉬워진다. 버그가 고쳐진 후에 프로그램을 rebuild하는 것은 해당되는 모듈 implementation에만 국한된다. (그리고 모든 프로그램을 link해야 함) 더 큰 관점에서는 프로그램을 다른 플랫폼으로 이식하거나 퍼포먼스를 향상시키기 위해 한 모듈 implementation 전체를 바꿀 수도 있다.
셋 다 중요하지만 그 중 가장 중요한 것은 maintainability. 현실의 대부분의 프로그램들은 몇년동안 서비스되는데 도중에 버그가 발견되고, 발전이 이뤄지고, 변화되는 요구에 맞춰 수정이 이루어진다. 프로그램을 모듈화된 방법으로 만들면 유지가 용이해진다.
Cohesion and Coupling
좋은 모듈 인터페이스는 선언들을 아무렇게나 모아놓은 것이 아니다. 잘 디자인된 프로그램에서 모듈은 두가지 특성을 가져야 한다.
High cohesion(화합, 결합, 응집력): 각각의 모듈의 원소들은 서로가 밀접하게 연관되어 있어야 한다; 공통의 목표를 향해 협력하는 것들로 생각해야 한다. 높은 응집력은 모듈 사용과 프로그램에 대한 이해를 쉽게 만들어 준다.
Low coupling(연결, 결합): 모듈은 서로 다른 모듈과 가능한 한 독립적이어야 한다. 낮은 연결성?은 프로그램 수정과 모듈 재사용을 쉽게 만들어 준다.
Types of Modules
high cohesion, low coupling의 필요성으로 인해 모듈은 전형적인 카테고리로 분류되는 경향이 있다.
data pool: 관련된 변수나 상수들의 모음이다. C에서 이 타입의 모듈은 보통 하나의 헤더 파일이다. 디자인의 관점에서 헤더 파일에 변수를 넣는 것은 보통 좋은 생각이 아니지만, 연관되어 있는 상수들을 하나의 헤더 파일에 모아 넣는 것은 유용할 때가 많다. C 라이브러리에서 <float.h>와 <limits.h>가 data pool이다.
library: 연관된 함수의 모음이다. <string.h> 헤더는 문자열을 다루는 함수들의 라이브러리의 인터페이스이다.
abstract object: 숨겨진 데이터 구조를 다루는 함수들의 모음이다. (이 챕터에서 쓰이는 'object' 는 책의 다른 부분에서 쓰인 'object'와 의미가 다르다. C 용어에서 object는 단순히 어떤 값을 저장하는 메모리 블록을 말한다. 하지만 이 챕터에서 쓰이는 object는 데이터의 모음, 그리고 데이터에 대한 작업을 묶은 말이다. 만약 데이터가 숨겨져 있다면, 그 object는 "abstract"하다.) stack 모듈은 이 카테고리에 속한다.
abstract data type(ADT): 표현이 숨겨진 형식이다. 클라이언트 모듈은 이 형식의 변수를 선언할 수는 있지만 그 변수가 어떻게 정의되어 있는지 알지 못한다. 클라이언트 모듈이 해당 변수에 대한 작업을 수행하려면 ADT에서 제공되는 함수를 호출해야 한다.
19.2 Information Hiding
잘 디자인된 모듈에서는 종종 몇몇 정보를 클라이언트에게는 비밀로 한다. 예를 들어 stack 모듈에서 클라이언트는 스택이 배열에 저장되는지, 연결리스트에 저장되는지, 혹은 다른 형태로 저장되는지 알 필요가 없다. 모듈이 클라이언트로부터 의도적으로 정보를 숨기는 것을 information hiding이라고 한다. 이것의 두가지 장점은:
Security: 클라이언트가 스택이 어떻게 저장되는지 모른다면, 클라이언트는 스택의 내부 작업에 쓸데없이 참견해서 망치는 일이 일어나지 않는다. 스택에 대한 작업을 수행하려면, 클라이언트들은 모듈 내부의 함수 -이미 검증된 것들- 를 통해서만 할 수 있다.
Flexibility: 모듈의 내부 작업 내용을 변경(그것이 크든 작든)하는 것이 어렵지 않다. 예를 들어, 스택을 처음에는 배열로 만들었다가, 연결리스트나 다른 형태로 바꿀 수 있다. 당연히 모듈의 implementation은 재작성해야 하겠지만, 모듈이 제대로 디자인 되었다면 모듈의 인터페이스는 변경하지 않아도 된다.
C에서 imformation hiding을 강화하는 주된 방법은 static storage class를 이용하는 것이다. file scope를 갖는 변수를 static으로 선언하면 internal linkage를 가지므로 모듈의 클라이언트들을 포함해 다른 파일들에서 접근할 수 없다. static으로 함수를 선언하면 같은 파일에 있는 함수들에서만 그 함수를 호출할 수 있다.
19.3 Abstract Data Types
위에서 살펴본 스택 module과 같은 abstract object 모듈의 가장 큰 단점은 해당 object에 대한 복수의 개체를 만들 수 없다는 것이다. 이걸 하기 위해서는 한걸음 더 나아가서 새로운 type을 만들어야 한다.
Stack type을 정의하게 되면 원하는 만큼의 stack을 만드는 것이 가능하다.
Stack s1, s2;
make_empty(&s1);
make_empty(&s2);
push(&s1, 1);
push(&s2, 2);
if (!is_empty(&s1))
prtinf("%d\n", pop(&s1)); /* prints "1" */
이 프로그램 조각은 한 프로그램 내에서 서로 다른 두개의 스택을 사용하는 것을 나타낸다. 우리는 s1과 s2가 정확히 어떤 타입인지(배열, 구조체?) 모르지만, 상관이 없다. 클라이언트에게 s1과 s2는 특정한 operations(make_empty, is_empty, is_full, push, and pop)에 응답하는 abstractions이다.
stack.h에서 Stack type을 제공하도록 변경하기
#define STACK_SIZE 100
typedef struct {
int contents[STACK_SIZE];
int top;
} Stack;
void make_empty(Stack *s);
bool is_empty(const Stack *s);
bool is_full(const Stack *s);
void push(Stack *s, int i);
int pop(Stack *s);
make_empty, push, pop 함수의 스택 파라미터는 포인터가 되어야 하는데, 해당 함수들은 스택을 변경하기 때문이다. is_empty와 is_full 함수의 파라미터는 포인터일 필요는 없지만 포인터로 했다. Stack 대신 Stack 포인터를 함수에 패스하는 것이 더 효율적이다. Stack을 직접 전달하는 것은 구조체의 복사를 수반하기 때문이다.
Encapsulation
Stack은 abstract data type은 아니다. stack.h에 Stack type의 정체가 드러나 있기 때문이다. 클라이언트에서 Stack 변수를 구조체로 사용하는 것을 막을 수 없다.
Stack s1; s1.top = 0; s1.contents[top++] = 1;
클라이언트에서 top과 contents에 접근할 수 있기 때문에, stack을 망치는 것을 방지할 수 없다. 그리고 클라이언트에서 스택에 접근할 수 없게 하면서 위와 같은 방식으로 저장하는 것을 동시에 할 수 없다. 새로운 C 기반의 언어들(C++, Java, C#)에서는 encapsulation을 더 잘 지원하지만, C에서는 제한된 방식으로만 type을 encapsulate할 수 있다.
Incomplete Types
C에서 encapsulation을 위해 제공하는 유일한 도구는 incomplete type이다. C 표준에서는 incomplete type을 다음과 같이 설명한다: types that describe objects but lack information needed to determine their sizes.
struct t; /* incomplete declration of t */
위 선언은 컴파일러에게 t가 structure tag라는 것은 알려주지만, 구조체의 멤버에 대해선 알려주지 않는다. 그 결과 컴파일러에겐 이 구조체에 대한 크기를 결정할 충분한 정보가 없다. 그 의도는 프로그램의 다른 어떤 곳에서 그 incomplete type이 완성되는 것이다.
type이 incomplete한 동안, 그 사용은 제한되어 있다. 컴파일러가 incomplete type의 크기를 알 수 없으므로, 변수를 선언할 수도 없다. 하지만 incomplete type에 대한 포인터를 정의하는 것은 legal하다.
struct t s; /*** WRONG ***/
typedef struct t *T; /*** LEGAL ***/
이 타입 정의는 type T의 변수가 tag t를 가진 구조체의 포인터라는 것을 나타낸다. 이제 우리는 type T를 가진 변수를 선언해서, 그것을 함수의 인자로 사용하고, 다른 포인터 관련 연산을 수행할 수 있다. (포인터의 크기는 그것이 가리키는 것과는 무관하다. 때문에 C도 이런 것을 허용한다) 다만 컴파일러는 t 구조체의 멤버에 대해서는 아는 것이 없으므로 -> 연산은 수행할 수 없다.
19.4 A Stack Abstract Data Type
19.5 Design Issues for Abstract Data Types
19.4에선 스택 ADT를 소개하고 그것을 구현하는 몇가지 방법을 다뤘다. 하지만 이 ADT는 실제로 적용되기에는 몇가지 문제점을 지니고 있다. 그 문제점들과 해결책을 알아본다.
Naming Conventions
스택 ADT의 함수들은 짧고 이해하기 쉬운 이름을 가지고 있다. create, destroy, make_empty, is_empty, is_full, push, pop 등. 만약 우리가 하나 이상의 ADT를 프로그램에 가지고 있다면 서로 다른 ADT에 있는 서로 다른 함수들의 이름이 충돌할 가능성이 매우 높다. 따라서 함수 이름에 ADT의 이름을 포함시킨다. ex) create 대신 stack_create
Error Handling
스택 ADT에서 에러에 대처한 방법은 에러 메시지를 띄우고 프로그램을 종료시키는 것이었다. 하지만 프로그램을 종료시키지 않고 에러로부터 프로그램을 복구할 수 있는 방법을 마련하고 싶을 수 있다.
대안은 push와 pop 함수가 성공했는지 실패했는지 여부를 bool 값으로 리턴하게 하는 것이다. push는 기존 형식이 void였으므로 push 작업이 성공했을 때 true를 리턴하게 하는 것은 쉽다. pop 함수의 경우 기존 형식이 int이고 pop된 값을 리턴하도록 되어 있었지만, pop이 값의 포인터를 리턴하도록 하고, 실패했을 경우 NULL 포인터를 리턴하게 하면 된다.
C 표준 라이브러리에서는 assert라는 parameterized macro를 제공해 특정 조건이 만족되지 않았을 경우 프로그램을 종료하도록 할 수 있다. 스택 ADT 내에 있는 if문들과 terminate 호출을 이 매크로로 대체할 수 있다.
Generic ADTs
스택에 저장되는 아이템의 형식을 쉽게 바꿀 수 있게 되었다(typedef으로 정의한 Item의 형식만 바꾸면 된다). stack.h를 수정하지 않고 stack이 모든 형식을 받아들이면 더 좋을 것이다. 또한 스택 ADT에 심각한 결점이 있는데 프로그램에서 스택 여러개를 만들더라도 각각 다른 형식의 아이템을 받아들이지는 못한다는 것이다.
하나의 "gerenic" 스택 타입을 만들어서 integer의 스택, 문자열의 스택 등등을 만들 수 있다. 가장 흔한 방법은 item type으로 void *를 사용해서 임의의 포인터를 push하고 pop하는 것이다. 이때 stackADT.h에서 push와 pop 함수는 다음과 같은 원형을 갖는다.
void push(Stack s, void *p);
void *pop(Stack s);
pop은 스택에서 pop된 아이템의 포인터를 리턴하고, 스택이 비어있는 경우엔 NULL 포인터를 리턴한다.
하지만 void *를 아이템의 형식으로 사용할 때 두가지 단점이 있다. 첫째로 이 방법은 포인터 형태로 나타낼 수 없는 데이터에 사용할 수 없다는 것이다. 아이템이 문자열(문자열의 첫 글자를 가리키는 포인터로 표현), 동적할당된 구조체가 될 수는 있지만 int나 double같은 basic type이 될 수 없다.
두번째로 더 이상 에러를 체크할 수 없다. void * 아이템을 저장하는 스택은 서로 다른 형식의 포인터들을 아무 문제 없이 받아들이므로, 잘못된 형식을 가리키는 포인터를 push해서 생기는 에러를 탐지할 수 없다.
ADTs in Newer Languages
위에서 다른 문제점들은 새로운 C 기반의 언어들, C++, Java, C# 등에서는 더 명확히 다루어진다. 이름의 충돌 문제는 class 내에서 함수를 정의함으로써 방지된다. stack ADT는 Stack class로 표현된다; stack 함수들은 이 클래스 내에 소속되며 Stack object에 적용된 경우에만 컴파일러에서 인식한다. 또 이런 언어들은 exception handling 기능을 적용해서 push나 pop같은 함수에서 에러가 발생한 경우 예외를 "throw"할 수 있다. client 내에 있는 코드에서는 이런 예외를 "catch"해서 에러에 대처할 수 있다. generic ADT의 경우 C++를 예로 들면 stack template를 정의해서 아이템 타입을 미지정할 수 있다.