Object Pascal · 기본
Object Pascal 첫걸음
유닛 - interface 와 implementation
unit 구조, interface 와 implementation 구획, uses 순환 피하기, initialization·finalization, 여러 파일 빌드
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서는 Format 과 SysUtils 로 문자열을 만드는 방법을 배웠다. 이제 카페 계산대 프로그램은 메뉴 이름, 가격, 주문 합계, 기록 출력을 모두 갖췄고, 그 코드가 전부 main.pas 한 파일에 들어 있다. 이 장에서는 그 코드를 역할별로 나눠 여러 파일로 옮긴다. Pascal 에서 파일 하나에 해당하는 코드 묶음을 유닛(unit)이라고 부른다. 유닛은 밖에 보여 줄 것과 감출 것을 문법으로 구분하고, 자기 일을 시작하고 마칠 때 실행할 코드를 따로 둘 수 있다.
- unit 파일의 뼈대와 interface·implementation 두 구획이 각각 무엇을 담는지 설명한다.
- uses 절을 어느 구획에 적어야 하는지 구분하고, 유닛끼리 서로를 부르는 순환 참조를 피한다.
- initialization 과 finalization 이 실행되는 시점과 순서를 예측한다.
- fpc 로 여러 파일을 한 번에 빌드하고, 컴파일 후 생기는 파일의 용도를 안다.
문제 상황
카페 주인이 메뉴에 케이크를 추가해 달라고 한다. main.pas 를 열었더니 메뉴 이름 배열, 가격 배열, 주문 합계 계산, 기록 출력이 한 파일에 섞여 있고 길이는 이미 수백 줄이다. 가격 하나를 바꾸려고 파일을 훑다가 합계 계산 함수의 지역 변수 이름을 잘못 건드리기 쉽다.
이름이 겹치는 일도 생긴다. 주문 합계를 Total 이라 부르고, 일일 정산 합계도 Total 이라 부르고 싶어진다. 한 파일 안에서는 같은 이름을 두 번 쓸 수 없으니 OrderTotal, DayTotal 처럼 억지로 길게 쓰게 된다. 정산 담당 코드가 주문 코드의 내부 배열을 직접 읽어 버리면, 나중에 주문 코드의 내부 구조를 바꿀 때 정산 코드까지 고쳐야 한다.
유닛으로 나누면 이 문제를 줄일 수 있다. 메뉴는 cafemenu.pas, 주문은 cafeorder.pas, 기록은 cafelog.pas 로 분리하고, 각 파일은 다른 파일이 써도 되는 것만 공개한다. 이 장의 예제는 이 세 유닛과 main.pas 로 구성된다.
유닛의 구조: interface 와 implementation
유닛의 뼈대
유닛 파일은 정해진 순서의 구획으로 이루어진다. 구획 순서는 바꿀 수 없다.
{$mode objfpc}{$H+}
unit 유닛이름;
interface
{ 밖에 공개하는 선언 }
implementation
{ 구현과 비공개 항목 }
initialization
{ 선택: 처음 로드될 때 한 번 실행 }
finalization
{ 선택: 프로그램이 끝날 때 한 번 실행 }
end.
파일 첫머리의 {$mode objfpc}{$H+} 는 앞 장까지와 같이 모든 파일에 적는다. 이 지시문은 파일마다 따로 적용되므로 main.pas 에만 적고 유닛 파일에서 빼면 유닛은 다른 모드로 컴파일된다. 마지막 줄은 end. 이고 마침표가 붙는다. initialization 과 finalization 은 없어도 되며, finalization 은 initialization 이 있을 때만 둘 수 있다.
유닛 이름은 파일 이름과 맞춰야 한다. unit CafeMenu; 는 cafemenu.pas 에 둔다. Pascal 은 대소문자를 구분하지 않지만 Linux 의 파일 시스템은 구분하므로, 파일 이름을 모두 소문자로 쓰면 어느 환경에서나 같은 방식으로 찾는다.
두 구획이 담는 것
interface 구획은 유닛의 공개 안내문이다. 여기에는 프로시저와 함수의 머리(이름, 매개변수, 반환 타입)만 적고, 본문은 적지 않는다. 타입, 상수, 변수도 이 구획에 적으면 밖에서 보인다. implementation 구획은 실제 본문과 비공개 항목을 둔다. 여기에 선언한 상수, 변수, 타입, 함수는 같은 유닛 안에서만 보인다.
| 구획 | 적는 것 | 다른 파일에서 |
|---|---|---|
| interface | 공개할 타입·상수·함수 머리 | 보인다 |
| implementation | 함수 본문, 비공개 상수·변수·타입 | 보이지 않는다 |
| initialization | 시작할 때 실행할 문장 | 직접 부를 수 없다 |
| finalization | 끝날 때 실행할 문장 | 직접 부를 수 없다 |
이 장의 CafeOrder 유닛에서는 주문 줄을 담는 레코드와 배열을 implementation 에 두었다. main.pas 는 AddLine 과 OrderTotal 만 부를 수 있고, 배열을 직접 읽거나 고칠 수 없다. 나중에 배열을 다른 자료 구조로 바꾸더라도 interface 의 함수 머리가 같으면 main.pas 는 고칠 필요가 없다.
implementation 에서 함수 본문을 쓸 때는 interface 의 머리와 같은 이름, 같은 매개변수, 같은 반환 타입으로 적어야 한다. 다르면 컴파일러가 선언을 찾지 못한다는 오류를 낸다.
uses 와 순환 피하기
uses 는 두 곳에 쓸 수 있다
다른 유닛의 이름을 쓰려면 uses 절에 그 유닛을 적는다. 유닛에서는 uses 를 interface 아래와 implementation 아래 두 곳에 적을 수 있다. 기준은 간단하다. interface 의 선언문 안에서 그 유닛의 타입을 쓰면 interface 쪽에 적는다. 본문에서만 쓰면 implementation 쪽에 적는다.
CafeOrder 의 AddLine 은 매개변수 타입으로 CafeMenu 의 TMenuCode 를 쓰므로 interface 쪽에 uses CafeMenu; 가 필요하다. 반면 기록을 남기는 CafeLog 는 본문 안에서만 부르므로 implementation 쪽에 둔다.
uses 는 전달되지 않는다. main.pas 가 CafeOrder 를 쓴다고 해서 CafeOrder 가 쓰는 CafeMenu 의 이름까지 main.pas 에서 보이지는 않는다. main.pas 가 mcAmericano 를 직접 쓰려면 main.pas 에도 CafeMenu 를 적어야 한다.
순환 참조
유닛 A 의 interface 가 B 를 쓰고 B 의 interface 가 A 를 쓰면 컴파일러는 어느 쪽을 먼저 처리해야 할지 정할 수 없다. 이것을 순환 참조(circular reference)라고 한다. 컴파일러는 "Circular unit reference between …" 와 같은 오류를 낸다.
순환은 보통 이런 때 생긴다. 메뉴 유닛이 주문 수를 알고 싶어서 CafeOrder 를 쓰고, 주문 유닛은 이미 CafeMenu 를 쓰고 있다. 해결 방법은 세 가지다.
- uses 를 implementation 쪽으로 옮긴다. 두 유닛의 implementation 은 서로를 참조해도 되는 경우가 많다. 다만 이것은 급한 해결이고, 구조 자체가 꼬였다는 신호일 수 있다.
- 둘이 함께 쓰는 타입이나 상수를 새 유닛으로 뽑는다. 두 유닛이 새 유닛을 쓰면 화살표가 한 방향이 된다.
- 필요한 값을 매개변수로 넘긴다. 이 장의 CafeLog 는 CafeOrder 를 모른다. 주문 번호를 로그에 넣고 싶으면 CafeOrder 가 문자열로 만들어서
WriteLog에 넘긴다.
설계할 때는 그림처럼 의존 관계를 위에서 아래로 그려 본다. 아래쪽 유닛(메뉴, 기록)은 위쪽 유닛(주문, 메인)을 모르는 구조가 되면 순환이 생기지 않는다.
initialization, finalization 과 여러 파일 빌드
initialization 과 finalization
유닛의 initialization 구획은 프로그램이 본문 begin 에 들어가기 전에 한 번 실행된다. 전역 변수의 시작 값을 정하거나 준비 작업을 할 때 쓴다. finalization 구획은 프로그램 본문이 끝난 뒤 한 번 실행되고, 정리 작업과 마무리 출력을 둔다.
실행 순서에는 규칙이 있다. 어떤 유닛이 다른 유닛을 쓰면 쓰이는 쪽이 먼저 초기화된다. 종료는 정확히 반대다. 이 장의 예에서 CafeOrder 는 implementation 에서 CafeLog 를 쓰므로 초기화는 CafeLog, CafeOrder 순서이고, 종료는 CafeOrder, CafeLog 순서다. 이렇게 되어야 CafeOrder 의 finalization 이 실행될 때 CafeLog 가 아직 살아 있다. 서로 의존 관계가 없는 유닛 사이의 순서는 보장되지 않으므로, 그 순서에 기대는 코드는 쓰지 않는다.
initialization 은 가벼운 일만 맡긴다. 이 장의 예제는 설명을 위해 화면에 한 줄씩 출력하지만, 실제 프로그램에서는 변수 초기화 정도가 적당하다. 오래 걸리거나 실패할 수 있는 일을 여기에 두면 프로그램 본문이 시작하기도 전에 문제가 생기고 원인을 찾기도 어렵다.
여러 파일 빌드
파일이 여러 개여도 명령은 주 프로그램 하나만 적는다. fpc 는 main.pas 의 uses 절을 읽고, 같은 폴더에서 이름이 같은 .pas 파일을 찾아 먼저 컴파일한 뒤 하나의 실행 파일로 묶는다. 유닛은 컴파일되면 .ppu(유닛의 interface 정보)와 .o(기계어) 파일을 남긴다. 다음 번 빌드에서는 바뀐 유닛과 그 영향을 받는 유닛만 다시 컴파일한다.
| 명령 | 하는 일 | 쓰는 때 |
|---|---|---|
| fpc main.pas | 바뀐 유닛만 컴파일하고 연결 | 평소 빌드 |
| fpc -B main.pas | 모든 유닛을 다시 컴파일 | 이상한 오류가 남을 때 |
| fpc -Fu./lib main.pas | lib 폴더에서도 유닛을 찾음 | 유닛을 폴더로 나눴을 때 |
유닛의 implementation 만 고치면 보통 그 유닛만 다시 컴파일한다. interface 를 고치면 그 유닛을 쓰는 다른 유닛도 다시 컴파일한다. 의존 관계를 얕게 유지할수록 빌드가 빨라지는 이유다.
Delphi 와 다른 점을 짧게 적는다. Delphi 는 프로그램 파일이 .dpr 이고, uses 절에서 CafeMenu in 'src\cafemenu.pas' 처럼 경로를 줄 때가 많다. fpc 도 같은 문법을 받지만 이 장에서는 같은 폴더에 두고 이름으로 찾게 한다. initialization, finalization 의 의미와 순서 규칙은 두 환경이 같다.
완성 코드
네 파일을 같은 폴더에 둔다. 가격과 이름은 CafeMenu, 기록은 CafeLog, 주문은 CafeOrder, 이를 사용하는 프로그램은 main.pas 다.
cafemenu.pas
{$mode objfpc}{$H+}
unit CafeMenu;
interface
type
TMenuCode = (mcAmericano, mcLatte, mcCake);
function MenuName(Code: TMenuCode): string;
function MenuPrice(Code: TMenuCode): Integer;
implementation
const
Names: array[TMenuCode] of string = ('아메리카노', '라테', '치즈케이크');
Prices: array[TMenuCode] of Integer = (3500, 4200, 5500);
function MenuName(Code: TMenuCode): string;
begin
Result := Names[Code];
end;
function MenuPrice(Code: TMenuCode): Integer;
begin
Result := Prices[Code];
end;
end.
cafelog.pas
{$mode objfpc}{$H+}
unit CafeLog;
interface
procedure WriteLog(const Msg: string);
procedure DumpLog;
implementation
uses
SysUtils;
const
MaxLines = 20;
var
Lines: array[1..MaxLines] of string;
Count: Integer;
procedure WriteLog(const Msg: string);
begin
if Count < MaxLines then
begin
Inc(Count);
Lines[Count] := Msg;
end;
end;
procedure DumpLog;
var
I: Integer;
begin
for I := 1 to Count do
WriteLn(Format(' %d. %s', [I, Lines[I]]));
end;
initialization
Count := 0;
WriteLn('[log] 열림');
finalization
WriteLn(Format('[log] 닫힘, 기록 %d줄', [Count]));
end.
cafeorder.pas
{$mode objfpc}{$H+}
unit CafeOrder;
interface
uses
CafeMenu;
procedure BeginOrder;
procedure AddLine(Code: TMenuCode; Qty: Integer);
function OrderTotal: Integer;
function OrderNumber: Integer;
implementation
uses
SysUtils, CafeLog;
type
TOrderLine = record
Code: TMenuCode;
Qty: Integer;
end;
const
MaxOrderLines = 10;
var
OrderLines: array[1..MaxOrderLines] of TOrderLine;
LineCount: Integer;
Number: Integer;
procedure BeginOrder;
begin
Inc(Number);
LineCount := 0;
WriteLog(Format('주문 %d 시작', [Number]));
end;
procedure AddLine(Code: TMenuCode; Qty: Integer);
begin
if LineCount >= MaxOrderLines then
Exit;
Inc(LineCount);
OrderLines[LineCount].Code := Code;
OrderLines[LineCount].Qty := Qty;
WriteLog(Format('%s x%d', [MenuName(Code), Qty]));
end;
function OrderTotal: Integer;
var
I: Integer;
begin
Result := 0;
for I := 1 to LineCount do
Result := Result + MenuPrice(OrderLines[I].Code) * OrderLines[I].Qty;
end;
function OrderNumber: Integer;
begin
Result := Number;
end;
initialization
Number := 0;
LineCount := 0;
WriteLn('[order] 주문 번호는 1번부터 시작한다');
finalization
WriteLn(Format('[order] 처리한 주문 %d건', [Number]));
end.
main.pas
{$mode objfpc}{$H+}
program Main;
uses
SysUtils, CafeMenu, CafeOrder, CafeLog;
procedure PrintReceipt;
begin
WriteLn(Format('주문 %d 합계: %d원', [OrderNumber, OrderTotal]));
end;
begin
WriteLn('== 메인 시작 ==');
BeginOrder;
AddLine(mcAmericano, 2);
AddLine(mcCake, 1);
PrintReceipt;
BeginOrder;
AddLine(mcLatte, 3);
PrintReceipt;
WriteLn('-- 기록 --');
DumpLog;
WriteLn('== 메인 끝 ==');
end.
줄별 해설
cafemenu.pas
- interface 에는 열거 타입
TMenuCode와 함수 머리 두 개만 있다. 이름 배열과 가격 배열은 implementation 의 상수라서 밖에서 직접 읽을 수 없다. array[TMenuCode] of string은 열거 타입을 첨자로 쓰는 배열이다. 원소 수가 열거 값 수와 맞지 않으면 컴파일 오류가 나므로, 메뉴를 추가하고 가격을 빼먹는 실수를 컴파일러가 잡아 준다.- 함수 본문은
Result에 값을 대입한다. 이 유닛은 uses 가 하나도 없는 가장 아래쪽 유닛이다.
cafelog.pas
- interface 는
WriteLog와DumpLog둘만 공개한다. 줄을 담는 배열Lines와 개수Count는 비공개다. uses SysUtils는 implementation 쪽에 있다.Format을 본문에서만 쓰기 때문이다.WriteLog는 배열이 가득 차지 않았을 때만 줄을 추가한다. 가득 차면 조용히 무시하는 단순한 규칙이다.- initialization 은
Count를 0 으로 두고 열림 메시지를 출력한다. finalization 은 쌓인 줄 수를 출력한다. 메시지는 모두 프로그램 본문 밖에서 나온다는 것을 보이기 위한 출력이다.
cafeorder.pas
- interface 의
uses CafeMenu;는AddLine의 매개변수 타입TMenuCode때문에 필요하다. - implementation 의
uses SysUtils, CafeLog;는 본문에서만 필요한 유닛이다. CafeLog 를 이쪽에 둔 덕분에 CafeLog 쪽에서 CafeOrder 를 쓰게 되더라도 interface 순환은 생기지 않는다. TOrderLine,OrderLines,LineCount,Number는 모두 비공개다. 밖에서는 함수로만 접근한다.BeginOrder는 번호를 하나 올리고 줄 수를 0 으로 되돌린 뒤 기록을 남긴다.AddLine은 줄이 가득 차면Exit로 바로 빠져나온다.OrderTotal는 줄마다 가격에 수량을 곱해 더한다. 첫 주문은 3500×2 + 5500 = 12500, 둘째 주문은 4200×3 = 12600 이다.
main.pas
- uses 에 네 유닛이 모두 있는 이유가 있다.
Format은 SysUtils,mcAmericano는 CafeMenu,BeginOrder는 CafeOrder,DumpLog는 CafeLog 에 있기 때문이다. - 프로그램 본문이 시작되기 전에 CafeLog 와 CafeOrder 의 initialization 출력이 나온다. 본문이 끝나면 finalization 출력이 반대 순서로 나온다.
- CafeMenu 는 initialization 이 없어서 출력이 없다.
실행 결과
$ fpc main.pas
$ ./main
[log] 열림
[order] 주문 번호는 1번부터 시작한다
== 메인 시작 ==
주문 1 합계: 12500원
주문 2 합계: 12600원
-- 기록 --
1. 주문 1 시작
2. 아메리카노 x2
3. 치즈케이크 x1
4. 주문 2 시작
5. 라테 x3
== 메인 끝 ==
[order] 처리한 주문 2건
[log] 닫힘, 기록 5줄
첫 줄 두 개는 본문이 시작되기 전, 마지막 두 줄은 본문이 끝난 뒤에 나온 것이다. 컴파일할 때는 fpc 의 버전과 줄 수를 알리는 메시지가 먼저 나오지만 경고는 나오지 않는다.
실무에서 자주 틀리는 것
구현만 쓰고 interface 에 선언하지 않는다
유닛 안에서는 함수를 잘 쓰는데 main.pas 에서만 찾지 못하는 경우다.
{ cafeorder.pas — 틀림: implementation 에만 있다 }
implementation
function LineTotal: Integer;
begin
Result := 0;
end;
{ main.pas }
WriteLn(LineTotal); { Error: Identifier not found "LineTotal" }
밖에서 부를 함수는 interface 에 머리를 적어야 한다.
{ cafeorder.pas — 고침 }
interface
function LineTotal: Integer;
implementation
function LineTotal: Integer;
begin
Result := 0;
end;
interface 의 uses 로 순환을 만든다
{ cafemenu.pas — 틀림 }
interface
uses CafeOrder; { CafeOrder 의 interface 도 CafeMenu 를 쓴다 }
{ 컴파일: Circular unit reference between CafeMenu and CafeOrder }
먼저 정말 필요한 의존인지 따져 본다. 본문에서만 쓴다면 implementation 쪽으로 옮긴다. 둘이 같은 타입을 쓰는 것이 원인이면 그 타입을 새 유닛으로 뽑는다.
{ cafemenu.pas — 고침 1: 본문에서만 쓴다면 }
interface
{ uses 없음 }
implementation
uses CafeOrder;
파일 이름과 unit 이름이 다르다
{ 파일 이름이 menu.pas 인데 내용은 unit CafeMenu; }
{ main.pas 의 uses CafeMenu 에서: Can't find unit CafeMenu }
fpc 는 uses 에 적힌 이름으로 파일을 찾는다. 파일 이름을 cafemenu.pas 로 바꾸면 해결된다. 이름을 바꾸기 싫다면 uses CafeMenu in 'menu.pas' 로 경로를 알려 줄 수도 있지만, 이름을 맞추는 쪽이 단순하다.
같은 이름을 가진 두 유닛의 순서를 모르고 쓴다
두 유닛이 같은 이름의 함수를 공개하면, uses 에서 뒤에 적은 유닛의 것이 앞의 것을 가린다. 아래 예의 두 유닛은 설명을 위해 가정한 것이다.
uses OrderSum, DaySum; { 둘 다 Total 을 공개한다 }
WriteLn(Total); { 뒤에 적은 DaySum 의 Total 이 불린다 }
유닛 이름을 앞에 붙이면 어느 쪽인지 분명해진다. uses 순서를 바꾸기만 해도 결과가 달라지는 코드는 읽기 어렵다.
WriteLn(OrderSum.Total);
WriteLn(DaySum.Total);
한눈에 보기
| 요소 | 역할 | 규칙 | 이 장의 예 |
|---|---|---|---|
| unit 이름 | 유닛을 가리키는 이름 | 파일 이름과 같게 한다 | CafeMenu = cafemenu.pas |
| interface | 공개 선언 | 함수는 머리만 적는다 | MenuName, AddLine |
| implementation | 본문과 비공개 항목 | 머리가 interface 와 같아야 한다 | OrderLines 배열 |
| uses | 다른 유닛 사용 | 전달되지 않고 순환하면 오류 | CafeOrder 가 CafeMenu 를 사용 |
| initialization | 시작 시 한 번 실행 | 의존하는 유닛이 먼저 | [log] 열림 |
| finalization | 종료 시 한 번 실행 | 초기화의 반대 순서 | [log] 닫힘 |
연습 문제
- 메뉴에 모카(4800원)를 추가하려고 한다. 어느 파일의 어느 부분을 고쳐야 하는지 말하고, 고치는 항목을 모두 적어라.
- CafeLog 가 로그 줄 앞에 주문 번호를 붙이고 싶어서
OrderNumber를 부르려고 한다. CafeLog 에서 CafeOrder 를 쓰면 어떤 일이 생기고, 순환 없이 해결하려면 어떻게 하는가. - main.pas 의 uses 절을
SysUtils, CafeMenu, CafeLog, CafeOrder로 바꾸면 실행 결과의 첫 두 줄이 달라지는가. 이유도 설명하라. - unit A 의 interface 가 B 를 쓰고, unit B 의 interface 가 A 를 쓴다. 이 코드를 컴파일하면 어떻게 되며, 고치는 방법 두 가지를 말하라.
정답과 해설
- cafemenu.pas 만 고친다. interface 의
TMenuCode에mcMocha를 추가하고, implementation 의Names와Prices의 원소를 하나씩 늘린다. 열거 타입을 첨자로 쓰는 배열은 원소 수가 맞지 않으면 컴파일 오류가 나므로, 세 곳 중 하나를 빼먹으면 컴파일러가 알려 준다. 함수 머리는 바뀌지 않아서 cafeorder.pas 와 main.pas 는 고칠 필요가 없다. 다만 interface 의 타입이 바뀌었으므로 그 유닛을 쓰는 파일은 다시 컴파일된다. - CafeOrder 의 interface 는 CafeMenu 만 쓰고 implementation 은 CafeLog 를 쓴다. 여기에 CafeLog 가 CafeOrder 를 쓰도록 하면 두 유닛이 서로를 참조하는 구조가 된다. CafeLog 가 CafeOrder 를 implementation 에서만 쓴다면 컴파일은 되지만, 가장 아래에 있어야 할 기록 유닛이 위쪽을 알게 되어 의존 방향이 꼬인다. 해결은 CafeOrder 가
WriteLog(Format('[%d] ...', [Number]))처럼 문자열을 만들어 넘기는 것이다. CafeLog 는 받은 문자열만 저장하면 되고 주문 번호가 무엇인지 알 필요가 없다. - 달라지지 않는다. CafeOrder 가 implementation 에서 CafeLog 를 쓰므로, uses 에 어떤 순서로 적든 CafeLog 가 먼저 초기화된다. 첫 두 줄은
[log] 열림,[order] 주문 번호는 1번부터 시작한다로 그대로다. 의존 관계가 있으면 순서가 정해지고, 의존 관계가 없는 유닛끼리만 uses 순서가 영향을 줄 수 있다. - 컴파일러가 "Circular unit reference between A and B" 같은 오류를 낸다. 고치는 방법은 둘 중 하나의 uses 를 implementation 으로 옮기는 것, 또는 둘이 함께 쓰는 선언을 새 유닛 C 로 뽑아서 A 와 B 가 모두 C 를 쓰게 하는 것이다. 둘 중 하나가 매개변수로 값을 받도록 바꿔 의존 자체를 없애는 방법도 있다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.