Devin.KR

문자열 다루기 - Format 과 SysUtils

개발자KR 조회 0

이 장에서 배우는 것

카페 계산대 프로그램은 숫자만으로 돌아가지 않는다. 메뉴 이름을 읽고, 손님이 입력한 주문 문장을 쪼개고, 금액에 쉼표를 찍어 영수증을 만든다. 이 모든 일이 문자열 처리다. 이 장에서는 Free Pascal 의 string 타입으로 글자를 자르고, 찾고, 바꾸고, 보기 좋게 출력하는 방법을 배운다. 한글을 다룰 때 길이가 왜 예상과 다르게 나오는지도 함께 본다.

  • Length, Copy, Pos, Delete, Insert 로 문자열을 조회하고 편집한다.
  • UpperCase, Trim, StringReplace 로 입력을 다듬고 일부를 바꾼다.
  • Format 으로 자릿수와 칸 너비를 맞춰 출력한다.
  • 문자열의 첨자가 1부터 시작함을 알고, 범위를 벗어나는 실수를 피한다.
  • UTF-8 한글이 글자당 3바이트라서 Length 가 글자 수가 아님을 이해하고 대처한다.

문제 상황

카페 주인이 계산대 프로그램에 주문을 한 줄씩 적어 넣는다고 하자. 입력은 americano x 2, Latte x 1 , SCONE x 3 처럼 제각각이다. 대문자와 소문자가 섞여 있고, 앞뒤에 공백이 붙어 있기도 하다. 프로그램은 이 줄에서 메뉴 이름과 수량을 뽑아 메뉴판에서 가격을 찾아야 한다. 메뉴판에 없는 이름은 걸러야 한다.

영수증도 문제다. 12500 같은 숫자는 12,500 으로 찍어야 읽기 쉽다. 평균 단가는 소수점 한 자리로 고정해야 한다. 메뉴 이름이 한글이면 칸을 맞추는 일이 생각보다 까다롭다. 이 장은 이 문제들을 하나씩 풀어 한 프로그램으로 묶는다.

string 의 기본 연산

이어 붙이기와 비교

+ 로 문자열을 이어 붙이고, =, <> 로 내용이 같은지 비교한다. 문자열 상수는 작은따옴표로 감싼다. 큰따옴표가 아니다. 작은따옴표 자체를 문자열에 넣으려면 두 번 겹쳐 쓴다('it''s'). 첫 줄에 적는 {$mode objfpc}{$H+} 에서 {$H+} 는 string 을 길이 제한이 없는 긴 문자열(AnsiString)로 만든다는 뜻이다. 이 책의 모든 예제가 이 설정을 쓴다.

조회와 편집 함수

다섯 함수가 문자열 처리의 뼈대다. 함수와 프로시저가 섞여 있다는 점에 주의한다. 함수는 결과를 돌려주고 원본을 건드리지 않는다. 프로시저는 넘겨받은 변수 자체를 고친다.

문자열 조회·편집 기본 루틴
이름종류하는 일예 (S = 'Cafe Latte')
Length(S)함수길이(바이트 수)10
Copy(S, 시작, 개수)함수일부를 새 문자열로 복사Copy(S, 6, 5) 는 'Latte'
Pos(찾을 것, S)함수처음 나오는 위치, 없으면 0Pos('Latte', S) 는 6
Delete(S, 시작, 개수)프로시저S 에서 구간을 지움S 가 바뀐다
Insert(넣을 것, S, 위치)프로시저S 의 해당 위치 앞에 끼워 넣음S 가 바뀐다

Pos 는 인자 순서가 "찾을 것, 대상" 이다. 거꾸로 쓰면 0 이 나오는 경우가 많아 찾기 어려운 오류가 된다. Copy 의 개수가 남은 길이보다 크면 가능한 데까지만 복사하고, 시작이 길이를 넘으면 빈 문자열을 돌려준다. Copy 가 새 문자열을 돌려주므로 Delete(Trim(S), 1, 2) 처럼 함수 결과에 Delete 를 쓸 수 없다. Delete 는 고칠 변수가 필요하다.

