문자열 다루기 - Format 과 SysUtils
이 장에서 배우는 것
카페 계산대 프로그램은 숫자만으로 돌아가지 않는다. 메뉴 이름을 읽고, 손님이 입력한 주문 문장을 쪼개고, 금액에 쉼표를 찍어 영수증을 만든다. 이 모든 일이 문자열 처리다. 이 장에서는 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) | 함수 | 처음 나오는 위치, 없으면 0 | Pos('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 로 범위 검사를 켜 두면 잘못된 첨자를 실행 중에 오류로 알려 준다.
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 으로 찍는다.
| 지시자 | 넣을 값 | 뜻 | 예 → 결과 |
|---|---|---|---|
%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 도 전부 바이트 위치로 일한다.
글자 수가 필요하면 바이트를 훑으며 글자의 첫 바이트만 센다. 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, Insert | S 를 직접 수정 | 프로시저라 변수가 필요 |
| 대소문자·공백 | UpperCase, LowerCase, Trim | 새 문자열 | SysUtils 필요 |
| 바꾸기 | StringReplace | 새 문자열 | 모두 바꾸려면 rfReplaceAll |
| 자릿수·칸 맞춤 | Format | 새 문자열 | %%, 값 종류 일치, 너비는 바이트 |
이 도구들의 정확한 시그니처와 옵션은 공식 문서의 Format 항목 과 RTL 참조 에서 확인할 수 있다.
연습 문제
S := 'Americano Hot';에서Pos로 공백 위치를 찾고,Copy로 앞 낱말과 뒤 낱말을 각각 한 줄에 출력하라.- 영수증 번호를
NO-0007처럼 숫자 부분 4자리로 출력하려 한다. 번호가 7일 때Format형식을 써라. - 문자열에서 쉼표(
,)가 몇 개인지 세는 함수CountChar(const S: string; Ch: Char): Integer를 작성하라.'라테,모카,스콘'에 쓰면 몇이 나와야 하는가. 한글이 섞여 있어도 이 방법이 안전한 이유도 말하라. - 다음 코드는 실행 중에 오류가 난다. 이유를 말하고 고쳐라.
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 를 그대로 쓸 수도 있다. 이 경우 소수 부분은 버려진다.