클래스와 객체 - 만들고 반드시 해제하기
이 장에서 배우는 것
앞 장까지는 프로시저와 함수, 레코드, 유닛으로 프로그램을 나누는 방법을 익혔다. 이 장에서는 데이터와 그 데이터를 다루는 동작을 하나로 묶는 클래스(class)를 다룬다. 클래스로 만든 값은 레코드와 달리 프로그램이 직접 만들고 직접 돌려줘야 한다. 이 책의 제목에 "반드시 해제하기"가 들어간 이유가 여기에 있다. 카페 계산대 프로그램에 재고 품목과 주문을 클래스로 넣어 보면서 이 규칙을 몸에 익힌다.
- class 로 필드와 메서드를 가진 타입을 선언하고 객체를 만들 수 있다.
- constructor Create 와 destructor Destroy 가 각각 언제 불리는지 설명할 수 있다.
- Free 와 try finally 로 객체를 빠짐없이 해제하는 형태를 쓸 수 있다.
- private 과 public 으로 바깥에 보일 범위를 정할 수 있다.
- property 와 getter·setter 로 필드 접근을 한 곳에서 통제할 수 있다.
문제 상황
카페 계산대 프로그램이 커지면서 변수가 늘어났다. 메뉴 이름은 menuName1, 가격은 menuPrice1, 재고는 menuStock1이다. 메뉴가 둘이면 변수가 여섯 개이고, 메뉴가 열 개이면 서른 개다. 레코드로 묶으면 변수 수는 줄지만 다른 문제가 남는다.
"재고가 모자라면 판매하지 않는다"는 규칙은 레코드 바깥의 프로시저 곳곳에 흩어진다. 어느 한 곳에서 stock := stock - 5처럼 규칙을 건너뛰고 필드를 직접 고치면 재고가 음수가 된다. 가격에 음수가 들어가는 일도 막을 방법이 없다.
이럴 때 클래스를 쓴다. 클래스는 필드(데이터)와 메서드(동작)를 한 타입에 묶는다. 바깥에서는 메서드와 property 로만 접근하게 하면 규칙을 한 곳에 모을 수 있다. 다만 클래스의 객체는 직접 만든 만큼 직접 정리해야 한다. 이 정리를 빠뜨리면 프로그램이 오래 돌수록 메모리가 쌓인다.
클래스 선언과 객체 만들기
필드와 메서드
필드(field)는 객체 안에 들어 있는 변수이고, 메서드(method)는 객체 안에 속한 프로시저나 함수다. 클래스 선언은 type 부분에 쓰며, 메서드는 선언부에서는 머리글만 적고 본문은 implementation 쪽에서 클래스이름.메서드이름 형태로 따로 쓴다. 이 장의 프로그램은 한 파일이므로 본문을 같은 파일 아래쪽에 쓴다.
관례로 클래스 이름은 T로 시작하고(TStockItem), 필드 이름은 F로 시작하며(FPrice), 매개변수 이름은 A로 시작한다(APrice). 규칙은 아니지만 필드와 매개변수의 이름이 겹치는 것을 막아 주므로 이 책도 따른다.
클래스 이름으로 만든 변수는 객체 자체가 아니라 객체가 있는 곳을 가리키는 참조(reference)다. 객체는 프로그램이 따로 확보하는 메모리 영역(힙, heap)에 만들어진다. 그림 1은 이 관계를 보여 준다.
생성자와 소멸자
생성자(constructor)는 객체를 만들면서 필드의 처음 값을 정하는 특별한 메서드다. 이름은 관례로 Create이며 TOrder.Create(3)처럼 변수가 아니라 클래스 이름에 붙여서 부른다. 생성자의 첫 줄에는 inherited Create;를 쓴다. 모든 클래스는 이름 없이도 TObject라는 공통 조상에서 이어지는데, 이 줄은 그 조상이 해야 할 준비를 먼저 시킨다. 상속은 다음 장에서 자세히 다룬다.
소멸자(destructor)는 객체가 사라지기 직전에 불리는 메서드이며 이름은 Destroy다. 객체가 다른 객체를 품고 있다면 소멸자에서 그것들을 해제한다. 소멸자 선언 끝의 override는 조상의 Destroy를 덮어쓴다는 표시다. 이 장에서는 정해진 문구로 외우면 되고, 뜻은 다음 장에서 설명한다. 소멸자의 마지막 줄에는 inherited Destroy;를 쓴다.
소멸자를 직접 부르는 일은 거의 없다. 대신 Free를 부른다. Free는 변수가 비어 있지 않은지(nil이 아닌지) 확인한 뒤에 Destroy를 부른다.
해제 책임: Free 와 try finally
Pascal 에는 객체를 자동으로 치워 주는 장치가 없다. Create로 만든 객체는 Free를 부를 때까지 메모리에 남는다. 해제하지 않은 채 변수만 사라지면 객체는 어디에서도 가리키지 않는 상태로 남는데, 이것이 메모리 누수(memory leak)다. 계산대처럼 하루 종일 켜 두는 프로그램에서는 누수가 쌓여 결국 문제가 된다.
생성과 해제 사이에 다른 코드가 끼면 문제가 더 어려워진다. 중간에 오류가 나서 프로그램 흐름이 빠져나가면 Free 줄에 도달하지 못한다. 그래서 다음 형태를 습관으로 쓴다.
Obj := TOrder.Create(1);
try
// Obj 를 사용하는 코드
finally
Obj.Free;
end;
try와 finally 사이의 코드가 어떻게 끝나든 finally 안의 코드는 실행된다. 생성은 try 바로 앞에 쓴다. 이유는 뒤의 "자주 틀리는 것"에서 다시 본다. 오류를 받아서 처리하는 try except는 예외 처리를 다루는 장에서 따로 배운다. 이 장에서는 finally를 해제 전용 장치로만 쓴다.
객체가 둘 이상이면 객체마다 try finally를 하나씩 쓴다. 한 finally에 Free를 나란히 쓰고 싶어지지만, 그러면 둘째 객체를 만드는 도중에 문제가 생겼을 때 첫째 객체를 해제할 길이 없다. 완성 코드에서 이 중첩 형태를 확인할 수 있다.
해제 뒤에 변수를 비우고 싶다면 SysUtils 의 FreeAndNil(Obj)를 쓴다. 해제와 동시에 변수를 nil로 만든다. 해제한 객체를 가리키던 변수를 그대로 쓰면 이미 반납한 메모리를 건드리게 되므로, 해제 뒤에도 변수가 오래 살아 있는 구조라면 이 함수가 안전하다.
Free Pascal 에서 누수를 확인하려면 fpc -gh main.pas로 컴파일하면 된다. 실행이 끝날 때 해제하지 않은 메모리가 있으면 보고서를 출력한다. 이 장의 완성 코드에서는 누수가 없다고 나온다.
가시성과 property
private 과 public
가시성(visibility)은 클래스의 멤버(필드, 메서드, property)를 어디서 쓸 수 있는지 정하는 규칙이다. 필드는 대개 바깥에 숨기고, 바깥에 약속할 동작만 열어 둔다. 그래야 규칙을 한 곳에서 지킬 수 있다.
| 키워드 | 접근 가능한 곳 | 주로 두는 멤버 | 비고 |
|---|---|---|---|
| strict private | 그 클래스 안에서만 | 필드, getter, setter | 이 책이 기본으로 쓰는 형태 |
| private | 그 클래스 + 같은 유닛 전체 | 필드, 보조 메서드 | 같은 파일의 다른 코드에 열려 있다 |
| public | 어디서나 | 생성자, 메서드, property | 바깥에 약속하는 부분 |
Pascal 의 private은 다른 언어와 다른 점이 하나 있다. 같은 유닛(또는 같은 프로그램 파일) 안에 있는 코드에는 열려 있다. 이 장의 main.pas는 파일 하나이므로 그냥 private이라고 쓰면 메인 프로그램이 필드를 직접 고칠 수 있다. 그래서 완성 코드에서는 strict private을 쓴다. 이 경우 클래스 바깥에서 필드에 접근하면 컴파일 오류가 난다. 그림 2는 이 구조를 보여 준다.
Delphi 와 다른 점이 있다. 가시성 키워드를 아무것도 쓰지 않았을 때의 기본값이 컴파일러 설정에 따라 다르다. 이 책은 항상 명시하므로 영향이 없다. strict private은 Delphi 에도 있으며 뜻이 같다.
property 와 getter·setter
property(속성)는 필드처럼 읽고 쓰지만 실제로는 필드나 메서드로 이어지는 통로다. 선언 형태는 다음과 같다.
property Price: Integer read FPrice write SetPrice;
read 뒤에는 값을 읽을 때 쓸 필드 또는 getter(값을 돌려주는 함수)를 적고, write 뒤에는 값을 쓸 때 쓸 필드 또는 setter(값을 받는 프로시저)를 적는다. 바깥에서 Latte.Price := 3500이라고 쓰면 컴파일러가 SetPrice(3500) 호출로 바꿔 준다. 그래서 값을 넣는 모든 경로에 검사를 한 번만 쓰면 된다.
| 선언 형태 | 읽기 | 쓰기 | 쓰임 |
|---|---|---|---|
| read FName | 필드를 바로 읽는다 | 불가(컴파일 오류) | 바깥에서 못 바꾸는 값 |
| read FPrice write SetPrice | 필드를 바로 읽는다 | setter 가 검사 후 대입 | 값 범위를 지켜야 할 때 |
| read GetLineCount | getter 가 계산해 돌려준다 | 불가 | 저장하지 않고 계산하는 값 |
setter 의 이름은 관례로 Set으로, getter 의 이름은 Get으로 시작하고 strict private 쪽에 둔다. setter 안에서는 property 가 아니라 반드시 필드(FPrice)에 대입한다.
완성 코드
재고 품목 TStockItem과 주문 TOrder를 만든다. 주문은 주문 줄을 담을 TStringList(Classes 유닛의 문자열 목록 클래스)를 속에 품고 있으므로 소멸자에서 이것을 해제한다. 파일 이름은 main.pas다.
{$mode objfpc}{$H+}
program Main;
uses
SysUtils, Classes;
type
TStockItem = class
strict private
FName: string;
FPrice: Integer;
FQuantity: Integer;
procedure SetPrice(AValue: Integer);
public
constructor Create(const AName: string; APrice, AQuantity: Integer);
function Sell(ACount: Integer): Boolean;
procedure Restock(ACount: Integer);
property Name: string read FName;
property Price: Integer read FPrice write SetPrice;
property Quantity: Integer read FQuantity;
end;
TOrder = class
strict private
FTable: Integer;
FLines: TStringList;
FTotal: Integer;
function GetLineCount: Integer;
public
constructor Create(ATable: Integer);
destructor Destroy; override;
function Add(AItem: TStockItem; ACount: Integer): Boolean;
procedure Print;
property Table: Integer read FTable;
property LineCount: Integer read GetLineCount;
property Total: Integer read FTotal;
end;
constructor TStockItem.Create(const AName: string; APrice, AQuantity: Integer);
begin
inherited Create;
FName := AName;
FPrice := APrice;
FQuantity := AQuantity;
end;
procedure TStockItem.SetPrice(AValue: Integer);
begin
if AValue > 0 then
FPrice := AValue;
end;
function TStockItem.Sell(ACount: Integer): Boolean;
begin
if (ACount > 0) and (ACount <= FQuantity) then
begin
Dec(FQuantity, ACount);
Result := True;
end
else
Result := False;
end;
procedure TStockItem.Restock(ACount: Integer);
begin
Inc(FQuantity, ACount);
end;
constructor TOrder.Create(ATable: Integer);
begin
inherited Create;
FTable := ATable;
FLines := TStringList.Create;
FTotal := 0;
end;
destructor TOrder.Destroy;
begin
FLines.Free;
inherited Destroy;
end;
function TOrder.GetLineCount: Integer;
begin
Result := FLines.Count;
end;
function TOrder.Add(AItem: TStockItem; ACount: Integer): Boolean;
begin
if AItem.Sell(ACount) then
begin
FLines.Add(Format('%s x %d', [AItem.Name, ACount]));
Inc(FTotal, AItem.Price * ACount);
Result := True;
end
else
Result := False;
end;
procedure TOrder.Print;
var
I: Integer;
begin
WriteLn(Format('주문 테이블 %d (%d줄)', [FTable, LineCount]));
for I := 0 to FLines.Count - 1 do
WriteLn(' ', FLines[I]);
WriteLn(Format(' 합계 %d원', [FTotal]));
end;
var
Americano: TStockItem;
Latte: TStockItem;
Order: TOrder;
begin
Americano := TStockItem.Create('아메리카노', 3000, 10);
try
Latte := TStockItem.Create('라테', 3500, 2);
try
Order := TOrder.Create(3);
try
Order.Add(Americano, 2);
Order.Add(Latte, 1);
if not Order.Add(Latte, 5) then
WriteLn('재고 부족: ', Latte.Name, ' 남은 수량 ', Latte.Quantity);
Order.Print;
finally
Order.Free;
end;
Americano.Price := 3200;
Latte.Price := -1;
WriteLn(Format('%s %d원', [Americano.Name, Americano.Price]));
WriteLn(Format('%s %d원', [Latte.Name, Latte.Price]));
WriteLn('남은 재고: ', Americano.Quantity, ' / ', Latte.Quantity);
Latte.Restock(4);
WriteLn('라테 입고 후 재고: ', Latte.Quantity);
finally
Latte.Free;
end;
finally
Americano.Free;
end;
end.
줄별 해설
TStockItem
strict private 아래의 FName, FPrice, FQuantity는 바깥에서 보이지 않는 필드다. SetPrice도 같은 구역에 있어서 바깥에서는 부를 수 없고, property 를 통해서만 실행된다.
constructor Create는 조상의 준비를 시키고(inherited Create) 받은 값을 필드에 넣는다. 이름 필드는 읽기 전용 property Name으로만 열려 있어 객체를 만든 뒤에는 이름을 바꿀 수 없다.
SetPrice는 0보다 큰 값만 받아들이고, 아니면 아무것도 하지 않는다. 그래서 Latte.Price := -1이 가격을 바꾸지 못한다. 오류를 알리는 방법은 예외 처리를 다루는 장에서 배우므로 여기서는 조용히 무시한다.
Sell은 요청 수량이 1 이상이고 재고 이하일 때만 재고를 줄이고 True를 돌려준다. 재고를 줄이는 코드가 이 한 곳뿐이므로 재고가 음수가 될 길이 없다. Restock은 입고된 수량만큼 재고를 늘린다.
TOrder
TOrder.Create는 TStringList.Create로 주문 줄 목록을 만든다. 주문이 이 목록을 만들었으므로 해제할 책임도 주문에 있다. destructor Destroy; override;가 그 자리다. 본문에서 FLines.Free로 목록을 해제한 다음 inherited Destroy를 부른다. 자기 것을 먼저 치우고 조상의 정리를 맨 마지막에 하는 순서가 일반적이다.
GetLineCount는 저장해 둔 값이 아니라 목록의 현재 개수를 돌려주는 getter 다. 그래서 LineCount는 줄이 추가될 때마다 따로 갱신하지 않아도 항상 맞다.
Add는 먼저 품목의 Sell을 불러 재고 확인과 차감을 맡기고, 성공했을 때만 주문 줄과 합계를 갱신한다. 판매에 실패하면 주문에는 아무것도 남지 않는다. AItem은 주문이 만든 객체가 아니라 빌려 쓰는 것이므로 TOrder가 해제하지 않는다. 만든 쪽이 해제한다는 원칙이다.
메인 프로그램
세 객체를 만들 때마다 바로 아래에 try를 열고, 가장 안쪽 finally부터 바깥쪽 순서로 해제한다. 만든 순서의 반대로 해제된다. 주문은 두 품목을 가리키는 객체이므로 품목보다 먼저 해제하는 것이 안전하다.
첫 Order.Add(Latte, 1)은 라테 재고 2개 중 1개를 팔고, 이어지는 Order.Add(Latte, 5)는 남은 재고 1개로는 5개를 팔 수 없어 False를 돌려준다. 이때 Add는 재고도 합계도 건드리지 않는다. 합계는 3000×2 + 3500×1 = 9500원이다.
주문을 해제한 뒤 Americano.Price := 3200은 setter 를 거쳐 가격이 바뀐다. Latte.Price := -1은 setter 가 거부하므로 3500원이 유지된다. 마지막으로 Restock(4)가 라테 재고 1을 5로 늘린다.
실행 결과
$ fpc main.pas
$ ./main
재고 부족: 라테 남은 수량 1
주문 테이블 3 (2줄)
아메리카노 x 2
라테 x 1
합계 9500원
아메리카노 3200원
라테 3500원
남은 재고: 8 / 1
라테 입고 후 재고: 5
실무에서 자주 틀리는 것
1. Free 를 부르지 않는다
틀린 코드는 다음과 같다. 프로그램은 정상으로 보이게 돌지만 객체가 해제되지 않는다.
Order := TOrder.Create(3);
Order.Add(Latte, 1);
Order.Print;
// Free 가 없다. 객체와 안의 TStringList 가 남는다.
고친 코드는 생성 바로 아래에 try finally를 쓰는 형태다.
Order := TOrder.Create(3);
try
Order.Add(Latte, 1);
Order.Print;
finally
Order.Free;
end;
2. Create 를 try 안에 넣는다
틀린 코드는 다음과 같다.
try
Order := TOrder.Create(3);
Order.Print;
finally
Order.Free;
end;
Create가 도중에 실패하면 Order에는 값이 대입되지 않았는데도 finally가 실행되어, 아직 쓸 수 없는 변수에 Free를 부르게 된다. 고친 코드는 Create를 try 앞에 둔다.
Order := TOrder.Create(3);
try
Order.Print;
finally
Order.Free;
end;
3. Destroy 를 직접 부르거나 소멸자 선언에서 override 를 빠뜨린다
틀린 코드는 다음과 같다.
Order.Destroy; // 변수가 nil 인지 확인하지 않는다
destructor Destroy; // override 가 없다. 컴파일러가 경고한다.
Destroy를 직접 부르면 nil 검사가 없어서 비어 있는 변수에는 오류가 난다. 선언에서 override를 빼면 조상의 Destroy를 덮어쓰지 못하고 이름만 같은 별개의 메서드가 되어, Free가 부르는 것은 조상 쪽이다. 안의 FLines가 해제되지 않는다. 고친 코드는 다음과 같다.
Order.Free;
destructor Destroy; override;
4. setter 안에서 property 에 대입한다
틀린 코드는 다음과 같다.
procedure TStockItem.SetPrice(AValue: Integer);
begin
if AValue > 0 then
Price := AValue; // property 대입은 SetPrice 호출이다
end;
Price := AValue가 다시 SetPrice를 부르므로 호출이 끝없이 이어져 프로그램이 멈춘다. 고친 코드는 필드에 대입한다.
procedure TStockItem.SetPrice(AValue: Integer);
begin
if AValue > 0 then
FPrice := AValue;
end;
한눈에 보기
| 항목 | 문법 | 역할 | 주의 |
|---|---|---|---|
| 클래스 선언 | TName = class ... end; | 필드와 메서드를 한 타입에 묶는다 | 변수는 객체의 참조일 뿐이다 |
| 생성자 | constructor Create(...) | 객체를 만들고 필드를 초기화한다 | 첫 줄에 inherited Create |
| 소멸자 | destructor Destroy; override; | 품고 있는 객체를 해제한다 | 마지막 줄에 inherited Destroy |
| 해제 | try ... finally Obj.Free; end | 어떤 경우에도 해제한다 | Create 는 try 앞에 쓴다 |
| 가시성 | strict private / public | 바깥에 보일 범위를 정한다 | private 은 같은 파일에는 열려 있다 |
| property | property X: T read FX write SetX; | 필드 접근에 검사를 끼운다 | setter 안에서는 필드에 대입한다 |
연습 문제
- 다음 코드에서 해제와 관련된 문제 두 가지를 찾아라.
Item := TStockItem.Create('모카', 4000, 5); Order := TOrder.Create(1); Order.Add(Item, 1); Order.Print; Order.Free; TStockItem에 읽기 전용 propertyIsSoldOut: Boolean을 추가하라. 재고가 0이면True이다. 값을 따로 저장하지 않고 getter 로 계산한다.Restock이 0 이하의 수량을 무시하도록 고쳐라. 이 규칙이 한 곳에만 있으면 되는 이유를 한 문장으로 써라.- 객체
A와B를 함께 쓰는 코드를try finally로 안전하게 작성하라. 둘 다TStockItem이며 만드는 값은 임의로 정해도 된다.
정답과 해설
- 첫째,
Item을 해제하지 않아 누수가 생긴다. 둘째,Order.Free가try finally안에 없어서Add나Print에서 흐름이 빠져나가면Free에 도달하지 못한다. 고치려면 두 객체 모두 생성 바로 아래에try를 열고finally에서 해제한다. - 선언부의
strict private에function GetIsSoldOut: Boolean;를,public에property IsSoldOut: Boolean read GetIsSoldOut;를 추가한다. 본문은 다음과 같다.
계산해서 돌려주므로 재고가 바뀔 때마다 따로 갱신할 필요가 없다.function TStockItem.GetIsSoldOut: Boolean; begin Result := FQuantity = 0; end; - 본문을 다음과 같이 고친다.
재고를 바꾸는 경로가 메서드 하나뿐이면 검사를 한 번만 써도 모든 호출에 적용되기 때문이다.procedure TStockItem.Restock(ACount: Integer); begin if ACount > 0 then Inc(FQuantity, ACount); end; - 객체마다
try finally를 하나씩 쓴다.A := TStockItem.Create('아메리카노', 3000, 10); try B := TStockItem.Create('라테', 3500, 2); try WriteLn(A.Name, ' ', B.Name); finally B.Free; end; finally A.Free; end;B를 만들다 문제가 생겨도 이미 만든A는 바깥finally에서 해제된다.