첨자는 1부터 센다

Pascal 의 문자열은 첫 글자가 S[1] 이다. 앞 장에서 배열의 첨자 범위를 직접 정했듯이, 문자열은 1부터 Length(S) 까지로 정해져 있다. S[0] 이나 S[Length(S) + 1] 은 문자열 밖의 메모리를 건드린다. 기본 설정에서는 컴파일러가 막아 주지 않아 엉뚱한 값이 나오거나 나중에 프로그램이 이상하게 멈춘다. 연습할 때는 fpc -Cr main.pas 로 범위 검사를 켜 두면 잘못된 첨자를 실행 중에 오류로 알려 준다.

문자열 Cafe Latte 의 첫 글자는 1번 칸이고, Copy(S, 6, 5) 와 Pos 는 이 번호를 기준으로 동작한다.

Delphi 와 다른 점: Delphi 의 string 은 UTF-16 문자열이라 Length 가 UTF-16 단위 개수를 돌려준다. Free Pascal 3.2 에서 {$H+} 로 쓰는 string 은 바이트 단위의 AnsiString 이다. 첨자가 1부터라는 점과 이 장의 함수 이름은 둘이 같다.

다듬기, 바꾸기, Format

UpperCase, Trim, StringReplace

UpperCase 와 LowerCase 는 영문을 대문자·소문자로 바꾼 새 문자열을 돌려준다. Trim 은 앞뒤 공백을 떼고, TrimLeft, TrimRight 는 한쪽만 뗀다. 사용자 입력을 메뉴 이름과 비교하기 전에 UpperCase(Trim(…)) 로 모양을 맞추는 것이 기본 습관이다. 대소문자만 무시하고 비교하려면 SameText(가, 나) 도 쓸 수 있다. 이 함수들은 모두 SysUtils 유닛에 있으므로 uses SysUtils; 가 필요하다.

StringReplace(원문, 찾을 것, 바꿀 것, 옵션) 은 바뀐 새 문자열을 돌려준다. 옵션은 집합이다. 아무것도 주지 않으면([]) 처음 한 곳만 바꾸고, rfReplaceAll 을 주면 모두 바꾸며, rfIgnoreCase 를 더하면 대소문자를 가리지 않는다. 집합은 앞 장에서 다룬 것과 같은 모양이다.

Format 과 자릿수

Format(형식, [값, 값, …]) 은 형식 문자열의 % 자리에 값을 채워 문자열 하나를 만든다. 값은 대괄호 안에 쉼표로 나열한다. 소수를 문자열로 바꿀 때 자릿수를 고정하는 데 가장 많이 쓴다. 실행할 때마다 같은 출력이 나와야 하는 이 책의 예제에서도 소수는 항상 Format 으로 찍는다.

자주 쓰는 Format 지시자
지시자넣을 값뜻예 → 결과
%d정수십진수%d, 42 → 42
%5d정수너비 5, 오른쪽 맞춤42 → ' 42'
%.4d정수최소 4자리, 앞을 0 으로 채움7 → '0007'
%.2f실수소수점 아래 2자리로 반올림3.14159 → 3.14
%-8s문자열너비 8, 왼쪽 맞춤'tea' → 'tea     '
%%없음% 글자 하나'75.0%%' → 75.0%

값의 종류와 지시자가 맞지 않으면(%d 에 실수를 넣는 식) 컴파일은 되지만 실행 중에 오류가 난다. 값의 개수가 모자라도 마찬가지다. 소수점 기호는 . 로 나온다.

UTF-8 한글 길이 주의

이 책의 소스 파일은 UTF-8 로 저장한다. UTF-8 에서 영문자와 숫자는 1바이트이고 한글 음절 하나는 3바이트다. Free Pascal 3.2 의 Length 는 글자 수가 아니라 바이트 수를 센다. '카페라테' 의 Length 는 4가 아니라 12다. S[1] 도 첫 글자 '카' 가 아니라 그 글자의 첫 바이트다. Pos 와 Copy 도 전부 바이트 위치로 일한다.

