Object Pascal · 기본
Object Pascal 첫걸음
완성 프로젝트 - 카페 일일 정산
유닛으로 나눈 메뉴·주문·재고 설계, 주문 기록 파일 읽기, 메뉴별 매출·재고 부족 경고·정산표 출력, 메모리 누수 점검(-gh heaptrc), Delphi 로 옮길 때의 차이
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장까지 변수, 조건과 반복, 프로시저, 레코드, 문자열, 유닛, 클래스, 예외, 컬렉션과 파일을 하나씩 익혔다. 이 장에서는 그것들을 모아 동네 카페의 하루를 마감하는 콘솔 프로그램을 만든다. 메뉴, 재고, 주문 기록 파일을 읽어서 메뉴별 매출과 재고 부족 경고가 들어 있는 정산표를 출력한다. 마지막에는 메모리 누수를 점검하고, 같은 코드를 Delphi 로 옮길 때 무엇이 달라지는지도 확인한다.
- 프로그램을 메뉴·재고·주문 세 유닛과 main 으로 나누고, 어느 유닛이 어느 유닛을 쓰는지 정한다.
- 쉼표로 구분한 주문 기록 파일을 읽어 잘못된 줄을 걸러내고, 나머지를 재고에 반영한다.
- 메뉴별 매출과 비중, 재고 부족 경고, 처리하지 못한 주문을 정산표로 출력한다.
fpc -gh로 컴파일해서 만든 객체를 모두 해제했는지 확인한다.- Free Pascal 코드를 Delphi 로 옮길 때 고쳐야 할 곳을 안다.
문제 상황
카페 사장님은 퇴근 전에 계산대에 쌓인 주문 기록을 보고 세 가지를 알고 싶어 한다. 오늘 메뉴별로 몇 잔이 팔려 얼마가 되었는지, 내일 아침에 채워 넣어야 할 재료가 무엇인지, 기록 중에 이상한 줄이 없는지다.
주문 기록은 한 줄에 주문 하나를 적은 텍스트 파일이다. 사람이 손으로 고치기도 해서 메뉴 코드가 소문자로 적히거나, 없는 코드가 들어가거나, 형식이 깨진 줄이 섞이기도 한다. 재고보다 많은 수량이 주문된 줄도 있다. 이런 줄 때문에 프로그램이 멈추면 안 된다. 처리할 수 있는 줄은 처리하고, 처리하지 못한 줄은 이유를 적어서 마지막에 보여 주어야 한다.
한 파일에 모든 것을 넣으면 코드는 금방 수백 줄이 된다. 가격표를 고치려는데 재고 계산 코드를 같이 읽어야 한다면 실수하기 쉽다. 그래서 역할별로 유닛을 나눈다.
유닛으로 나눈 설계
역할과 의존 방향
나눈 기준은 "무엇을 알고 있는가"다. 메뉴 유닛은 코드, 이름, 가격만 안다. 재고 유닛은 코드별 남은 수량만 안다. 두 유닛은 서로를 모른다. 주문 유닛이 둘을 함께 쓰며 파일 읽기와 판매 집계를 맡고, main 은 이들을 조립해서 출력한다.
| 파일 | 공개하는 것 | 숨기는 것 | 쓰는 유닛 |
|---|---|---|---|
| cafemenu.pas | TMenuEntry, TMenu | 목록을 담는 컨테이너 | Generics.Collections |
| cafestock.pas | TStock | 코드별 수량 사전 | Generics.Collections |
| cafeorders.pas | TOrderLine, ParseLine, TOrderProcessor | 판매 집계 사전, 거절 기록 목록 | CafeMenu, CafeStock |
| main.pas | 없음(프로그램) | 출력 형식 | 위 세 유닛 |
화살표가 한 방향으로만 가는 것이 중요하다. 메뉴 유닛이 주문 유닛을 쓰기 시작하면 서로를 부르는 순환이 생기고, Pascal 컴파일러는 이를 오류로 막는다. 의존이 한 방향이면 메뉴만 따로 시험해 볼 수도 있다.
객체의 주인을 정한다
클래스로 만든 객체는 만든 쪽이 해제해야 한다. 이 프로그램에서는 규칙을 하나로 정했다. 만든 곳에서 해제한다. 메뉴 객체와 재고 객체는 main 이 만들고 main 이 해제한다. 주문 처리기는 이 둘을 생성자로 받아서 빌려 쓸 뿐이므로 소멸자에서 해제하지 않는다. 반대로 처리기가 스스로 만든 판매 집계 사전과 거절 기록 목록은 처리기의 소멸자에서 해제한다. 메뉴 항목 객체는 TObjectList 가 소유하도록 만들었기 때문에, 목록을 해제하면 항목도 함께 해제된다.
주문 기록 파일 읽기
파일 형식
한 줄은 주문번호,메뉴코드,수량 세 칸이다. 프로그램은 실행할 때 예제 주문 12줄을 orders.txt 로 먼저 쓴 다음, 같은 파일을 읽어서 처리한다. 표준 입력을 읽지 않아도 실행되고, 파일 읽기 코드도 실제 파일로 확인해 볼 수 있게 하려는 구성이다. 파일은 프로그램을 실행한 폴더에 만들어진다.
한 줄을 칸으로 쪼개는 일은 TStringList 에 맡긴다. Delimiter 를 쉼표로 하고 StrictDelimiter 를 True 로 해야 공백이 구분자로 취급되지 않는다. 숫자 변환은 StrToIntDef 로 한다. 변환에 실패하면 예외 대신 기본값 0 을 돌려주고, 번호와 수량은 0 이하이면 잘못된 값으로 본다.
걸러내는 세 단계
| 사유 | 판단하는 곳 | 재고 변화 | 기록 예 |
|---|---|---|---|
| 형식 오류 | ParseLine | 없음 | 8번째 줄: 형식 오류 |
| 없는 메뉴 코드 | Apply 의 첫 검사 | 없음 | 주문 1004: 없는 메뉴 코드 X99 |
| 재고 부족 | Apply 의 둘째 검사 | 없음(줄 전체 거절) | 주문 1005: C02 재고 부족 |
재고가 모자랄 때는 일부만 파는 대신 그 줄을 통째로 거절한다. 가게 규칙에 따라 달라질 수 있는 부분이므로 TStock.Take 한 곳에만 두었다. 규칙이 바뀌면 그 함수만 고치면 된다.
메모리 누수 점검
이 프로그램은 객체를 여러 개 만든다. 하나라도 해제하지 않으면 실행 중에는 아무 증상이 없다가 오래 켜 두는 프로그램에서 문제가 된다. Free Pascal 에는 이를 점검하는 도구가 컴파일러에 들어 있다. 컴파일할 때 -gh 를 주면 heaptrc 라는 유닛이 프로그램에 붙는다. 이 유닛은 힙(heap, 객체가 만들어지는 메모리 영역)에서 빌려 쓴 블록을 모두 기록해 두었다가, 프로그램이 끝날 때 아직 돌려주지 않은 블록의 수를 보고한다.
보고서 끝부분에 0 unfreed memory blocks : 0 이 나오면 해제하지 않은 블록이 없다는 뜻이다. 0 이 아니면 어딘가에서 Free 가 빠진 것이다. -gl 도 함께 주면 어느 줄에서 만든 블록인지 호출 위치가 같이 출력된다. 이 점검은 개발할 때만 사용한다. 기록 때문에 프로그램이 느려지고 메모리를 더 쓰기 때문이다.
Delphi 로 옮길 때의 차이
이 책의 문법은 대부분 Delphi 와 같다. 그래도 그대로는 컴파일되지 않는 곳이 몇 군데 있다.
| 항목 | Free Pascal(이 책) | Delphi | 할 일 |
|---|---|---|---|
| 제네릭 사용 | specialize TDictionary<string, Integer> | TDictionary<string, Integer> | specialize 삭제 |
| 모드 지시어 | {$mode objfpc}{$H+} | 필요 없음 | 삭제하고 {$APPTYPE CONSOLE} 추가 |
| 표준 유닛 이름 | SysUtils, Generics.Collections | System.SysUtils, System.Generics.Collections | uses 절 수정 |
| 문자열 길이 | UTF-8 바이트 수 | UTF-16 문자 수 | 한글 정렬 계산 확인 |
| 파일 인코딩 | 바이트를 그대로 읽음 | BOM 이 없으면 ANSI 로 읽을 수 있음 | LoadFromFile 에 TEncoding.UTF8 지정 |
| 누수 점검 | 컴파일 옵션 -gh | ReportMemoryLeaksOnShutdown := True | 프로그램 첫머리에 한 줄 추가 |
Windows 콘솔에서 한글이 깨져 보이면 Windows 유닛의 SetConsoleOutputCP(CP_UTF8) 을 호출한다. 유닛 파일 이름과 unit 이름을 맞추는 규칙, constructor/destructor/override 문법, try finally 구조는 그대로 쓸 수 있다.
완성 코드
네 파일을 같은 폴더에 두고 fpc main.pas 로 컴파일한다. 컴파일러가 uses 절을 따라가 유닛 파일을 찾아 함께 컴파일한다.
cafemenu.pas
unit CafeMenu;
{$mode objfpc}{$H+}
interface
uses
Generics.Collections;
type
TMenuEntry = class
private
FCode: string;
FName: string;
FPrice: Integer;
public
constructor Create(const ACode, AName: string; APrice: Integer);
property Code: string read FCode;
property Name: string read FName;
property Price: Integer read FPrice;
end;
TMenu = class
private
FEntries: specialize TObjectList<TMenuEntry>;
function GetCount: Integer;
function GetEntry(Index: Integer): TMenuEntry;
public
constructor Create;
destructor Destroy; override;
procedure Add(const ACode, AName: string; APrice: Integer);
function Find(const ACode: string): TMenuEntry;
property Count: Integer read GetCount;
property Entries[Index: Integer]: TMenuEntry read GetEntry; default;
end;
implementation
constructor TMenuEntry.Create(const ACode, AName: string; APrice: Integer);
begin
inherited Create;
FCode := ACode;
FName := AName;
FPrice := APrice;
end;
constructor TMenu.Create;
begin
inherited Create;
FEntries := specialize TObjectList<TMenuEntry>.Create(True);
end;
destructor TMenu.Destroy;
begin
FEntries.Free;
inherited Destroy;
end;
function TMenu.GetCount: Integer;
begin
Result := FEntries.Count;
end;
function TMenu.GetEntry(Index: Integer): TMenuEntry;
begin
Result := FEntries[Index];
end;
procedure TMenu.Add(const ACode, AName: string; APrice: Integer);
begin
FEntries.Add(TMenuEntry.Create(ACode, AName, APrice));
end;
function TMenu.Find(const ACode: string): TMenuEntry;
var
I: Integer;
begin
Result := nil;
for I := 0 to FEntries.Count - 1 do
if FEntries[I].Code = ACode then
begin
Result := FEntries[I];
Exit;
end;
end;
end.
cafestock.pas
unit CafeStock;
{$mode objfpc}{$H+}
interface
uses
Generics.Collections;
type
TStock = class
private
FQty: specialize TDictionary<string, Integer>;
public
constructor Create;
destructor Destroy; override;
procedure SetQty(const Code: string; AQty: Integer);
function Remaining(const Code: string): Integer;
function Take(const Code: string; N: Integer): Boolean;
end;
implementation
constructor TStock.Create;
begin
inherited Create;
FQty := specialize TDictionary<string, Integer>.Create;
end;
destructor TStock.Destroy;
begin
FQty.Free;
inherited Destroy;
end;
procedure TStock.SetQty(const Code: string; AQty: Integer);
begin
FQty.AddOrSetValue(Code, AQty);
end;
function TStock.Remaining(const Code: string): Integer;
begin
if not FQty.TryGetValue(Code, Result) then
Result := 0;
end;
function TStock.Take(const Code: string; N: Integer): Boolean;
var
Cur: Integer;
begin
Cur := 0;
Result := FQty.TryGetValue(Code, Cur) and (Cur >= N);
if Result then
FQty[Code] := Cur - N;
end;
end.
cafeorders.pas
unit CafeOrders;
{$mode objfpc}{$H+}
interface
uses
Classes, Generics.Collections, CafeMenu, CafeStock;
type
TOrderLine = record
OrderNo: Integer;
Code: string;
Qty: Integer;
end;
TOrderProcessor = class
private
FMenu: TMenu;
FStock: TStock;
FSold: specialize TDictionary<string, Integer>;
FLog: TStringList;
FAccepted: Integer;
FRejected: Integer;
public
constructor Create(AMenu: TMenu; AStock: TStock);
destructor Destroy; override;
procedure Apply(const Line: TOrderLine);
procedure LoadFromFile(const FileName: string);
function SoldQty(const Code: string): Integer;
property Accepted: Integer read FAccepted;
property Rejected: Integer read FRejected;
property Log: TStringList read FLog;
end;
function ParseLine(const S: string; out Line: TOrderLine): Boolean;
implementation
uses
SysUtils;
function ParseLine(const S: string; out Line: TOrderLine): Boolean;
var
Parts: TStringList;
begin
Line.OrderNo := 0;
Line.Code := '';
Line.Qty := 0;
Parts := TStringList.Create;
try
Parts.StrictDelimiter := True;
Parts.Delimiter := ',';
Parts.DelimitedText := S;
Result := Parts.Count = 3;
if Result then
begin
Line.OrderNo := StrToIntDef(Trim(Parts[0]), 0);
Line.Code := UpperCase(Trim(Parts[1]));
Line.Qty := StrToIntDef(Trim(Parts[2]), 0);
Result := (Line.OrderNo > 0) and (Line.Qty > 0) and (Line.Code <> '');
end;
finally
Parts.Free;
end;
end;
constructor TOrderProcessor.Create(AMenu: TMenu; AStock: TStock);
begin
inherited Create;
FMenu := AMenu;
FStock := AStock;
FSold := specialize TDictionary<string, Integer>.Create;
FLog := TStringList.Create;
end;
destructor TOrderProcessor.Destroy;
begin
FLog.Free;
FSold.Free;
inherited Destroy;
end;
procedure TOrderProcessor.Apply(const Line: TOrderLine);
var
Prev: Integer;
begin
if FMenu.Find(Line.Code) = nil then
begin
FLog.Add(Format('주문 %d: 없는 메뉴 코드 %s', [Line.OrderNo, Line.Code]));
Inc(FRejected);
Exit;
end;
if not FStock.Take(Line.Code, Line.Qty) then
begin
FLog.Add(Format('주문 %d: %s 재고 부족 (요청 %d, 남음 %d)',
[Line.OrderNo, Line.Code, Line.Qty, FStock.Remaining(Line.Code)]));
Inc(FRejected);
Exit;
end;
Prev := 0;
FSold.TryGetValue(Line.Code, Prev);
FSold.AddOrSetValue(Line.Code, Prev + Line.Qty);
Inc(FAccepted);
end;
procedure TOrderProcessor.LoadFromFile(const FileName: string);
var
Lines: TStringList;
I: Integer;
Line: TOrderLine;
begin
Lines := TStringList.Create;
try
Lines.LoadFromFile(FileName);
for I := 0 to Lines.Count - 1 do
begin
if Trim(Lines[I]) = '' then
Continue;
if ParseLine(Lines[I], Line) then
Apply(Line)
else
begin
FLog.Add(Format('%d번째 줄: 형식 오류 "%s"', [I + 1, Lines[I]]));
Inc(FRejected);
end;
end;
finally
Lines.Free;
end;
end;
function TOrderProcessor.SoldQty(const Code: string): Integer;
begin
if not FSold.TryGetValue(Code, Result) then
Result := 0;
end;
end.
main.pas
program Main;
{$mode objfpc}{$H+}
uses
SysUtils, Classes, CafeMenu, CafeStock, CafeOrders;
const
OrderFile = 'orders.txt';
LowLimit = 2;
Separator = '------------------------------------';
procedure WriteSampleOrders;
var
Lines: TStringList;
begin
Lines := TStringList.Create;
try
Lines.Add('1001,A01,2');
Lines.Add('1001,C01,1');
Lines.Add('1002,A02,3');
Lines.Add('1003,A01,3');
Lines.Add('1003,C02,2');
Lines.Add('1004,B01,2');
Lines.Add('1004,X99,1');
Lines.Add('이건 잘못된 줄');
Lines.Add('1005,c02,2');
Lines.Add('1006,A02,1');
Lines.Add('1007,A01,2');
Lines.Add('1007,B01,1');
Lines.SaveToFile(OrderFile);
finally
Lines.Free;
end;
end;
procedure BuildMenu(Menu: TMenu);
begin
Menu.Add('A01', '아메리카노', 3000);
Menu.Add('A02', '카페라떼', 3500);
Menu.Add('B01', '녹차', 3200);
Menu.Add('C01', '크루아상', 2800);
Menu.Add('C02', '치즈케이크', 4500);
end;
procedure BuildStock(Stock: TStock);
begin
Stock.SetQty('A01', 8);
Stock.SetQty('A02', 6);
Stock.SetQty('B01', 4);
Stock.SetQty('C01', 5);
Stock.SetQty('C02', 3);
end;
procedure PrintSales(Menu: TMenu; Orders: TOrderProcessor);
var
I, Qty, Sales, TotalQty, TotalSales: Integer;
Share: Double;
Item: TMenuEntry;
begin
TotalQty := 0;
TotalSales := 0;
for I := 0 to Menu.Count - 1 do
begin
Item := Menu[I];
Qty := Orders.SoldQty(Item.Code);
Inc(TotalQty, Qty);
Inc(TotalSales, Qty * Item.Price);
end;
WriteLn('== 일일 정산표 ==');
WriteLn('CODE QTY SALES SHARE MENU');
WriteLn(Separator);
for I := 0 to Menu.Count - 1 do
begin
Item := Menu[I];
Qty := Orders.SoldQty(Item.Code);
Sales := Qty * Item.Price;
if TotalSales > 0 then
Share := Sales * 100.0 / TotalSales
else
Share := 0;
WriteLn(Format('%-4s %4d %8d %5.1f%% %s',
[Item.Code, Qty, Sales, Share, Item.Name]));
end;
WriteLn(Separator);
WriteLn(Format('%-4s %4d %8d', ['SUM', TotalQty, TotalSales]));
WriteLn(Format('처리한 주문 줄: 수락 %d, 거절 %d',
[Orders.Accepted, Orders.Rejected]));
end;
procedure PrintLowStock(Menu: TMenu; Stock: TStock);
var
I, Left, Found: Integer;
Item: TMenuEntry;
begin
WriteLn(Format('== 재고 부족 경고 (남은 수량 %d 이하) ==', [LowLimit]));
Found := 0;
for I := 0 to Menu.Count - 1 do
begin
Item := Menu[I];
Left := Stock.Remaining(Item.Code);
if Left <= LowLimit then
begin
WriteLn(Format('%s %s: %d개 남음', [Item.Code, Item.Name, Left]));
Inc(Found);
end;
end;
if Found = 0 then
WriteLn('없음');
end;
procedure PrintRejected(Orders: TOrderProcessor);
var
I: Integer;
begin
WriteLn('== 처리하지 못한 주문 ==');
for I := 0 to Orders.Log.Count - 1 do
WriteLn(Orders.Log[I]);
if Orders.Log.Count = 0 then
WriteLn('없음');
end;
procedure RunSettlement;
var
Menu: TMenu;
Stock: TStock;
Orders: TOrderProcessor;
begin
Menu := nil;
Stock := nil;
Orders := nil;
try
Menu := TMenu.Create;
Stock := TStock.Create;
BuildMenu(Menu);
BuildStock(Stock);
Orders := TOrderProcessor.Create(Menu, Stock);
Orders.LoadFromFile(OrderFile);
PrintSales(Menu, Orders);
WriteLn;
PrintLowStock(Menu, Stock);
WriteLn;
PrintRejected(Orders);
finally
Orders.Free;
Stock.Free;
Menu.Free;
end;
end;
begin
WriteSampleOrders;
RunSettlement;
end.
줄별 해설
cafemenu.pas
interface아래에는 다른 유닛이 알아야 하는 선언만 둔다.TMenuEntry의 필드(FCode등)는private이라 같은 유닛 밖에서는 읽거나 쓸 수 없고, 속성(property)으로만 읽을 수 있다. 값을 바꾸는 길이 막혀 있어 항목이 만들어진 뒤에는 가격이 바뀌지 않는다.specialize TObjectList<TMenuEntry>는 "TMenuEntry 를 담는 목록"을 만든다. 생성자의True는 목록이 항목을 소유한다는 뜻이다. 그래서TMenu.Destroy에서FEntries.Free한 줄이면 항목 객체들도 함께 해제된다.default가 붙은Entries속성 덕분에 main 에서Menu[I]로 항목을 꺼낼 수 있다.Find는 먼저Result := nil로 시작하고, 찾으면 대입한 뒤Exit한다. 없는 코드는nil로 알려 주고, 호출하는 쪽이nil검사를 한다.
cafestock.pas
- 재고는 코드를 열쇠로 하는 사전(
TDictionary)에 둔다. 코드로 바로 찾을 수 있어서 메뉴가 늘어나도 검색 코드를 고칠 필요가 없다. Remaining은TryGetValue가 실패하면 0 을 돌려준다. 없는 코드의 재고는 0 이라는 규칙이다.Take의and는 왼쪽이 거짓이면 오른쪽을 계산하지 않는다. 코드가 없거나 수량이 모자라면Result가 거짓이 되어 재고가 줄지 않고, 충분할 때만FQty[Code]에서 빼 준다.
cafeorders.pas
uses절이 두 군데다.interface쪽에는 선언에 나오는TStringList,TDictionary,TMenu,TStock의 유닛을 둔다.implementation쪽의SysUtils는 구현에서만 쓰므로 바깥에 알릴 필요가 없다.ParseLine은 맨 처음에out매개변수Line의 모든 칸을 채운다. 실패하더라도 쓰레기 값이 남지 않는다.- 코드는
UpperCase(Trim(...))로 정리한다. 예제의c02가C02로 처리된다. Apply는 검사를 세 번에 나눠 한다. 메뉴 확인, 재고 차감, 판매 수량 누적 순서다. 재고 차감이 성공한 다음에만 누적하므로 거절된 줄은 매출에 들어가지 않는다.Prev := 0다음에TryGetValue를 부르면, 처음 팔린 메뉴는 0 에서 시작하고 이미 팔린 메뉴는 기존 수량에서 시작한다.LoadFromFile은 읽은 줄 수만큼 반복한다.Lines는try finally로 감싸 읽는 도중 문제가 생겨도 해제된다. 형식 오류 메시지에는 줄 번호I + 1을 쓴다. 목록 번호는 0 부터지만 사람이 세는 줄 번호는 1 부터이기 때문이다.- 소멸자에는 처리기가 만든
FLog와FSold만 있다.FMenu와FStock은 빌린 것이므로 해제하지 않는다.
main.pas
- 상수
LowLimit은 경고 기준을 한곳에 모은 것이다. 머리말 출력에도 이 값을 그대로 가져다 써서 기준을 고쳐도 문구가 어긋나지 않는다. PrintSales는 메뉴를 두 번 훑는다. 첫 번째는 합계를 구하는 데, 두 번째는 각 메뉴의 비중을 합계로 나누어 출력하는 데 쓴다. 합계가 있어야 비중을 계산할 수 있기 때문이다.- 서식
%5.1f%%는 소수점 한 자리로 고정하고,%%는 글자 % 하나를 뜻한다.%-4s는 왼쪽 정렬로 4칸이다. 한글 이름을 마지막 칸에 둔 것은 아래 "자주 틀리는 것"에서 설명한다. - 순회는 항상 메뉴 목록 순서로 한다. 사전은 순서를 보장하지 않으므로 사전을 직접 훑으면 실행할 때마다 출력 순서가 달라질 수 있다.
RunSettlement는 지역 변수 세 개를 먼저nil로 만든다. 지역 변수는 초기값이 없어서, 생성이 중간에 실패한 채finally로 넘어가면 쓰레기 값을 해제하려 들기 때문이다.nil인 객체에Free를 부르는 것은 안전하다. 해제는 만든 순서의 반대로 한다.
실행 결과
$ fpc main.pas
$ ./main
== 일일 정산표 ==
CODE QTY SALES SHARE MENU
------------------------------------
A01 7 21000 37.2% 아메리카노
A02 4 14000 24.8% 카페라떼
B01 3 9600 17.0% 녹차
C01 1 2800 5.0% 크루아상
C02 2 9000 16.0% 치즈케이크
------------------------------------
SUM 17 56400
처리한 주문 줄: 수락 9, 거절 3
== 재고 부족 경고 (남은 수량 2 이하) ==
A01 아메리카노: 1개 남음
A02 카페라떼: 2개 남음
B01 녹차: 1개 남음
C02 치즈케이크: 1개 남음
== 처리하지 못한 주문 ==
주문 1004: 없는 메뉴 코드 X99
8번째 줄: 형식 오류 "이건 잘못된 줄"
주문 1005: C02 재고 부족 (요청 2, 남음 1)
손으로 확인해 보면 아메리카노는 1001에서 2잔, 1003에서 3잔, 1007에서 2잔으로 7잔이고 7 × 3000 = 21000 이다. 치즈케이크는 3개 중 1003 에서 2개가 팔렸고, 1005 가 2개를 더 요청했지만 남은 것이 1개뿐이라 그 줄 전체가 거절되었다.
이어서 누수를 점검한다.
$ fpc -gh -gl main.pas
$ ./main
(정산표는 위와 같다)
Heap dump by heaptrc unit of main
N memory blocks allocated : A/B
N memory blocks freed : A/B
0 unfreed memory blocks : 0
True heap size : ...
True free heap : ...
마지막 블록은 형태를 보인 예시다. N, A, B 와 힙 크기는 환경마다 다르다. 보고서는 표준 오류로 나올 수 있으니 터미널에서 직접 실행해서 본다. 확인할 것은 0 unfreed memory blocks : 0 한 줄이다.
실무에서 자주 틀리는 것
빌려 쓴 객체까지 해제한다
처리기가 메뉴를 받아서 쓰고, 소멸자에서 "내 것처럼" 해제하는 경우다.
destructor TOrderProcessor.Destroy;
begin
FMenu.Free; // main 도 Menu.Free 를 부른다
FLog.Free;
FSold.Free;
inherited Destroy;
end;
main 의 finally 에서 Menu.Free 가 다시 실행되면 이미 해제된 객체를 또 해제하게 되어 실행 중 오류가 난다. -gh 를 켜 두면 같은 블록을 두 번 해제했다는 보고가 나온다. 빌린 객체는 해제하지 않고, 만든 곳에서 한 번만 해제한다.
destructor TOrderProcessor.Destroy;
begin
FLog.Free;
FSold.Free;
inherited Destroy;
end;
객체를 연달아 만들고 try 하나로 감싼다
procedure Run;
var
A, B: TStock;
begin
A := TStock.Create;
B := TStock.Create; // 여기서 예외가 나면 A 는 해제되지 않는다
try
// 사용
finally
B.Free;
A.Free;
end;
end;
try 앞의 만들기에서 문제가 생기면 finally 에 도달하지 못한다. 객체 하나를 만들 때마다 바로 아래에 try finally 를 둔다. 이 장의 RunSettlement 처럼 변수를 먼저 nil 로 만들고 한 구간에서 묶어 처리하는 방법도 있다.
procedure Run;
var
A, B: TStock;
begin
A := TStock.Create;
try
B := TStock.Create;
try
// 사용
finally
B.Free;
end;
finally
A.Free;
end;
end;
한글 이름을 폭 지정으로 맞추려 한다
WriteLn(Format('%-10s %4d', [Item.Name, Qty])); // 이름 칸이 들쭉날쭉하다
이 환경의 string 은 UTF-8 바이트로 저장되어서 한글 한 글자가 3바이트다. %-10s 는 글자 수가 아니라 바이트 수로 채운다. 같은 폭을 주어도 화면에서는 줄마다 칸이 달라진다. 숫자와 코드처럼 ASCII 로 된 열을 앞에 두고, 한글 이름은 맨 오른쪽 열에 폭 없이 출력하면 줄이 맞는다.
WriteLn(Format('%-4s %4d %s', [Item.Code, Qty, Item.Name]));
Free 를 빠뜨리고 점검하지 않는다
procedure TOrderProcessor.LoadFromFile(const FileName: string);
var
Lines: TStringList;
begin
Lines := TStringList.Create;
Lines.LoadFromFile(FileName);
// ... 처리 ...
end; // Lines.Free 가 없다
이 코드는 컴파일 경고도 없고 출력도 정상이다. 그러나 fpc -gh 로 실행하면 끝에 0 이 아닌 unfreed memory blocks 가 나온다. 기능을 하나 추가할 때마다 점검 한 번을 습관으로 하면 원인이 되는 코드가 그 변경 안에 있다는 것을 알 수 있다. 고친 코드는 완성 코드의 LoadFromFile 처럼 만든 직후 try finally 로 감싼다.
Lines := TStringList.Create;
try
Lines.LoadFromFile(FileName);
// ... 처리 ...
finally
Lines.Free;
end;
한눈에 보기
| 주제 | 선택 | 이유 | 확인 방법 |
|---|---|---|---|
| 유닛 나누기 | 메뉴, 재고, 주문, main | 각 유닛이 한 가지만 알게 한다 | 순환 uses 가 없다 |
| 객체 소유 | 만든 곳에서 해제 | 이중 해제와 누수를 막는다 | fpc -gh 의 unfreed 수 |
| 주문 줄 검증 | ParseLine 과 Apply 에서 거른다 | 나쁜 줄 하나가 전체를 멈추지 않는다 | 처리하지 못한 주문 목록 |
| 출력 순서 | 메뉴 목록 순서로 순회 | 사전 순서에 기대지 않아 결과가 같다 | 같은 입력의 출력 비교 |
| 소수 표기 | %5.1f | 자릿수를 고정한다 | 비중 열 |
| Delphi 이식 | specialize 삭제, 유닛 이름 수정 | 컴파일러 방언 차이 | 컴파일 오류 메시지 |
연습 문제
- 메뉴에
C03 마들렌 2500을 추가하고 재고 4개를 준다. 주문 기록 끝에1008,C03,3을 더하면 SUM 행의 수량과 매출, 그리고 C03 의 남은 수량은 얼마인가? 재고 부족 경고에는 C03 가 나타나는가? LowLimit을 1 로 바꾸면 재고 부족 경고에 어떤 메뉴가 나오는가? (원래 예제 기준이다.)TOrderProcessor.Destroy에서FLog.Free줄을 지우고fpc -gh로 실행하면 보고서가 어떻게 달라지는가? 원인과 고치는 방법을 말하라.- 다음 날 아침에 재료를 들여오는
Restock(Code, N)을 추가하려 한다. 어느 파일의 어느 부분을 고쳐야 하고, 왜 main 은 고치지 않아도 되는가?
정답과 해설
- C03 는 3개가 팔려 7500 원이 늘어난다. 수량은 17 + 3 = 20, 매출은 56400 + 7500 = 63900 이므로 SUM 행은
SUM 20 63900이다. 남은 수량은 4 − 3 = 1 이고 기준 2 이하이므로 경고에C03 마들렌: 1개 남음이 나타난다. 메뉴 순서에서 C03 는 C02 뒤에 있어 경고의 맨 끝에 나온다. 코드는BuildMenu와BuildStock에 한 줄씩, 주문 기록에 한 줄 더하면 되고 다른 유닛은 고치지 않는다. - 남은 수량이 1 이하인 메뉴만 나온다. A01(1), B01(1), C02(1) 이 나오고 남은 수량이 2 인 A02 는 빠진다. C01 은 4 라서 원래도 나오지 않는다. 상수 한 줄만 바꿨는데 머리말의 "2 이하"도 "1 이하"로 함께 바뀐다.
- 보고서에
0이 아닌unfreed memory blocks가 나온다.TStringList객체와 그 안의 문자열 기록이 해제되지 않은 것이다. 출력은 정상이므로 화면만 봐서는 알 수 없다.-gl도 함께 주면 어디서 만든 블록인지 호출 위치가 나온다. 소멸자에FLog.Free를 되돌려 놓으면0 unfreed memory blocks : 0으로 돌아온다. - 재고를 직접 다루는 곳은 cafestock.pas 이다.
interface의TStock선언에procedure Restock(const Code: string; N: Integer);를 추가하고,implementation에 구현을 적는다.
main 은procedure TStock.Restock(const Code: string; N: Integer); begin FQty.AddOrSetValue(Code, Remaining(Code) + N); end;uses CafeStock만 하고 있으므로 새 메서드가 공개되는 순간부터 쓸 수 있다. 호출하는 코드를 추가하지 않으면 기존 동작은 달라지지 않는다. 사전을 어떻게 쓰는지는 유닛 안에 숨겨져 있어서 이런 변경이 다른 파일로 퍼지지 않는다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.