프로시저와 함수 - 매개변수 전달 방식
이 장에서 배우는 것
앞 장에서는 조건과 반복으로 프로그램의 흐름을 만들었다. 카페 계산대 프로그램이 커지면 같은 계산이 여러 곳에 복사되기 시작한다. 이 장에서는 반복되는 일에 이름을 붙여 묶는 도구인 프로시저(procedure)와 함수(function)를 배운다. 묶은 일에 값을 건네는 방법인 매개변수(parameter, 루틴이 밖에서 받는 입력 칸)에는 네 가지 방식이 있고, 어느 방식을 고르느냐에 따라 호출한 쪽의 변수가 바뀌기도 하고 바뀌지 않기도 한다. 이 차이가 이 장의 중심이다.
- procedure 와 function 의 차이를 설명하고, function 의 반환값을
Result로 돌려줄 수 있다. - 값,
var,const,out매개변수가 각각 호출한 쪽 변수에 어떤 영향을 주는지 구분한다. - 기본값 매개변수와 오버로딩(overloading, 같은 이름의 루틴을 매개변수 모양으로 구분하는 방식)을 쓴다.
- 다른 루틴 안에 중첩 루틴을 선언하고, 바깥 루틴의 변수를 어떻게 볼 수 있는지 안다.
문제 상황
카페 계산대 프로그램에 주문 계산을 넣고 있다. 처음에는 메인 본문에 이런 식을 그대로 적었다. 아메리카노 가격에 수량을 곱하고, 단골 할인을 빼고, 영수증에 한 줄을 찍는다. 라떼를 추가하자 같은 세 줄을 복사해서 붙였다. 스콘을 추가하면서 할인율을 10%에서 15%로 바꿔야 했는데, 두 곳만 고치고 한 곳을 빠뜨렸다. 영수증의 합계가 서로 맞지 않았다.
같은 계산이 코드 세 곳에 있으면 고칠 곳도 세 곳이다. 계산을 한 번만 적고 이름을 붙여 부르면 고칠 곳은 하나가 된다. 그런데 이름을 붙이는 순간 새 질문이 생긴다. "할인을 적용하는 루틴이 합계 변수를 직접 고쳐야 하는가, 아니면 계산한 값을 돌려주기만 해야 하는가", "메뉴에 없는 이름이 들어오면 어떻게 알리는가" 같은 질문이다. 이 장은 그 질문에 답하는 문법을 순서대로 다룬다.
프로시저와 함수
루틴(routine)은 프로시저와 함수를 합쳐 부르는 말이다. 둘의 차이는 하나다. 프로시저는 일을 하고 끝나며, 함수는 일을 하고 값을 하나 돌려준다. 그래서 프로시저는 문장처럼 한 줄로 호출하고, 함수는 값이 들어갈 자리(대입문의 오른쪽, WriteLn 의 인자 등)에서 호출한다.
| 구분 | 선언 머리 | 돌려주는 값 | 호출하는 자리 |
|---|---|---|---|
| 프로시저 | procedure 이름(...) | 없음 | 문장 자리 |
| 함수 | function 이름(...): 타입 | 있음 | 식 안 |
Result 로 값을 돌려준다
함수 안에는 Result 라는 변수가 미리 준비되어 있고, 그 타입은 함수 머리에 적은 반환 타입이다. Result 에 대입한 값이 함수가 끝날 때 호출한 쪽으로 넘어간다. 대입한다고 함수가 끝나지는 않는다. 아래 줄이 계속 실행되고, 마지막으로 대입한 값이 남는다. 중간에 끝내려면 Exit 를 쓴다.
function Won(Amount: Integer): string;
begin
Result := IntToStr(Amount) + '원';
end;
Free Pascal 은 함수 이름에 대입하는 옛 방식(Won := ...)도 받아 주지만, 이 책은 Result 만 쓴다. Delphi 에서도 Result 가 표준이다. 모든 실행 경로에서 Result 에 값이 들어가야 한다는 점은 뒤의 "자주 틀리는 것"에서 다시 본다.
또 하나, 루틴은 쓰기 전에 선언되어 있어야 한다. 앞 장에서 변수를 위에서 아래로 선언했듯이 컴파일러는 파일을 위에서 아래로 읽는다. 그래서 다른 루틴이 부르는 루틴을 더 위에 적는다.
매개변수 전달 방식 네 가지
루틴을 호출할 때 괄호에 넣는 값을 인자(argument)라 하고, 루틴 머리에서 받는 칸을 매개변수라 한다. 매개변수 앞에 아무것도 붙이지 않으면 값 매개변수이고, var, const, out 를 붙이면 전달 방식이 달라진다.
| 방식 | 호출 때 넘길 수 있는 것 | 안에서 바꾸면 | 주로 쓰는 곳 |
|---|---|---|---|
| 값 (표시 없음) | 변수, 상수, 식 | 복사본만 바뀐다 | 입력값 |
var | 변수만 | 호출한 쪽 변수가 바뀐다 | 입력과 출력을 겸하는 값 |
const | 변수, 상수, 식 | 대입이 컴파일 오류 | 바꾸지 않는 입력값 |
out | 변수만 | 호출한 쪽 변수가 바뀐다 | 출력만 하는 값 |
값 매개변수와 var 매개변수
값 매개변수는 인자의 복사본을 받는다. 루틴 안에서 아무리 바꿔도 호출한 쪽의 변수는 그대로다. var 매개변수는 복사본이 아니라 호출한 쪽 변수 그 자체를 가리킨다. 그림 1은 같은 Qty 를 두 방식으로 넘겼을 때의 차이다.
var 매개변수에는 변수만 넘길 수 있다. 가리킬 칸이 있어야 하기 때문에 3000 같은 상수나 A + B 같은 식은 받지 못한다.
const 매개변수
const 매개변수는 "이 루틴은 값을 읽기만 한다"는 약속이다. 안에서 대입하면 컴파일러가 오류로 막아 준다. 문자열처럼 큰 값은 컴파일러가 복사하지 않고 넘길 수도 있어서, 읽기만 하는 문자열 매개변수에는 const 를 습관처럼 붙이는 것이 좋다. 호출하는 쪽에서 보면 값 매개변수와 똑같이 상수와 식을 넘길 수 있어 불편함이 없다.
out 매개변수
out 은 var 와 비슷하게 호출한 쪽 변수를 바꾸지만, 의미가 다르다. 값을 받아 들어오는 것이 아니라 결과를 내보내기만 한다. 그래서 루틴 안에서는 먼저 값을 대입한 다음에 읽어야 한다. 함수는 값을 하나만 돌려주므로, "성공했는가"를 Boolean 으로 돌려주고 실제 결과는 out 으로 내보내는 모양이 흔하다. 이 장의 TryGetPrice 가 그 모양이다. 이름이 Try 로 시작하는 루틴은 실패할 수 있으며 그 결과를 반환값으로 알린다는 관례를 따른 것이다.
Delphi 도 var, const, out 의 의미가 같아 이 장의 내용은 그대로 옮겨 쓸 수 있다.
기본값, 오버로딩, 중첩 루틴
기본값 매개변수
매개변수 뒤에 = 값 을 적으면 호출할 때 그 인자를 생략할 수 있다. 생략하면 적어 둔 기본값이 들어간다. 기본값 매개변수는 목록의 맨 뒤쪽에 모아야 하며, 값 매개변수와 const 매개변수에만 쓸 수 있다. var 와 out 에는 기본값을 줄 수 없다. 이 장의 ApplyPercent(var Amount: Integer; Percent: Integer = 10) 에서 첫 매개변수는 var 이고, 기본값은 뒤의 값 매개변수에 있다.
오버로딩
이름은 같고 매개변수의 개수나 타입이 다른 루틴을 여러 개 만들 수 있다. 각 선언 끝에 overload 를 붙이면 컴파일러가 호출할 때의 인자를 보고 알맞은 것을 고른다. 반환 타입만 다른 것은 구분 기준이 되지 못한다. 이 장에서는 가격(숫자)으로 계산하는 LineTotal 과 메뉴 이름(문자열)으로 계산하는 LineTotal 을 함께 둔다.
| 달라도 되는 것 | 구분 기준이 되는가 | 예 |
|---|---|---|
| 매개변수 개수 | 된다 | Show(A) 와 Show(A, B) |
| 매개변수 타입 | 된다 | Show(Integer) 와 Show(string) |
| 반환 타입 | 되지 않는다 | F: Integer 와 F: string |
| 매개변수 이름 | 되지 않는다 | Show(A: Integer) 와 Show(B: Integer) |
중첩 루틴
루틴 안의 변수 선언 자리에 다른 프로시저나 함수를 선언할 수 있다. 이것이 중첩 루틴이다. 바깥 루틴의 지역 변수와 매개변수를 안쪽 루틴이 그대로 읽고 쓸 수 있고, 반대로 안쪽 루틴은 바깥 루틴 밖에서는 보이지 않는다. 한 루틴에서만 쓰는 작은 도우미 일을 바깥으로 꺼내 이름을 어지럽히지 않고 묶어 두는 용도다. 그림 2는 이 장의 영수증 루틴 구조다.
바깥 변수를 직접 건드리는 코드는 편하지만, 어느 줄이 Sum 을 바꾸는지 눈으로 따라가기 어려워진다. 안쪽 루틴은 짧게 유지하고, 바깥 변수를 바꾸는 곳이 한두 군데로 한정되도록 쓴다.
완성 코드
아래는 한 파일로 된 완전한 프로그램이다. main.pas 로 저장한다. 메뉴 가격표는 아직 자료를 묶는 방법을 배우기 전이라 함수 하나에 if 로 적었다. 금액은 모두 정수(원)로 다뤄서 소수점 문제가 없다.
{$mode objfpc}{$H+}
program Main;
uses
SysUtils;
function Won(Amount: Integer): string;
begin
Result := IntToStr(Amount) + '원';
end;
function PriceOf(const Name: string): Integer;
begin
if Name = 'americano' then
Result := 3000
else if Name = 'latte' then
Result := 4000
else if Name = 'scone' then
Result := 3500
else
Result := 0;
end;
function TryGetPrice(const Name: string; out Price: Integer): Boolean;
begin
Price := PriceOf(Name);
Result := Price > 0;
end;
function LineTotal(Price, Qty: Integer; Discount: Integer = 0): Integer; overload;
begin
Result := Price * Qty - Discount;
end;
function LineTotal(const Name: string; Qty: Integer): Integer; overload;
begin
Result := PriceOf(Name) * Qty;
end;
procedure ApplyPercent(var Amount: Integer; Percent: Integer = 10);
begin
Amount := Amount - Amount * Percent div 100;
end;
procedure ChangeQtyCopy(Qty: Integer);
begin
Qty := Qty + 100;
WriteLn(' 함수 안의 Qty = ', Qty);
end;
procedure PrintReceipt(const Customer: string);
var
Count: Integer;
Sum: Integer;
procedure AddLine(const Name: string; Qty: Integer);
var
Price: Integer;
begin
if TryGetPrice(Name, Price) then
begin
Inc(Count);
Sum := Sum + LineTotal(Price, Qty);
WriteLn(' ', Name:10, Qty:3, ' x', Price:6, ' =', LineTotal(Price, Qty):7);
end
else
WriteLn(' ', Name:10, ' 메뉴에 없음');
end;
begin
Count := 0;
Sum := 0;
WriteLn('[', Customer, ' 영수증]');
AddLine('americano', 2);
AddLine('latte', 1);
AddLine('mocha', 1);
AddLine('scone', 3);
WriteLn(' 줄 수: ', Count);
WriteLn(' 할인 전: ', Won(Sum));
ApplyPercent(Sum);
WriteLn(' 할인 후: ', Won(Sum));
end;
var
Qty: Integer;
Total: Integer;
P: Integer;
begin
WriteLn('== 1. 값 매개변수 ==');
Qty := 2;
ChangeQtyCopy(Qty);
WriteLn(' 호출한 쪽의 Qty = ', Qty);
WriteLn('== 2. var 매개변수 ==');
Total := 10000;
ApplyPercent(Total);
WriteLn(' 기본 10% 할인: ', Total);
ApplyPercent(Total, 50);
WriteLn(' 50% 추가 할인: ', Total);
WriteLn('== 3. out 매개변수 ==');
if TryGetPrice('latte', P) then
WriteLn(' latte = ', P)
else
WriteLn(' latte 는 메뉴에 없다');
if TryGetPrice('tea', P) then
WriteLn(' tea = ', P)
else
WriteLn(' tea 는 메뉴에 없다, P = ', P);
WriteLn('== 4. 오버로딩과 기본값 ==');
WriteLn(' 이름으로: ', LineTotal('latte', 2));
WriteLn(' 가격으로: ', LineTotal(3500, 2));
WriteLn(' 쿠폰 500원: ', LineTotal(3500, 2, 500));
WriteLn('== 5. 중첩 루틴 ==');
PrintReceipt('테이블 3');
end.
줄별 해설
Won 은 정수를 받아 "3000원" 같은 문자열을 돌려주는 가장 단순한 함수다. IntToStr 는 SysUtils 유닛에 있는 정수를 문자열로 바꾸는 함수라서 uses SysUtils 가 필요하다.
PriceOf 는 모든 if 갈래가 Result 에 값을 넣는다. 마지막 else 가 0을 넣어 "메뉴에 없음"을 뜻하게 한다. 이름은 const string 이라서 읽기만 한다는 것이 머리에서 드러난다.
TryGetPrice 는 out Price 에 가격을 먼저 넣고, 0보다 크면 True 를 돌려준다. 호출한 쪽은 반환값으로 성공 여부를 알고, Price 변수로 값을 받는다. 3번 구역에서 없는 메뉴 tea 를 찾으면 P 가 0으로 채워지는 것도 이 대입 때문이다.
LineTotal 두 개 는 둘 다 overload 가 붙었다. 첫 번째는 Discount: Integer = 0 이 있어서 LineTotal(3500, 2) 처럼 두 인자만 줘도 된다. 두 번째는 첫 인자가 문자열이라 LineTotal('latte', 2) 가 이쪽으로 연결된다.
ApplyPercent 의 var Amount 는 호출한 쪽 변수를 직접 고친다. 식 Amount * Percent div 100 은 곱셈을 먼저 하고 정수 나눗셈을 하므로 할인액이 정수로 나온다. 이 장의 입력에서는 항상 나누어떨어지지만, 나누어떨어지지 않으면 소수점 아래가 버려진다.
ChangeQtyCopy 는 값 매개변수 Qty 에 100을 더한다. 메인 본문의 Qty 는 다른 칸이므로 호출 뒤에도 2로 남는다. 이름이 같아도 서로 다른 변수라는 점에 주의한다.
PrintReceipt 는 지역 변수 Count 와 Sum 을 선언한 뒤 중첩 프로시저 AddLine 을 선언한다. 중첩 루틴은 바깥 루틴의 begin 앞, 변수 선언 다음에 온다. AddLine 은 Inc(Count) 와 Sum := Sum + ... 로 바깥 변수를 직접 바꾼다. Name:10, Qty:3 같은 :폭 표기는 WriteLn 이 값을 지정한 칸 수에 오른쪽 맞춤으로 찍게 한다. 열을 맞추는 용도다.
본문 끝에서는 ApplyPercent(Sum) 으로 기본 할인율 10%를 적용한다. Sum 은 변수이므로 var 매개변수에 넘길 수 있다. 메인 본문의 5개 구역은 이 장의 개념을 한 가지씩 순서대로 확인한다.
실행 결과
$ fpc main.pas
$ ./main
== 1. 값 매개변수 ==
함수 안의 Qty = 102
호출한 쪽의 Qty = 2
== 2. var 매개변수 ==
기본 10% 할인: 9000
50% 추가 할인: 4500
== 3. out 매개변수 ==
latte = 4000
tea 는 메뉴에 없다, P = 0
== 4. 오버로딩과 기본값 ==
이름으로: 8000
가격으로: 7000
쿠폰 500원: 6500
== 5. 중첩 루틴 ==
[테이블 3 영수증]
americano 2 x 3000 = 6000
latte 1 x 4000 = 4000
mocha 메뉴에 없음
scone 3 x 3500 = 10500
줄 수: 3
할인 전: 20500원
할인 후: 18450원
실무에서 자주 틀리는 것
var 를 빠뜨려 값이 바뀌지 않는다
포인트를 더하는 프로시저를 만들었는데 호출한 뒤에도 포인트가 그대로다. 컴파일은 되고 오류도 없어서 눈에 띄지 않는다.
procedure AddBonus(Points: Integer);
begin
Points := Points + 10; // 복사본만 바뀐다
end;
procedure AddBonus(var Points: Integer);
begin
Points := Points + 10;
end;
"호출한 쪽 변수를 바꾸려는가"를 먼저 묻고, 그렇다면 var 를 붙인다. 단순히 계산한 값을 돌려주는 용도라면 var 대신 함수의 Result 가 더 읽기 쉬운 경우가 많다.
일부 경로에서 Result 를 정하지 않는다
function Grade(Total: Integer): string;
begin
if Total >= 10000 then
Result := '단골';
end;
1만 원 미만이면 Result 가 정해지지 않은 채 끝난다. 컴파일러는 "Function result variable does not seem to be initialized" 경고를 낸다. 경고를 무시하면 아무 문자열이 나올 수 있다. 모든 경로에서 값을 넣는다.
function Grade(Total: Integer): string;
begin
if Total >= 10000 then
Result := '단골'
else
Result := '일반';
end;
var 매개변수에 상수나 식을 넘긴다
ApplyPercent(3000); // 오류: 변수가 필요하다
ApplyPercent(Total + 500); // 오류: 식은 안 된다
컴파일러는 변수 식별자가 필요하다는 뜻의 오류를 낸다. var 와 out 은 바뀐 값을 담을 칸이 있어야 하므로 먼저 변수에 담는다.
Total := Total + 500;
ApplyPercent(Total);
오버로딩에 overload 를 빼먹거나, 기본값과 겹친다
같은 이름의 루틴을 두 번 선언하면서 overload 를 빼먹으면 "Duplicate identifier" 오류가 난다. 오버로딩끼리 기본값 때문에 구분이 안 되는 경우도 있다.
procedure Show(A: Integer); overload;
procedure Show(A: Integer; B: Integer = 0); overload;
Show(1); // 어느 쪽인지 정할 수 없다는 오류
Show(1) 은 두 선언 모두에 맞는다. 하나를 빼거나, 기본값을 없애고 인자 개수로 구분되게 고친다.
procedure Show(A: Integer); overload;
procedure Show(A, B: Integer); overload;
한눈에 보기
| 개념 | 문법 | 기억할 점 |
|---|---|---|
| 프로시저 | procedure P(...); | 값을 돌려주지 않고 문장 자리에서 호출한다 |
| 함수 | function F(...): T; | Result 에 값을 넣고, 모든 경로에서 정한다 |
| 값 매개변수 | X: T | 복사본이라 호출한 쪽은 바뀌지 않는다 |
var | var X: T | 변수만 넘기고, 호출한 쪽이 바뀐다 |
const | const X: T | 읽기 전용, 안에서 대입하면 오류 |
out | out X: T | 결과만 내보낸다, 먼저 대입한 뒤 읽는다 |
| 기본값 | X: T = 값 | 맨 뒤쪽에 둔다, var·out 에는 불가 |
| 오버로딩 | overload; | 개수와 타입으로 구분, 반환 타입은 기준이 아니다 |
| 중첩 루틴 | 변수 선언 자리에 선언 | 바깥 변수를 쓸 수 있고, 바깥에서는 보이지 않는다 |
연습 문제
- 정수
Price와 세율(퍼센트)Rate를 받아 세금을 포함한 가격을 돌려주는 함수WithTax를 작성한다.Rate의 기본값은 10이다.WithTax(4000)과WithTax(4000, 5)의 결과를 적는다. - 두 정수 변수의 값을 맞바꾸는 프로시저
SwapInt를 작성한다. 매개변수 방식은 무엇이어야 하는지 이유와 함께 답한다. - 아래 코드를 컴파일할 때 오류가 나는 줄을 찾고, 이유와 고치는 방법을 설명한다.
procedure Reset(var N: Integer); begin N := 0; end; begin Reset(5); end. - 같은 이름
Describe의 오버로딩 두 개를 작성한다. 하나는 정수를 받아 "수량 3" 모양의 문자열을, 다른 하나는 문자열을 받아 "메뉴 latte" 모양의 문자열을 돌려준다. 호출 예를 한 줄씩 보인다.
정답과 해설
1번. 정수 나눗셈이므로 곱한 뒤에 나눈다.
function WithTax(Price: Integer; Rate: Integer = 10): Integer;
begin
Result := Price + Price * Rate div 100;
end;
WithTax(4000) 은 4000 + 400 = 4400, WithTax(4000, 5) 는 4000 + 200 = 4200 이다. 기본값 매개변수는 목록 맨 뒤에 있어야 하므로 Rate 를 마지막에 두었다.
2번. 두 매개변수 모두 var 여야 한다. 호출한 쪽의 두 변수를 직접 바꿔야 하기 때문이다. 값 매개변수로 만들면 복사본끼리만 바뀌어 아무 일도 일어나지 않는다.
procedure SwapInt(var A, B: Integer);
var
Temp: Integer;
begin
Temp := A;
A := B;
B := Temp;
end;
Temp 에 한쪽 값을 잠시 보관하지 않으면 첫 대입에서 원래 값이 사라진다. 호출할 때는 SwapInt(X, Y) 처럼 변수를 넘긴다.
3번. Reset(5); 줄이 오류다. var 매개변수에는 변수만 넘길 수 있는데 5 는 상수라서 바뀐 값을 담을 칸이 없다. 변수를 만들어 넘기면 된다.
var
Count: Integer;
begin
Count := 5;
Reset(Count);
end.
4번. 두 선언에 overload 를 붙인다. 타입 Integer 와 string 이 달라 구분된다.
function Describe(Qty: Integer): string; overload;
begin
Result := '수량 ' + IntToStr(Qty);
end;
function Describe(const Name: string): string; overload;
begin
Result := '메뉴 ' + Name;
end;
호출 예로 WriteLn(Describe(3)); 은 "수량 3" 을, WriteLn(Describe('latte')); 는 "메뉴 latte" 를 찍는다. IntToStr 를 쓰므로 uses SysUtils 가 필요하다.
Free Pascal 의 루틴 선언 문법은 Free Pascal 공식 문서의 언어 참조(Reference guide)에서 확인할 수 있다.