카페라테는 글자 4개지만 UTF-8 로는 12바이트이고, Length 는 12, Pos 라테 는 7을 돌려준다.

글자 수가 필요하면 바이트를 훑으며 글자의 첫 바이트만 센다. UTF-8 에서 글자의 두 번째·세 번째 바이트는 이진수로 10 으로 시작하므로, 위쪽 두 비트를 뽑아 $80 이 아닌 바이트만 세면 된다. 위쪽 두 비트는 and $C0 로 뽑는다. 완성 코드의 CountChars 가 이 방법을 쓴다.

다행인 점도 있다. 쉼표나 공백 같은 ASCII 문자의 바이트 값은 한글의 어떤 바이트와도 겹치지 않는다. 그래서 한글이 섞인 문자열에서도 Pos(',', S) 나 StringReplace 로 ASCII 구분자를 찾거나, 한글 낱말을 통째로 찾아 바꾸는 일은 안전하다. 위험한 것은 Copy 의 개수나 S[i] 를 "글자 단위" 라고 믿고 쓰는 경우다. 글자 중간에서 자르면 깨진 바이트가 남는다.

출력 칸 맞춤에도 문제가 두 겹으로 있다. 첫째, Format('%-8s', …) 의 너비는 바이트 기준이라 한글 이름은 의도보다 적게 채워진다. 둘째, 터미널은 한글 한 글자를 영문 두 글자 폭으로 그린다. 그래서 완성 코드는 한글 음절을 2칸, 나머지를 1칸으로 치는 DisplayWidth 로 직접 공백을 채운다. 한글 음절과 영문·숫자만 쓰는 이 예제에는 충분한 어림 규칙이다. 이모지나 결합 문자까지 정확히 다루려면 별도의 유니코드 도구가 필요하다.

완성 코드

메뉴판, 주문 줄 해석, 영수증 출력, 그리고 앞에서 본 함수들의 동작 확인을 한 파일에 담았다. 파일 이름은 main.pas 이고 UTF-8 로 저장한다.

{$mode objfpc}{$H+}
program Main;

uses
  SysUtils;

type
  TMenuItem = record
    Code: string;
    Title: string;
    Price: Integer;
  end;

const
  Menu: array[1..3] of TMenuItem = (
    (Code: 'AMERICANO'; Title: '아메리카노'; Price: 4500),
    (Code: 'LATTE'; Title: '카페라테'; Price: 5000),
    (Code: 'SCONE'; Title: '스콘'; Price: 3500)
  );
  Orders: array[1..4] of string = (
    'americano x 2',
    '  Latte x 1  ',
    'SCONE x 3',
    'mocha x 1'
  );

{ 글자 수: 이어지는 바이트(10xxxxxx)가 아닌 바이트만 센다 }
function CountChars(const S: string): Integer;
var
  I: Integer;
begin
  Result := 0;
  for I := 1 to Length(S) do
    if (Ord(S[I]) and $C0) <> $80 then
      Inc(Result);
end;

{ 터미널 칸 수: 한글 음절(3바이트 글자)은 2칸, 나머지는 1칸 }
function DisplayWidth(const S: string): Integer;
var
  I: Integer;
begin
  Result := 0;
  for I := 1 to Length(S) do
    if (Ord(S[I]) and $C0) <> $80 then
    begin
      if Ord(S[I]) >= $E0 then
        Inc(Result, 2)
      else
        Inc(Result);
    end;
end;

function PadRight(const S: string; Width: Integer): string;
var
  Gap: Integer;
begin
  Gap := Width - DisplayWidth(S);
  if Gap < 0 then
    Gap := 0;
  Result := S + StringOfChar(' ', Gap);
end;

function PadLeft(const S: string; Width: Integer): string;
var
  Gap: Integer;
