Object Pascal · 기본
Object Pascal 첫걸음
배열·레코드·열거·집합 - 자료 묶기
정적 배열과 범위 첨자, 동적 배열과 SetLength·Length·High, 레코드, 열거형과 Ord, 집합 타입과 in
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서는 프로시저와 함수에 값을 넘기는 방식을 배웠다. 지금까지 만든 카페 계산대 프로그램은 메뉴 이름, 가격, 재고를 낱개 변수로 따로따로 들고 있었다. 메뉴가 네 개만 되어도 변수가 열두 개로 늘어난다. 이 장에서는 같은 종류의 값을 줄 세우는 배열, 서로 다른 종류의 값을 한 덩어리로 묶는 레코드, 이름 붙은 선택지를 만드는 열거형, 선택지의 조합을 담는 집합을 배운다. 이 네 가지를 쓰면 메뉴·주문·재고를 구조를 가진 데이터로 다룰 수 있다.
- 정적 배열(static array)을 범위 첨자
[1..4]로 선언하고Low,High로 순회한다. - 동적 배열(dynamic array)의 길이를
SetLength로 바꾸고Length,High로 읽는다. - 레코드(record)로 메뉴 항목과 주문 한 줄을 하나의 타입으로 묶고, 복사하면 값이 따로 생김을 확인한다.
- 열거형(enumeration)과
Ord로 분류를 표현하고, 열거형을 첨자로 쓰는 배열을 만든다. - 집합(set) 타입과
in연산자로 주문 옵션의 조합을 한 변수에 담는다.
문제 상황
카페 사장이 계산대 프로그램에 다음을 요청했다. 메뉴는 아메리카노, 카페라테, 녹차, 치즈케이크 네 가지다. 주문에는 아이스, 샷 추가, 포장 옵션이 붙을 수 있고 여러 개를 동시에 고를 수 있다. 하루 동안 주문이 몇 건 들어올지 미리 알 수 없다. 마감 때는 분류별(커피, 차, 디저트) 매출과 남은 재고를 보고 싶어 한다.
낱개 변수로 만들면 이렇게 된다.
var
Name1, Name2, Name3, Name4: string;
Price1, Price2, Price3, Price4: Integer;
Stock1, Stock2, Stock3, Stock4: Integer;
Iced1, ExtraShot1, Takeout1: Boolean; // 주문 1건의 옵션만 해도 이만큼
메뉴가 다섯 개가 되면 변수를 또 추가하고, 모든 계산 코드를 복사해서 번호만 바꿔야 한다. 주문 건수는 실행해 봐야 알기 때문에 변수 개수를 정할 수도 없다. 필요한 것은 네 가지다. 같은 종류를 번호로 찾는 줄(배열), 서로 다른 항목을 묶는 상자(레코드), 오타를 막아 주는 이름 붙은 선택지(열거형), 선택지 여러 개를 한 번에 담는 주머니(집합)다.
배열: 같은 종류를 번호로 찾는다
정적 배열과 범위 첨자
배열은 같은 타입의 값을 연달아 놓고 번호(첨자, index)로 하나씩 꺼내 쓰는 구조다. 정적 배열은 칸 수가 소스 코드에 박혀 있다.
var
Stock: array[1..4] of Integer;
[1..4]가 첨자의 범위다. Pascal은 첨자가 0부터 시작해야 한다고 강제하지 않는다. 메뉴 번호가 1번부터라면 범위도 1부터 잡으면 되고, [0..6]이나 [-2..2]도 가능하다. 범위의 끝을 확인할 때는 숫자를 직접 쓰지 말고 Low(Stock)와 High(Stock)를 쓴다. 범위를 고치더라도 순회 코드는 그대로 둘 수 있다.
for I := Low(Stock) to High(Stock) do
WriteLn(I, '번 재고: ', Stock[I]);
범위 밖 첨자를 쓰면 어떻게 되는지는 아래 "자주 틀리는 것"에서 다룬다. 여기서는 상수 첨자(Stock[0])는 컴파일러가 잡아 주지만, 변수 첨자는 기본 설정에서 검사하지 않는다는 점만 기억해 두자.
동적 배열과 SetLength
주문 건수처럼 실행 중에야 알 수 있는 개수에는 동적 배열을 쓴다. 범위를 적지 않고 array of만 쓴다.
var
Orders: array of Integer;
SetLength(Orders, 3); // 칸 3개: Orders[0], Orders[1], Orders[2]
동적 배열의 규칙은 세 가지다.
- 첨자는 항상 0부터 시작한다.
- 선언만 한 배열은 길이가 0이다.
SetLength로 칸을 만들기 전에는 아무 칸도 없다. Length(배열)은 칸 수,High(배열)는 마지막 첨자이므로High = Length - 1이다. 빈 배열에서는High가 -1이다.
길이를 늘려도 기존 칸의 값은 남고, 새로 생긴 칸은 0(문자열은 빈 문자열)으로 채워진다. 그래서 for I := 0 to High(Orders)는 빈 배열에서도 안전하다. 0부터 -1까지의 반복은 한 번도 실행되지 않는다.
두 배열 비교
| 항목 | 정적 배열 | 동적 배열 | 예 |
|---|---|---|---|
| 선언 | 범위를 적는다 | 범위를 적지 않는다 | array[1..4] / array of |
| 칸 수가 정해지는 때 | 컴파일할 때 | 실행 중 SetLength | 메뉴 수 / 주문 수 |
| 첨자 시작 | 선언한 범위의 시작 | 항상 0 | Low로 확인 |
대입(B := A) | 값 전체를 복사 | 같은 배열을 가리킴 | 아래 4번째 항목 참고 |
레코드와 열거형: 데이터에 모양을 준다
레코드
레코드는 서로 다른 타입의 필드(field)를 하나의 타입으로 묶는다. 메뉴 한 항목은 이름, 가격, 분류를 함께 가진다.
type
TMenuItem = record
Name: string;
Price: Integer;
Category: TCategory;
end;
필드는 점(.)으로 접근한다. 레코드를 배열의 원소로 쓰면 Menu[2].Price처럼 "2번 메뉴의 가격"이라는 읽기 쉬운 식이 된다. 레코드 변수끼리 대입하면 모든 필드가 복사되고 두 변수는 그 뒤로 서로 영향을 주지 않는다. 함수에 넘길 때 읽기만 한다면 앞 장에서 배운 const 매개변수로 받으면 된다.
열거형과 Ord
분류를 숫자 0, 1, 2로 표현하면 코드를 읽는 사람이 "2가 뭐였지"를 매번 찾아봐야 한다. 열거형은 이름 목록을 직접 타입으로 만든다.
type
TCategory = (catCoffee, catTea, catDessert);
열거형의 이름들은 선언된 순서대로 0, 1, 2번을 받는다. 이 번호를 서수(ordinal)라 하고 Ord 함수로 꺼낸다. Ord(catCoffee)는 0, Ord(catDessert)는 2다. 반대로 정수를 열거형으로 바꾸려면 TCategory(1)처럼 타입 이름으로 변환한다. 열거형은 정수와 서로 다른 타입이라 Category := 1이라고 쓰면 컴파일 오류가 난다. 오타나 엉뚱한 값을 컴파일 단계에서 걸러 주는 장치다.
열거형은 순서가 있는 타입이므로 for C := Low(TCategory) to High(TCategory)로 전체를 돌 수 있고, 배열의 첨자 범위로도 쓸 수 있다.
const
CategoryNames: array[TCategory] of string = ('커피', '차', '디저트');
이 배열은 CategoryNames[catTea]처럼 이름으로 찾는다. 열거형에 항목을 하나 더하면 칸 수가 맞지 않아 컴파일러가 알려 주므로, 이름 표를 갱신하는 것을 잊지 않는다.
집합: 선택지의 조합을 한 변수에
주문 옵션은 아이스, 샷 추가, 포장 중 0개부터 3개까지 아무 조합이나 가능하다. Pascal의 집합 타입은 열거형의 부분집합을 한 변수에 담는다.
type
TOption = (opIced, opExtraShot, opTakeout);
TOptions = set of TOption;
var
Opts: TOptions;
begin
Opts := [opIced, opTakeout]; // 아이스 + 포장
Opts := []; // 옵션 없음
집합 값은 대괄호로 쓴다. 속에 아무것도 없는 []는 공집합이다. 원소가 들어 있는지는 in으로 묻고 결과는 Boolean이다.
if opExtraShot in Opts then
Price := Price + 500;
원소를 넣고 빼는 방법은 Include(Opts, opIced), Exclude(Opts, opIced)다. +(합집합), -(차집합), *(교집합) 연산자도 있다. 집합 안의 순서나 중복은 의미가 없다. 같은 원소를 두 번 넣어도 한 번 들어 있는 것과 같다.
| 연산 | 뜻 | 식 | 결과 |
|---|---|---|---|
| 합집합 | 둘 중 하나라도 있는 원소 | A + B | [opIced, opExtraShot, opTakeout] |
| 교집합 | 둘 다 있는 원소 | A * B | [opExtraShot] |
| 차집합 | 앞에만 있는 원소 | A - B | [opIced] |
| 포함 검사 | 원소가 들어 있는가 | opIced in A | True |
집합은 내부적으로 원소마다 비트 하나를 배정해 저장한다. 그래서 옵션 세 개를 Boolean 변수 세 개로 두는 것보다 작고, 통째로 비교하거나 복사하기 쉽다. 집합의 기반 타입은 원소가 256개 이하인 순서 타입이어야 하므로 열거형이나 작은 범위의 정수가 대상이다. 문자열이나 실수는 집합의 원소가 될 수 없다.
Delphi와 다른 점: 이 장의 내용은 Delphi에서도 거의 같게 동작한다. 다만 Free Pascal 3.2에서는
begin블록 안에서 변수를 선언하는 인라인 변수 선언을 쓸 수 없다. 이 책의 예제는 항상var구역에서 선언한다. 문서는 Free Pascal Reference guide에서 확인할 수 있다.
완성 코드
메뉴 네 개와 주문 다섯 건을 처리해 매출·분류별 합계·재고를 출력한다. 파일 이름은 main.pas다.
{$mode objfpc}{$H+}
program Main;
type
TCategory = (catCoffee, catTea, catDessert);
TOption = (opIced, opExtraShot, opTakeout);
TOptions = set of TOption;
TMenuItem = record
Name: string;
Price: Integer;
Category: TCategory;
end;
TOrderLine = record
MenuIndex: Integer;
Qty: Integer;
Options: TOptions;
end;
const
MenuCount = 4;
ExtraShotPrice = 500;
LowStockLimit = 2;
CategoryNames: array[TCategory] of string = ('커피', '차', '디저트');
OptionNames: array[TOption] of string = ('아이스', '샷 추가', '포장');
var
Menu: array[1..MenuCount] of TMenuItem;
Stock: array[1..MenuCount] of Integer;
Orders: array of TOrderLine;
procedure SetItem(Index: Integer; const AName: string; APrice: Integer;
ACategory: TCategory; AStock: Integer);
begin
Menu[Index].Name := AName;
Menu[Index].Price := APrice;
Menu[Index].Category := ACategory;
Stock[Index] := AStock;
end;
procedure AddOrder(MenuIndex, Qty: Integer; Opts: TOptions);
var
N: Integer;
begin
N := Length(Orders);
SetLength(Orders, N + 1);
Orders[N].MenuIndex := MenuIndex;
Orders[N].Qty := Qty;
Orders[N].Options := Opts;
Dec(Stock[MenuIndex], Qty);
end;
function LineTotal(const Line: TOrderLine): Integer;
begin
Result := Menu[Line.MenuIndex].Price * Line.Qty;
if opExtraShot in Line.Options then
Inc(Result, ExtraShotPrice * Line.Qty);
end;
function OptionsText(Opts: TOptions): string;
var
O: TOption;
begin
Result := '';
for O := Low(TOption) to High(TOption) do
if O in Opts then
begin
if Result <> '' then
Result := Result + ',';
Result := Result + OptionNames[O];
end;
if Result = '' then
Result := '없음';
end;
var
I, Amount, Total, TakeoutCount: Integer;
C: TCategory;
CategoryTotal: array[TCategory] of Integer;
Temp: TMenuItem;
begin
SetItem(1, '아메리카노', 3000, catCoffee, 10);
SetItem(2, '카페라테', 3500, catCoffee, 8);
SetItem(3, '녹차', 3200, catTea, 5);
SetItem(4, '치즈케이크', 5500, catDessert, 3);
WriteLn('초기 주문 수: ', Length(Orders), ', High = ', High(Orders));
AddOrder(1, 2, [opIced]);
AddOrder(2, 1, [opExtraShot, opTakeout]);
AddOrder(4, 2, []);
AddOrder(3, 1, [opTakeout]);
AddOrder(1, 3, [opIced, opTakeout]);
Total := 0;
TakeoutCount := 0;
for C := Low(TCategory) to High(TCategory) do
CategoryTotal[C] := 0;
for I := 0 to High(Orders) do
begin
Amount := LineTotal(Orders[I]);
WriteLn('주문 ', I + 1, ': ', Menu[Orders[I].MenuIndex].Name,
' x', Orders[I].Qty, ' [', OptionsText(Orders[I].Options), '] = ',
Amount, '원');
Inc(Total, Amount);
Inc(CategoryTotal[Menu[Orders[I].MenuIndex].Category], Amount);
if opTakeout in Orders[I].Options then
Inc(TakeoutCount);
end;
WriteLn('주문 수: ', Length(Orders), ', High = ', High(Orders));
WriteLn('매출 합계: ', Total, '원 (포장 ', TakeoutCount, '건)');
for C := Low(TCategory) to High(TCategory) do
WriteLn(CategoryNames[C], '(', Ord(C), '): ', CategoryTotal[C], '원');
WriteLn('재고');
for I := Low(Stock) to High(Stock) do
begin
Write(Menu[I].Name, ': ', Stock[I], '개');
if Stock[I] <= LowStockLimit then
Write(' (재고 부족)');
WriteLn;
end;
Temp := Menu[4];
Temp.Price := 6000;
WriteLn('복사본 가격 ', Temp.Price, ', 원본 가격 ', Menu[4].Price);
end.
줄별 해설
type구역의TCategory,TOption,TOptions는 열거형 두 개와 그 위에 만든 집합 타입이다. 집합을 쓰려면 집합 타입에 이름을 먼저 붙여 두는 편이 매개변수나 필드에서 쓰기 편하다.TMenuItem과TOrderLine은 레코드다. 주문 한 줄은 메뉴 번호, 수량, 옵션 집합을 가진다. 가격을 주문에 복사해 두지 않고 번호만 들고 있으므로 가격은Menu에서 찾는다.CategoryNames와OptionNames는 열거형을 첨자로 쓰는 상수 배열이다. 값은 열거형 선언 순서와 같게 적는다.Menu와Stock은 범위1..MenuCount의 정적 배열이다. 메뉴 번호가 1부터라서 범위도 1부터 잡았다.Orders는 길이 0으로 시작하는 동적 배열이다.AddOrder는Length로 현재 건수N을 읽고SetLength(Orders, N + 1)로 한 칸을 늘린다. 새 칸의 첨자는 항상N이다. 이어서Dec(Stock[MenuIndex], Qty)로 재고를 줄인다. 집합 인수는 호출하는 쪽에서[opIced]나[]처럼 직접 적는다.LineTotal은 가격과 수량을 곱한 뒤,opExtraShot in Line.Options가 참이면 샷 추가 금액을 더한다. 레코드는 읽기만 하므로const로 받는다.OptionsText는 열거형 전체를for로 돌며 집합에 든 원소의 이름만 쉼표로 이어 붙인다. 하나도 없으면 '없음'을 돌려준다.- 본문 첫머리의
WriteLn은 빈 동적 배열에서Length가 0,High가 -1임을 보여 준다. - 주문 루프는
0부터High(Orders)까지 돈다. 주문 번호를 사람이 읽기 좋게I + 1로 출력한다.CategoryTotal[Menu[...].Category]는 열거형 값을 첨자로 써서 분류별 합계에 금액을 더한다. - 분류 출력 루프의
Ord(C)는 열거형의 서수를 보여 준다. - 재고 루프는
Low(Stock)부터High(Stock)까지 돈다. 기준값 이하이면 안내 문구를 덧붙인다. - 마지막 세 줄은 레코드 대입이 복사임을 확인한다.
Temp의 가격을 바꿔도Menu[4]는 5500 그대로다.
실행 결과
$ fpc main.pas
$ ./main
초기 주문 수: 0, High = -1
주문 1: 아메리카노 x2 [아이스] = 6000원
주문 2: 카페라테 x1 [샷 추가,포장] = 4000원
주문 3: 치즈케이크 x2 [없음] = 11000원
주문 4: 녹차 x1 [포장] = 3200원
주문 5: 아메리카노 x3 [아이스,포장] = 9000원
주문 수: 5, High = 4
매출 합계: 33200원 (포장 3건)
커피(0): 19000원
차(1): 3200원
디저트(2): 11000원
재고
아메리카노: 5개
카페라테: 7개
녹차: 4개
치즈케이크: 1개 (재고 부족)
복사본 가격 6000, 원본 가격 5500
실무에서 자주 틀리는 것
변수 첨자가 범위를 벗어난다
상수 첨자는 컴파일러가 막지만, 계산해서 나온 첨자는 기본 설정에서 검사하지 않는다. 틀린 코드는 컴파일이 통과하고, 범위 밖 메모리를 조용히 덮어쓴다.
I := MenuCount + 1;
Stock[I] := 0; // 5번 칸은 없다. 오류 없이 다른 메모리를 건드린다
첨자를 쓰기 전에 범위를 확인한다. 개발 중에는 {$R+} 지시자를 켜 두면 범위 밖 접근이 실행 중 오류로 드러난다.
if (I >= Low(Stock)) and (I <= High(Stock)) then
Stock[I] := 0;
동적 배열의 끝과 길이를 헷갈린다
칸 수 Length를 마지막 첨자로 착각하면 한 칸 넘어서 접근한다. 또 SetLength 없이 칸을 쓰는 것도 같은 종류의 실수다.
for I := 0 to Length(Orders) do // 마지막에 없는 칸을 읽는다
Total := Total + LineTotal(Orders[I]);
Orders[0].Qty := 1; // 길이가 0이라 칸이 없다
마지막 첨자는 High이고, 칸이 없으면 먼저 SetLength로 만든다.
for I := 0 to High(Orders) do
Total := Total + LineTotal(Orders[I]);
SetLength(Orders, 1);
Orders[0].Qty := 1;
집합에 원소를 그대로 대입하거나 합친다
집합 변수에는 집합 값이 들어가야 한다. 원소 하나는 집합이 아니다.
Opts := opIced; // 컴파일 오류: 원소와 집합은 다른 타입
Opts := Opts + opTakeout; // 컴파일 오류: 오른쪽도 집합이어야 한다
if Opts in opIced then ... // 컴파일 오류: in 의 왼쪽이 원소다
원소는 대괄호로 감싸 집합으로 만들거나 Include를 쓴다. in은 원소를 왼쪽에 둔다.
Opts := [opIced];
Opts := Opts + [opTakeout];
Include(Opts, opTakeout);
if opIced in Opts then ...
동적 배열을 대입하면 복사된다고 생각한다
레코드와 정적 배열은 대입하면 값이 복사되지만, 동적 배열은 같은 칸들을 가리키는 두 이름이 생길 뿐이다.
Backup := Orders; // 같은 배열을 가리킨다
Orders[0].Qty := 0; // Backup[0].Qty 도 0 이 된다
따로 보관하려면 Copy로 새 배열을 만든다.
Backup := Copy(Orders, 0, Length(Orders));
Orders[0].Qty := 0; // Backup 은 그대로다
한눈에 보기
| 구조 | 선언 예 | 핵심 도구 | 주의 |
|---|---|---|---|
| 정적 배열 | array[1..4] of Integer | Low, High | 변수 첨자는 기본적으로 검사하지 않는다 |
| 동적 배열 | array of TOrderLine | SetLength, Length, High | 첨자는 0부터, 대입하면 공유된다 |
| 레코드 | record ... end | 점(.)으로 필드 접근 | 대입하면 복사된다 |
| 열거형 | (catCoffee, catTea) | Ord, TCategory(n) | 정수를 직접 대입할 수 없다 |
| 집합 | set of TOption | in, Include, Exclude, + - * | 원소는 대괄호로 감싼다 |
연습 문제
- 요일 열거형
TWeekday = (wdMon, wdTue, wdWed, wdThu, wdFri, wdSat, wdSun)과Sales: array[TWeekday] of Integer가 있다. 값이 이미 채워져 있다고 할 때, 주말(토, 일) 매출만 더해Weekend변수에 담는 코드를 쓰라. 집합과in을 사용한다. - 동적 배열
Orders에서 가장 마지막 주문을 취소(삭제)하는 프로시저RemoveLast를 쓰라. 배열이 비어 있을 때도 안전해야 한다. - 다음 두 코드 조각이 출력하는 값을 각각 쓰고, 이유를 설명하라.
A.Price := 3000; B := A; B.Price := 4000; WriteLn(A.Price);
(SetLength(X, 1); X[0] := 1; Y := X; Y[0] := 2; WriteLn(X[0]);A,B는TMenuItem,X,Y는array of Integer다.) A = [opIced, opExtraShot],B = [opExtraShot, opTakeout]일 때A + B,A * B,A - B의 결과를 쓰라. 또Ord(opTakeout)의 값은 얼마인가.
정답과 해설
- 열거형 전체를 돌면서 집합에 속한 요일만 더한다.
Weekend := 0; for D := Low(TWeekday) to High(TWeekday) do if D in [wdSat, wdSun] then Inc(Weekend, Sales[D]);D는TWeekday타입 변수다. 평일을 더하고 싶으면 조건을not (D in [wdSat, wdSun])으로 바꾸면 된다. - 길이가 0보다 클 때만 한 칸 줄인다.
가드가 없으면 빈 배열에서 길이가 -1이 되어 오류가 난다. 줄인 칸의 재고를 되돌리는 일은 이 프로시저의 몫이 아니므로 필요하면 호출하는 쪽에서 처리한다.procedure RemoveLast; begin if Length(Orders) > 0 then SetLength(Orders, Length(Orders) - 1); end; - 첫 번째는 3000이다. 레코드 대입은 필드 값을 복사하므로
B를 바꿔도A는 그대로다. 두 번째는 2다. 동적 배열 대입은 같은 칸들을 가리키게 하므로Y[0]을 바꾸면X[0]이 바뀐다. 따로 두려면Y := Copy(X, 0, Length(X))를 쓴다. A + B는 [opIced, opExtraShot, opTakeout],A * B는 [opExtraShot],A - B는 [opIced]다.Ord(opTakeout)은 선언 순서가 세 번째이므로 2다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.