Devin.KR

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메뉴 수 / 주문 수
첨자 시작선언한 범위의 시작항상 0Low로 확인
대입(B := A)값 전체를 복사같은 배열을 가리킴아래 4번째 항목 참고
정적 배열은 선언한 범위가 고정이고, 동적 배열은 0부터 시작하며 SetLength로 칸이 늘어난다.

레코드와 열거형: 데이터에 모양을 준다

레코드

레코드는 서로 다른 타입의 필드(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 = [opIced, opExtraShot], B = [opExtraShot, opTakeout])
연산뜻식결과
합집합둘 중 하나라도 있는 원소A + B[opIced, opExtraShot, opTakeout]
교집합둘 다 있는 원소A * B[opExtraShot]
차집합앞에만 있는 원소A - B[opIced]
포함 검사원소가 들어 있는가opIced in ATrue

집합은 내부적으로 원소마다 비트 하나를 배정해 저장한다. 그래서 옵션 세 개를 Boolean 변수 세 개로 두는 것보다 작고, 통째로 비교하거나 복사하기 쉽다. 집합의 기반 타입은 원소가 256개 이하인 순서 타입이어야 하므로 열거형이나 작은 범위의 정수가 대상이다. 문자열이나 실수는 집합의 원소가 될 수 없다.

집합은 열거형 원소마다 포함 여부 비트를 하나씩 가지며, in 연산자는 그 비트를 읽는다.

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 IntegerLow, High변수 첨자는 기본적으로 검사하지 않는다
동적 배열array of TOrderLineSetLength, Length, High첨자는 0부터, 대입하면 공유된다
레코드record ... end점(.)으로 필드 접근대입하면 복사된다
열거형(catCoffee, catTea)Ord, TCategory(n)정수를 직접 대입할 수 없다
집합set of TOptionin, Include, Exclude, + - *원소는 대괄호로 감싼다

연습 문제

  1. 요일 열거형 TWeekday = (wdMon, wdTue, wdWed, wdThu, wdFri, wdSat, wdSun)과 Sales: array[TWeekday] of Integer가 있다. 값이 이미 채워져 있다고 할 때, 주말(토, 일) 매출만 더해 Weekend 변수에 담는 코드를 쓰라. 집합과 in을 사용한다.
  2. 동적 배열 Orders에서 가장 마지막 주문을 취소(삭제)하는 프로시저 RemoveLast를 쓰라. 배열이 비어 있을 때도 안전해야 한다.
  3. 다음 두 코드 조각이 출력하는 값을 각각 쓰고, 이유를 설명하라.
    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다.)
  4. A = [opIced, opExtraShot], B = [opExtraShot, opTakeout]일 때 A + B, A * B, A - B의 결과를 쓰라. 또 Ord(opTakeout)의 값은 얼마인가.

정답과 해설

  1. 열거형 전체를 돌면서 집합에 속한 요일만 더한다.
    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])으로 바꾸면 된다.
  2. 길이가 0보다 클 때만 한 칸 줄인다.
    procedure RemoveLast;
    begin
      if Length(Orders) > 0 then
        SetLength(Orders, Length(Orders) - 1);
    end;
    
    가드가 없으면 빈 배열에서 길이가 -1이 되어 오류가 난다. 줄인 칸의 재고를 되돌리는 일은 이 프로시저의 몫이 아니므로 필요하면 호출하는 쪽에서 처리한다.
  3. 첫 번째는 3000이다. 레코드 대입은 필드 값을 복사하므로 B를 바꿔도 A는 그대로다. 두 번째는 2다. 동적 배열 대입은 같은 칸들을 가리키게 하므로 Y[0]을 바꾸면 X[0]이 바뀐다. 따로 두려면 Y := Copy(X, 0, Length(X))를 쓴다.
  4. A + B는 [opIced, opExtraShot, opTakeout], A * B는 [opExtraShot], A - B는 [opIced]다. Ord(opTakeout)은 선언 순서가 세 번째이므로 2다.
오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.