begin
  Gap := Width - DisplayWidth(S);
  if Gap < 0 then
    Gap := 0;
  Result := StringOfChar(' ', Gap) + S;
end;

{ 0 이상의 금액에 천 단위 쉼표를 넣는다 }
function FormatWon(Amount: Integer): string;
var
  At: Integer;
begin
  Result := IntToStr(Amount);
  At := Length(Result) - 2;
  while At > 1 do
  begin
    Insert(',', Result, At);
    At := At - 3;
  end;
end;

{ 메뉴판에서 코드를 찾아 번호(1부터)를 돌려준다. 없으면 0 }
function FindMenu(const Code: string): Integer;
var
  I: Integer;
begin
  Result := 0;
  for I := Low(Menu) to High(Menu) do
    if Menu[I].Code = Code then
    begin
      Result := I;
      Exit;
    end;
end;

{ '이름 x 수량' 한 줄을 이름(대문자)과 수량으로 쪼갠다 }
procedure ParseOrder(const Line: string; out Code: string; out Qty: Integer);
var
  P: Integer;
begin
  P := Pos(' x ', Line);
  if P = 0 then
  begin
    Code := UpperCase(Trim(Line));
    Qty := 1;
  end
  else
  begin
    Code := UpperCase(Trim(Copy(Line, 1, P - 1)));
    Qty := StrToIntDef(Trim(Copy(Line, P + 3, Length(Line))), 1);
  end;
end;

procedure ShowBasics;
var
  S, T: string;
begin
  Writeln('== 기본 연산 ==');
  S := 'Cafe Latte';
  Writeln('길이: ', Length(S));
  Writeln('첫 글자: ', S[1]);
  Writeln('Copy(S, 6, 5): ', Copy(S, 6, 5));
  Writeln('Pos Latte: ', Pos('Latte', S));
  Writeln('Pos tea: ', Pos('tea', S));
  T := S;
  Delete(T, 1, 5);
  Writeln('Delete 후: ', T);
  Insert('Iced ', T, 1);
  Writeln('Insert 후: ', T);
  T[1] := 'i';
  Writeln('첨자 대입 후: ', T);
  Writeln('대문자: ', UpperCase(T));
  Writeln('소문자: ', LowerCase(T));
end;

procedure ShowEdit;
begin
  Writeln('== 다듬기와 바꾸기 ==');
  Writeln('Trim: [', Trim('  Latte  '), ']');
  Writeln('TrimLeft: [', TrimLeft('  Latte  '), ']');
  Writeln('TrimRight: [', TrimRight('  Latte  '), ']');
  Writeln('한 곳만: ', StringReplace('a-b-c', '-', '+', []));
  Writeln('모두: ', StringReplace('a-b-c', '-', '+', [rfReplaceAll]));
  Writeln('대소문자 무시: ',
    StringReplace('Latte latte', 'LATTE', 'Mocha', [rfReplaceAll, rfIgnoreCase]));
  Writeln('한글: ',
    StringReplace('얼음 적게, 시럽 적게', '적게', '조금', [rfReplaceAll]));
end;

procedure ShowKorean;
var
  S: string;
begin
  Writeln('== 한글과 UTF-8 ==');
  S := '카페라테';
  Writeln('Length: ', Length(S));
  Writeln('글자 수: ', CountChars(S));
  Writeln('첫 글자의 바이트: ', IntToHex(Ord(S[1]), 2), ' ',
    IntToHex(Ord(S[2]), 2), ' ', IntToHex(Ord(S[3]), 2));
  Writeln('Copy(S, 1, 3): ', Copy(S, 1, 3));
  Writeln('Pos 라테: ', Pos('라테', S));
  Writeln(Format('[%-8s]', ['라테']));
  Writeln(Format('[%-8s]', ['tea']));
end;

procedure PrintReceipt;
var
  I, M, Qty, Sub, Total, Units, Accepted: Integer;
  Code: string;
begin
  Writeln('== 주문 영수증 ==');
  Writeln(PadRight('메뉴', 12), PadLeft('수량', 5),
    PadLeft('단가', 9), PadLeft('금액', 10));
  Total := 0;
  Units := 0;
  Accepted := 0;
  for I := Low(Orders) to High(Orders) do
  begin
    ParseOrder(Orders[I], Code, Qty);
    M := FindMenu(Code);
    if M = 0 then
      Writeln('메뉴에 없음: ', Code)
    else
    begin
      Sub := Menu[M].Price * Qty;
      Total := Total + Sub;
      Units := Units + Qty;
      Inc(Accepted);
      Writeln(PadRight(Menu[M].Title, 12),
        Format('%5d%9s%10s', [Qty, FormatWon(Menu[M].Price), FormatWon(Sub)]));
    end;
  end;
  Writeln(PadRight('합계', 26), Format('%10s', [FormatWon(Total)]));
  Writeln(Format('접수 %d건 중 %d건 처리 (%.1f%%)',
    [Length(Orders), Accepted, Accepted * 100 / Length(Orders)]));
  Writeln(Format('평균 단가: %.1f원', [Total / Units]));
  Writeln(Format('부가세 포함분: %.2f원', [Total / 11]));
end;

begin
  ShowBasics;
  Writeln;
  ShowEdit;
  Writeln;
  ShowKorean;
  Writeln;
  PrintReceipt;
end.

줄별 해설

선언부

TMenuItem 은 메뉴 한 칸을 나타내는 레코드다. Code 는 비교용 영문 대문자 이름이고 Title 은 영수증에 찍을 한글 이름이다. 입력은 영문 코드로 받고 출력은 한글로 한다. 두 이름을 나눠 둔 덕분에 입력 비교에는 UpperCase 만으로 충분하다. Menu 와 Orders 는 첨자 범위를 1..3, 1..4 로 잡은 상수 배열이다. 문자열처럼 1부터 센다. 순회할 때는 Low 와 High 로 범위를 얻어 숫자를 코드에 박아 넣지 않는다.

CountChars 와 DisplayWidth

CountChars 는 S[1] 부터 S[Length(S)] 까지 바이트를 하나씩 본다. Ord 는 문자를 바이트 값(0~255)으로 바꾸고, and $C0 는 위쪽 두 비트만 남긴다. 그 값이 $80 이면 글자의 이어지는 바이트이므로 세지 않는다. 남는 바이트가 글자의 첫 바이트이고 그 개수가 글자 수다. DisplayWidth 는 같은 방식으로 훑되, 첫 바이트가 $E0 이상이면(3바이트 글자) 2칸, 아니면 1칸을 더한다. 안쪽 if…else 를 begin … end 로 감싼 이유는 바깥 if 와 else 가 헷갈리지 않게 하려는 것이다.

PadRight, PadLeft

원하는 칸 수에서 현재 칸 수를 빼 모자란 만큼을 Gap 에 담는다. 음수면 0 으로 고친다. StringOfChar(' ', Gap) 은 공백 Gap 개로 된 문자열을 만든다. 오른쪽에 붙이면 왼쪽 맞춤이고, 왼쪽에 붙이면 오른쪽 맞춤이다.

FormatWon

먼저 IntToStr 로 숫자를 문자열로 만든다. 쉼표는 오른쪽 끝에서 세 자리 앞마다 들어가야 한다. At 에 Length - 2 를 두면 12500 은 3이 되어 Insert(',', Result, 3) 으로 '12,500' 이 된다. 이미 끼워 넣은 쉼표 때문에 앞쪽 위치는 밀리지 않으므로 At 에서 3씩 빼면 된다. At 가 1 이하가 되면 맨 앞이라 멈춘다. 음수 금액은 다루지 않는다.

FindMenu, ParseOrder

FindMenu 는 메뉴판을 처음부터 훑어 코드가 같으면 번호를 돌려주고, 끝까지 없으면 0 을 돌려준다. 첨자가 1부터라서 0 을 "없음" 표시로 쓸 수 있다. Pos 가 못 찾았을 때 0 을 주는 것과 같은 약속이다.

ParseOrder 는 ' x '(공백, x, 공백)가 처음 나오는 위치 P 를 찾는다. 없으면 줄 전체를 이름으로 보고 수량은 1 로 한다. 있으면 Copy(Line, 1, P - 1) 이 이름 쪽이고 Copy(Line, P + 3, Length(Line)) 이 수량 쪽이다. 구분자가 세 글자라서 P + 3 에서 수량이 시작한다. 개수 자리에 Length(Line) 을 주면 "끝까지" 라는 뜻이 된다. 이름은 Trim 으로 공백을 떼고 UpperCase 로 대문자로 맞춘다. 수량은 StrToIntDef 로 정수로 바꾸되 숫자가 아니면 기본값 1 을 쓴다. out 매개변수는 호출한 쪽의 변수를 결과 전달용으로 쓰겠다는 표시이며 앞 장에서 배운 매개변수 전달 방식 중 하나다.

ShowBasics, ShowEdit

S 는 'Cafe Latte' 다. 'L' 은 6번째 글자이므로 Copy(S, 6, 5) 와 Pos('Latte', S) 의 결과가 맞아떨어진다. T := S 로 복사본을 만든 뒤 Delete(T, 1, 5) 로 앞 다섯 글자('Cafe ')를 지운다. Insert('Iced ', T, 1) 은 맨 앞에 'Iced ' 를 끼운다. T[1] := 'i' 처럼 첨자로 한 글자를 바꿀 수도 있다. 읽기도 쓰기도 되는 한 글자짜리 접근이다.

ShowEdit 의 마지막 줄은 한글 낱말 '적게' 를 통째로 바꾼다. 바이트열이 같은 곳을 찾아 바꾸므로 UTF-8 한글에서도 안전하다. 앞 절에서 설명한 "한글 낱말을 통째로 바꾸는 일은 안전하다" 의 예다.

ShowKorean

'카페라테' 의 Length 는 12, CountChars 는 4다. IntToHex(값, 자릿수) 로 첫 세 바이트를 16진수로 찍으면 'EC B9 B4' 가 나온다. 이 세 바이트가 '카' 한 글자다. Copy(S, 1, 3) 은 정확히 그 세 바이트를 복사하므로 '카' 가 출력된다. Pos('라테', S) 가 7인 것은 앞의 '카페' 가 6바이트이기 때문이다. 마지막 두 줄은 %-8s 가 바이트 기준임을 보여 준다. '라테' 는 6바이트라 공백이 2개만 붙고 'tea' 는 3바이트라 5개가 붙는다.

PrintReceipt

머리글은 PadRight, PadLeft 로 칸을 맞춘다. 반복문에서 주문 줄마다 ParseOrder 로 이름과 수량을 얻고 FindMenu 로 메뉴를 찾는다. 0 이면 "메뉴에 없음" 을 출력하고 넘어간다. 찾았으면 소계를 계산해 합계에 더한다. 본문 줄의 숫자 부분은 Format('%5d%9s%10s', …) 이다. 수량은 너비 5, 단가는 9, 금액은 10 이고, 머리글의 PadLeft 폭 5, 9, 10 과 같아서 오른쪽 끝이 맞는다. 숫자 열은 모두 ASCII 라 Format 의 바이트 기준 너비를 써도 어긋나지 않는다. 한글이 들어간 칸만 PadRight 로 처리한다.

끝의 세 줄은 소수 자릿수 고정을 보여 준다. 처리 비율 %.1f%% 은 75.0%, 평균 단가는 소수 한 자리(24500 ÷ 6 = 4083.33…), 부가세 포함분은 합계를 11 로 나눈 값을 두 자리로 찍는다. 부가세 10% 가 붙은 금액에서 부가세는 합계의 11분의 1 이라는 계산이다.

실행 결과

컴파일 시 fpc 가 출력하는 안내 줄은 생략했다. 경고는 나오지 않는다.

$ fpc main.pas
$ ./main
== 기본 연산 ==
길이: 10
첫 글자: C
Copy(S, 6, 5): Latte
Pos Latte: 6
Pos tea: 0
Delete 후: Latte
Insert 후: Iced Latte
첨자 대입 후: iced Latte
대문자: ICED LATTE
소문자: iced latte

== 다듬기와 바꾸기 ==
Trim: [Latte]
TrimLeft: [Latte  ]
TrimRight: [  Latte]
한 곳만: a+b-c
모두: a+b+c
대소문자 무시: Mocha Mocha
한글: 얼음 조금, 시럽 조금

== 한글과 UTF-8 ==
Length: 12
글자 수: 4
첫 글자의 바이트: EC B9 B4
Copy(S, 1, 3): 카
Pos 라테: 7
[라테  ]
[tea     ]

== 주문 영수증 ==
메뉴         수량     단가      금액
아메리카노      2    4,500     9,000
카페라테        1    5,000     5,000
스콘            3    3,500    10,500
메뉴에 없음: MOCHA
합계                          24,500
접수 4건 중 3건 처리 (75.0%)
평균 단가: 4083.3원
부가세 포함분: 2227.27원

실무에서 자주 틀리는 것

첨자를 0 부터 시작한다

다른 언어의 습관대로 0 부터 돌리면 첫 반복에서 문자열 밖을 읽고 마지막 글자는 빠뜨린다.

{ 틀림 }
for I := 0 to Length(S) - 1 do
  Write(S[I]);

{ 고침 }
for I := 1 to Length(S) do
  Write(S[I]);

한글을 글자 단위로 자른다고 믿는다

'카페라테' 에서 앞 두 글자를 얻으려고 Copy(S, 1, 2) 를 쓰면 '카' 의 세 바이트 중 두 개만 잘려 깨진 글자가 출력된다. 글자 수 기준으로 자르려면 글자 경계를 직접 찾아야 한다.

{ 틀림: '카' 의 3바이트 중 2바이트만 복사 }
Writeln(Copy(S, 1, 2));

{ 고침: 앞에서 N 글자를 돌려주는 함수 }
function FirstChars(const S: string; N: Integer): string;
var
  I, Seen: Integer;
begin
  Seen := 0;
  I := 1;
  while I <= Length(S) do
  begin
    if (Ord(S[I]) and $C0) <> $80 then
    begin
      Inc(Seen);
      if Seen > N then
        Break;
    end;
    Inc(I);
  end;
  Result := Copy(S, 1, I - 1);
end;

FirstChars(S, 2) 는 '카페' 를 돌려준다. 새 글자의 첫 바이트를 만나 개수가 N 을 넘는 순간 멈추고 그 앞까지를 복사한다.

Format 에 % 를 그냥 쓴다

형식 문자열 안의 % 는 항상 지시자의 시작으로 읽힌다. 퍼센트 기호를 출력하려면 두 번 써야 한다. 그렇지 않으면 실행 중에 형식 오류로 멈춘다. 값의 종류가 지시자와 다른 경우도 같은 방식으로 멈춘다.

{ 틀림: % 로 끝나는 형식, 정수에 실수 대입 }
Writeln(Format('할인율 %d%', [10]));
Writeln(Format('평균 %d잔', [Total / 6]));

{ 고침 }
Writeln(Format('할인율 %d%%', [10]));
Writeln(Format('평균 %.1f잔', [Total / 6]));

Pos 의 결과 0 을 확인하지 않는다

구분자가 없는 줄에서 Pos 는 0 을 돌려준다. 그대로 P - 1 을 개수로 쓰면 Copy(Line, 1, -1) 이 되어 오류 없이 빈 문자열이 나온다. 조용히 틀린 결과가 되므로 더 찾기 어렵다. 또 비교 전에 대소문자와 공백을 맞추지 않으면 같은 메뉴를 다른 이름으로 취급한다.

{ 틀림 }
Code := Copy(Line, 1, Pos(' x ', Line) - 1);
if Code = 'LATTE' then ...

{ 고침 }
P := Pos(' x ', Line);
if P = 0 then
  Code := UpperCase(Trim(Line))
else
  Code := UpperCase(Trim(Copy(Line, 1, P - 1)));
if Code = 'LATTE' then ...

한눈에 보기

이 장의 문자열 도구와 주의점
하고 싶은 일쓰는 것결과 / 성격주의
길이Length(S)바이트 수한글은 글자당 3
일부 복사Copy(S, 시작, 개수)새 문자열시작은 1부터, 한글 중간 절단 주의
위치 찾기Pos(찾을 것, S)없으면 0인자 순서, 0 확인
지우기·끼우기Delete, InsertS 를 직접 수정프로시저라 변수가 필요
대소문자·공백UpperCase, LowerCase, Trim새 문자열SysUtils 필요
바꾸기StringReplace새 문자열모두 바꾸려면 rfReplaceAll
자릿수·칸 맞춤Format새 문자열%%, 값 종류 일치, 너비는 바이트

이 도구들의 정확한 시그니처와 옵션은 공식 문서의 Format 항목 과 RTL 참조 에서 확인할 수 있다.

연습 문제

  1. S := 'Americano Hot'; 에서 Pos 로 공백 위치를 찾고, Copy 로 앞 낱말과 뒤 낱말을 각각 한 줄에 출력하라.
  2. 영수증 번호를 NO-0007 처럼 숫자 부분 4자리로 출력하려 한다. 번호가 7일 때 Format 형식을 써라.
  3. 문자열에서 쉼표(,)가 몇 개인지 세는 함수 CountChar(const S: string; Ch: Char): Integer 를 작성하라. '라테,모카,스콘' 에 쓰면 몇이 나와야 하는가. 한글이 섞여 있어도 이 방법이 안전한 이유도 말하라.
  4. 다음 코드는 실행 중에 오류가 난다. 이유를 말하고 고쳐라. Writeln(Format('%d잔 평균 %d원', [Units, Total / Units])); (Units, Total 은 정수)

정답과 해설

1. 공백이 10번째 글자이므로 앞 낱말은 1~9, 뒤 낱말은 11번째부터다.

S := 'Americano Hot';
P := Pos(' ', S);
Writeln(Copy(S, 1, P - 1));        { Americano }
Writeln(Copy(S, P + 1, Length(S))); { Hot }

개수에 Length(S) 를 주면 끝까지 복사한다. 공백이 없으면 P 가 0 이므로, 실제 코드에서는 P = 0 인지 먼저 확인해야 한다.

2. Format('NO-%.4d', [7]) 이다. 정수 지시자에 붙인 정밀도는 "최소 자릿수" 이고, 모자라는 앞자리는 0 으로 채워 NO-0007 이 된다. %4d 로 쓰면 0 대신 공백이 채워지므로 다르다.

3.

function CountChar(const S: string; Ch: Char): Integer;
var
  I: Integer;
begin
  Result := 0;
  for I := 1 to Length(S) do
    if S[I] = Ch then
      Inc(Result);
end;

CountChar('라테,모카,스콘', ',') 는 2 다. UTF-8 에서 한글의 모든 바이트는 128 이상이고 쉼표는 44 라서 한글의 일부 바이트가 쉼표로 오인될 일이 없다. 바이트 단위 비교가 ASCII 문자를 찾는 데는 정확하다.

4. Total / Units 는 정수끼리 나눠도 실수가 된다. 그런데 %d 는 정수만 받으므로 컴파일은 되어도 실행할 때 형식 오류가 난다. 실수에 맞는 지시자로 바꾼다.

Writeln(Format('%d잔 평균 %.1f원', [Units, Total / Units]));

정수 값이 필요하다면 Total div Units 로 몫만 구해 %d 를 그대로 쓸 수도 있다. 이 경우 소수 부분은 버려진다.

댓글 0

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

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