프로시저

프로시저

출처: Nim Manual

본문

대부분의 프로그래밍 언어에서 메서드(method)나 함수(function)라 부르는 개념을, Nim에서는 프로시저(procedure)라고 불러요. 프로시저 선언은 식별자 하나와 0개 이상의 정식 매개변수, 반환 값 타입, 그리고 코드 블록으로 이루어져요. 정식 매개변수는 쉼표나 세미콜론으로 구분한 식별자 목록으로 선언하는데요, 각 매개변수에는 : typename 형태로 타입을 붙여줘요. 이 타입은 바로 앞에 있는 모든 매개변수에 적용되는데요, 매개변수 목록의 시작, 세미콜론 구분자, 또는 이미 타입이 붙은 매개변수에 도달하기 전까지예요. 세미콜론을 쓰면 타입과 뒤따르는 식별자 사이를 더 명확히 나눌 수 있어요.

# 쉼표만 사용
proc foo(a, b: int, c, d: bool): int

# 세미콜론으로 시각적으로 구분
proc foo(a, b: int; c, d: bool): int

# 실패: ';'가 타입 전파를 막으므로 a는 타입이 없음
proc foo(a; b: int; c, d: bool): int

매개변수에는 기본값을 선언할 수 있어요. 호출하는 쪽에서 값을 넘겨주지 않으면 이 기본값이 사용되죠. 단, 이 값은 함수가 호출될 때마다 매번 다시 평가된다는 점을 기억해두세요.

# b는 기본값 47을 가진 선택 매개변수
proc foo(a: int, b: int = 47): int

매개변수는 var 타입 수식어를 붙여 가변(mutable)으로 선언할 수 있어요. 그러면 프로시저가 그 인자를 수정할 수 있게 되죠.

# 2번째 인자를 통해 호출자에게 값을 "돌려주는" 예
# 함수가 실제 반환 값을 전혀 쓰지 않는다는 점에 주목 (즉 void)
proc foo(inp: int, outp: var int) =
  outp = inp + 47

프로시저 선언에 몸체가 없으면 전방 선언(forward declaration)이 돼요. 프로시저가 값을 반환한다면, 그 본문에서는 암묵적으로 선언된 result라는 변수에 접근할 수 있는데요, 이 변수가 반환 값을 나타내요. 프로시저는 오버로드(overload)될 수 있고, 오버로드 해석 알고리즘이 인자에 가장 잘 맞는 프로시저를 골라줘요. 예를 들어볼게요:

proc toLower(c: char): char = # 문자용 toLower
  if c in {'A'..'Z'}:
    result = chr(ord(c) + (ord('a') - ord('A')))
  else:
    result = c

proc toLower(s: string): string = # 문자열용 toLower
  result = newString(len(s))
  for i in 0..len(s) - 1:
    result[i] = toLower(s[i]) # 문자용 toLower를 호출; 재귀 아님!

프로시저를 호출하는 방법은 여러 가지예요:

proc callme(x, y: int, s: string = "", c: char, b: bool = false) = ...

# 위치 인자로 호출                         # 매개변수 바인딩:
callme(0, 1, "abc", '\t', true)           # (x=0, y=1, s="abc", c='\t', b=true)
# 이름 인자와 위치 인자 섞어 호출:
callme(y=1, x=0, "abd", '\t')             # (x=0, y=1, s="abd", c='\t', b=false)
# 이름 인자로 호출 (순서는 상관없음):
callme(c='\t', y=1, x=0)                  # (x=0, y=1, s="", c='\t', b=false)
# 명령문처럼 호출: () 없이:
callme 0, 1, "abc", '\t'                  # (x=0, y=1, s="abc", c='\t', b=false)

프로시저는 스스로를 재귀적으로 호출할 수도 있어요.

연산자(operator)는 식별자 대신 특별한 연산자 기호를 가진 프로시저예요:

proc `$` (x: int): string =
  # 정수를 문자열로 변환; 이건 전치 연산자.
  result = intToStr(x)

매개변수 하나를 가진 연산자는 전치(prefix) 연산자이고, 매개변수 둘을 가진 연산자는 중치(infix) 연산자예요. (하지만 파서는 이를 표현식 안에서 연산자의 위치와 구분해요.) 후치(postfix) 연산자를 선언하는 방법은 없는데요, 모든 후치 연산자는 내장되어 있고 문법이 명시적으로 처리하기 때문이에요.

어떤 연산자든 opr 표기법으로 일반 프로시저처럼 호출할 수 있어요. (그래서 연산자가 매개변수를 둘보다 더 가질 수도 있죠):

proc `*+` (a, b, c: int): int =
  # 곱한 다음 더하는 연산
  result = a * b + c

assert `*+`(3, 4, 6) == `+`(`*`(3, 4), 6)

내보내기 표시 (Export marker)

선언한 심볼에 별표(*)를 표시하면 현재 모듈에서 내보내집니다:

proc exportedEcho*(s: string) = echo s
proc `*`*(a: string; b: int): string =
  result = newStringOfCap(a.len * b)
  for i in 1..b: result.add a

var exportedVar*: int
const exportedConst* = 78
type
  ExportedType* = object
    exportedField*: int

메서드 호출 문법 (Method call syntax)

객체지향 프로그래밍을 위해 methodName(obj, args) 대신 obj.methodName(args) 문법을 쓸 수 있어요. 남은 인자가 없으면 괄호를 생략할 수 있겠죠: obj.len (len(obj) 대신에요).

이 메서드 호출 문법은 객체에만 국한되지 않아요. 프로시저의 첫 번째 인자로 어떤 타입이든 제공할 때 쓸 수 있답니다:

echo "abc".len # echo len "abc"와 같음
echo "abc".toUpper()
echo {'a', 'b', 'c'}.card
stdout.writeLine("Hallo") # writeLine(stdout, "Hallo")와 같음

메서드 호출 문법을 다른 관점으로 보면, 빠져 있던 후치 표기법을 제공한다고 볼 수 있어요.

메서드 호출 문법은 명시적 제네릭 인스턴스화와 충돌하는데요, p[T](x)x.p[T]로 쓸 수 없어요. x.p[T]는 항상 (x.p)[T]로 파싱되기 때문이죠.

참고: 메서드 호출 문법의 한계.

이 문제를 완화하려고 [: ] 표기법이 설계됐어요. x.p[:T]는 파서가 p[T](x)로 다시 써주고, x.p[:T](y)p[T](x, y)로 다시 써줘요. [: ]에는 AST 표현이 없고, 다시 쓰기는 파싱 단계에서 바로 수행된다는 점 참고하세요.

프로퍼티 (Properties)

Nim에는 get-프로퍼티가 필요 없어요. 메서드 호출 문법으로 호출되는 일반 get-프로시저가 같은 역할을 해내거든요. 하지만 값을 설정하는 건 다르죠. 그래서 특별한 setter 문법이 필요해요:

# 모듈 asocket
type
  Socket* = ref object of RootObj
    host: int # 모듈 밖에서는 접근할 수 없음

proc `host=`*(s: var Socket, value: int) {.inline.} =
  ## hostAddr의 setter.
  ## 'host' 필드에 접근하는 것이지 `host=`를 재귀 호출하는 게 아님.
  ## 내장된 점(dot) 접근이 가능하면 그것이 우선되기 때문:
  s.host = value

proc host*(s: Socket): int {.inline.} =
  ## hostAddr의 getter
  ## 'host' 필드에 접근하는 것이지 `host`를 재귀 호출하는 게 아님.
  ## 내장된 점(dot) 접근이 가능하면 그것이 우선되기 때문:
  s.host
# 모듈 B
import asocket
var s: Socket
new s
s.host = 34  # `host=`(s, 34)와 같음

f= 처럼 끝에 =가 붙도록 정의된 프로시저를 setter라고 불러요. setter는 일반적인 백틱 표기법으로 명시적으로 호출할 수도 있고요:

proc `f=`(x: MyObject; value: string) =
  discard

`f=`(myObject, "value")

f=x.f = value 패턴에서 암묵적으로 호출될 수 있어요. 단, x의 타입에 f라는 이름의 필드가 없거나, 해당 모듈에서 f가 보이지 않을 때만요. 이 규칙 덕분에 객체 필드와 접근자가 같은 이름을 가질 수 있어요. 모듈 안에서는 x.f가 항상 필드 접근으로 해석되고, 모듈 밖에서는 접근자 프로시저 호출로 해석돼요.

명령 실행 문법 (Command invocation syntax)

호출이 문법적으로 문장(statement)이면 괄호 () 없이도 루틴을 호출할 수 있어요. 이 명령 실행 문법은 표현식에서도 동작하지만, 그 경우에는 인자가 하나만 뒤따를 수 있어요. 이 제약 때문에 echo f 1, f 2echo(f(1), f(2))로 파싱되지 echo(f(1, f(2)))로 파싱되지 않아요. 이 경우 메서드 호출 문법을 쓰면 인자를 하나 더 제공할 수 있어요:

proc optarg(x: int, y: int = 0): int = x + y
proc singlearg(x: int): int = 20*x

echo optarg 1, " ", singlearg 2  # "1 40" 출력

let fail = optarg 1, optarg 8   # 틀림. 명령 호출에는 인자가 너무 많음
let x = optarg(1, optarg 8)  # 인자 2개를 쓰는 전통적인 프로시저 호출
let y = 1.optarg optarg 8    # 괄호 없이 위와 똑같은 것
assert x == y

명령 실행 문법은 복잡한 표현식을 인자로 가질 수도 없어요. 예를 들어 익명 프로시저, if, case, try 같은 것들이요. 인자 없는 함수 호출은 호출과 함수 자체를 일급 값(first-class value)으로 구분하기 위해 여전히 ()가 필요해요.

클로저 (Closures)

프로시저는 모듈의 최상위뿐 아니라 다른 스코프 안에도 나타날 수 있어요. 이때는 중첩 프로시저(nested proc)라고 불러요. 중첩 프로시저는 자신을 감싼 스코프의 지역 변수에 접근할 수 있는데요, 그렇게 하면 클로저(closure)가 돼요. 포착된 변수들은 클로저의 숨은 추가 인자(그 환경)에 저장되고, 클로저와 감싼 스코프 양쪽 모두에서 참조로 접근돼요. 즉, 어느 쪽에서든 수정하면 양쪽에서 다 보이게 되죠. 클로저 환경은 컴파일러가 안전하다고 판단하면 힙이나 스택 어디에든 할당될 수 있어요.

반복문에서 클로저 만들기 (Creating closures in loops)

클로저가 지역 변수를 참조로 포착하기 때문에, 반복문 몸체 안에서는 원치 않는 동작이 되기 쉬워요. 이 동작을 바꾸는 방법은 closureScopecapture를 참고하세요.

익명 프로시저 (Anonymous procedures)

이름 없는 프로시저는 다른 프로시저에 전달하는 람다 표현식으로 쓸 수 있어요:

var cities = @["Frankfurt", "Tokyo", "New York", "Kyiv"]

cities.sort(proc (x, y: string): int =
  cmp(x.len, y.len))

프로시저는 표현식으로서 중첩 프로시저로도, 최상위 실행 코드 안에서도 나타날 수 있어요. sugar 모듈에는 => 매크로가 있는데요, 이 매크로는 JavaScript나 C# 같은 언어의 람다를 닮은, 익명 프로시저를 위한 더 간결한 문법을 쓸 수 있게 해줘요.

do 표기법 (Do notation)

일반 프로시저 표현식의 대부분 요소를 유지하는 특별한 편의 표기법으로, do 키워드를 사용해 익명 프로시저를 루틴에 전달할 수 있어요:

var cities = @["Frankfurt", "Tokyo", "New York", "Kyiv"]

sort(cities) do (x, y: string) -> int:
  cmp(x.len, y.len)

# 메서드 + 명령 문법으로 괄호를 줄인 예:
cities = cities.map do (x: string) -> string:
  "City of " & x

do는 일반 프로시저 매개변수를 감싼 괄호 뒤에 써요. do 블록이 나타내는 프로시저 표현식은 루틴 호출의 마지막 인자로 덧붙여져요. 명령 문법을 쓴 호출에서는 do 블록이 명령 호출이 아니라 바로 앞의 표현식에 결합돼요.

매개변수 목록이나 프라그마 목록을 가진 do는 익명 proc에 해당하지만, 매개변수나 프라그마가 없는 do는 일반 문장 목록으로 취급돼요. 이 덕분에 매크로가 인라인 호출에서 들여쓴 문장 목록을 인자로 받을 수도 있고, Nim의 루틴 문법을 그대로 받을 수도 있어요.

# 인라인 매크로에 문장 목록 전달:
macroResults.add quote do:
  if not `ex`:
    echo `info`, ": Check failed: ", `expString`

# 매크로에서 루틴 정의 처리:
rpc(router, "add") do (a, b: int) -> int:
  result = a + b

func

func 키워드는 부작용 없는 프로시저(noSideEffect proc)를 위한 단축 표기법이에요.

func binarySearch[T](a: openArray[T]; elem: T): int

이것은 다음의 줄임 표현이에요:

proc binarySearch[T](a: openArray[T]; elem: T): int {.noSideEffect.}

루틴 (Routines)

루틴(routine)은 proc, func, method, iterator, macro, template, converter 종류의 심볼을 가리켜요.

타입에 묶인 연산자 (Type bound operators)

타입에 묶인 연산자(type bound operator)는 이름이 =로 시작하지만 연산자(즉 ==처럼 기호만으로 이뤄진 것)는 아닌 proc이나 func를 말해요. 이들은 =로 끝나는 setter와는 관련 없어요(프로퍼티 참고). 타입에 선언된 타입에 묶인 연산자는, 그 연산자가 스코프에 있든 없든(비공개라도) 해당 타입에 적용돼요.

# foo.nim:
var witness* = 0
type Foo[T] = object
proc initFoo*(T: typedesc): Foo[T] = discard
proc `=destroy`[T](x: var Foo[T]) = witness.inc # 타입에 묶인 연산자

# main.nim:
import foo
block:
  var a = initFoo(int)
  doAssert witness == 0
doAssert witness == 1
block:
  var a = initFoo(int)
  doAssert witness == 1
  `=destroy`(a) # 스코프 밖이어도 명시적으로 호출 가능
  doAssert witness == 2
# 스코프를 벗어날 때 여전히 호출됨
doAssert witness == 3

타입에 묶인 연산자는 =destroy, =copy, =sink, =trace, =deepcopy, =wasMoved, =dup이에요.

이 연산들은 오버로드(overload) 대신 오버라이드(override)될 수 있어요. 즉, 구현이 구조적 타입으로 자동 승격된다는 뜻이죠. 예를 들어 타입 T에 할당 연산자 =가 오버라이드되어 있으면, 그 연산자는 seq[T] 타입의 할당에도 쓰여요.

이 연산들은 타입에 묶여 있기 때문에, 구현의 단순함을 위해 명목 타입(nominal type)에 묶여야 해요. 즉 오버라이드된 ref TdeepCopy는 실제로 ref T가 아니라 T에 묶여요. 덕분에 ptr Tref T 둘 다를 위한 deepCopy를 동시에 오버라이드할 수 없고, 포인터 타입 하나는 distinct나 object 헬퍼 타입을 써야 해요.

이들 중 일부 프로시저에 대한 자세한 내용은 수명 추적 훅(Lifetime-tracking hooks)을 참고하세요.

오버로드 불가능한 내장 함수 (Nonoverloadable builtins)

다음 내장 프로시저들은 구현의 단순함 때문에 오버로드할 수 없어요 (특수화된 의미 검사가 필요하거든요):

declared, defined, definedInScope, compiles, sizeof,
is, shallowCopy, getAst, astToStr, spawn, procCall

그래서 이들은 일반 식별자보다 키워드에 가깝게 동작해요. 다만 키워드와 달리, 재정의하면 system 모듈의 정의를 가릴(shadow) 수 있어요. 이 목록 중 다음은 x.f 형태의 점 표기법으로 쓰면 안 돼요. xf로 전달되기 전에 타입 검사를 할 수 없기 때문이에요:

declared, defined, definedInScope, compiles, getAst, astToStr

var 매개변수 (Var parameters)

매개변수의 타입 앞에 var 키워드를 붙일 수 있어요:

proc divmod(a, b: int; res, remainder: var int) =
  res = a div b
  remainder = a mod b

var
  x, y: int

divmod(8, 5, x, y) # x와 y를 수정함
assert x == 1
assert y == 3

이 예에서 resremaindervar 매개변수예요. var 매개변수는 프로시저가 수정할 수 있고, 그 변경은 호출자에게도 보여요. var 매개변수로 넘기는 인자는 l-value여야 해요. var 매개변수는 숨은 포인터로 구현되는데요, 위 예는 다음 코드와 동등해요:

proc divmod(a, b: int; res, remainder: ptr int) =
  res[] = a div b
  remainder[] = a mod b

var
  x, y: int
divmod(8, 5, addr(x), addr(y))
assert x == 1
assert y == 3

예시에서는 반환 값 두 개를 돌려주려고 var 매개변수나 포인터를 썼는데요, 튜플을 반환하면 더 깔끔하게 할 수 있어요:

proc divmod(a, b: int): tuple[res, remainder: int] =
  (a div b, a mod b)

var t = divmod(8, 5)

assert t.res == 1
assert t.remainder == 3

튜플 언패킹(tuple unpacking)로 튜플의 필드에 접근할 수도 있어요:

var (x, y) = divmod(8, 5) # 튜플 언패킹
assert x == 1
assert y == 3

참고: 효율적인 매개변수 전달을 위해 var 매개변수가 필요한 경우는 없어요. var가 아닌 매개변수는 수정할 수 없으므로, 컴파일러는 실행을 빨라지게 할 수 있다고 판단하면 언제든 인자를 참조로 전달할 자유가 있어요.

var 반환 타입 (Var return type)

proc, converter, iteratorvar 타입을 반환할 수 있어요. 이는 반환 값이 l-value라서 호출자가 수정할 수 있다는 뜻이에요:

var g = 0

proc writeAccessToG(): var int =
  result = g

writeAccessToG() = 6
assert g == 6

암묵적으로 도입된 포인터로 수명을 넘는 위치에 접근할 수 있다면 정적 오류(static error)가 나요:

proc writeAccessToG(): var int =
  var g = 0
  result = g # 오류!

이터레이터에서는 튜플 반환 타입의 한 구성 요소도 var 타입일 수 있어요:

iterator mpairs(a: var seq[string]): tuple[key: int, val: var string] =
  for i in 0..a.high:
    yield (i, a[i])

표준 라이브러리에서는 규약상 var 타입을 반환하는 루틴의 이름이 전부 m 접두사로 시작해요.

var T로 반환할 때의 메모리 안전성은 간단한 차용 규칙(borrowing rule)으로 보장돼요. result가 힙을 가리키는 위치를 참조하지 않는다면(result = X에서 Xptr이나 ref 접근을 포함하지 않으면), 루틴의 첫 번째 매개변수에서 파생되어야 해요:

proc forward[T](x: var T): var T =
  result = x # 좋음, 첫 번째 매개변수에서 파생됨.

proc p(param: var int): var int =
  var x: int
  # 'forward'가 첫 번째 인자 'x'에서 파생된 위치에 대한
  # 뷰를 제공한다는 걸 알고 있음.
  result = forward(x) # 오류: 위치가 `x`에서 파생됨.
                      # `x`는 p의 첫 번째 매개변수가 아니고
                      # 스택에 살아 있음.

다시 말해 result가 가리키는 것의 수명은 첫 번째 매개변수의 수명에 붙어 있고, 그 사실만으로 호출 지점에서 메모리 안전성을 검증하기에 충분해요.

향후 방향 (Future directions)

이후 버전의 Nim에서는 차용 규칙을 더 정밀하게 다루는 문법을 쓸 수 있을 거예요:

proc foo(other: Y; container: var X): var T from container

여기서 var T from container는 그 위치가 두 번째 매개변수(여기선 'container'라 부르는)에서 파생됐음을 명시적으로 드러내요. var T from p 문법은 varTy[T, 2] 타입을 지정하는데, 이는 varTy[T, 1]과 호환되지 않아요.

NRVO

참고: 이 절은 현재 구현을 설명해요. 언어 명세의 이 부분은 바뀔 예정이에요. 자세한 내용은 https://github.com/nim-lang/RFCs/issues/230를 보세요.

반환 값은 루틴 본문 안에서 특별한 result 변수로 표현돼요. 이 덕분에 C++의 "이름 붙은 반환 값 최적화"(named return value optimization, NRVO)와 비슷한 메커니즘을 쓸 수 있어요. NRVO는 p 안에서 result로의 저장이 let/var dest = p(args)(즉 dest 정의)와 dest = p(args)(dest로의 할당) 양쪽에서 목적지 dest에 직접 영향을 준다는 뜻이에요. 이것은 dest = p(args)p'(args, dest)로 다시 쓰는 방식으로 이뤄지는데요, p'p에서 void를 반환하고 result를 나타내는 숨은 가변 매개변수를 받는 변형이에요.

비공식적으로는 이렇게 됩니다:

proc p(): BigT = ...

var x = p()
x = p()

# 대략 이렇게 바뀜:

proc p(result: var BigT) = ...

var x; p(x)
p(x)

Tp의 반환 타입이라고 할게요. NRVO는 sizeof(T) >= N일 때 T에 적용돼요 (N은 구현에 따라 달라요). 즉 "큰" 구조체에 적용된다는 뜻이죠.

p가 예외를 일으킬 수 있다면 NRVO는 조건과 무관하게 적용돼요. 이 때문에 관찰 가능한 동작 차이가 생길 수 있어요:

type
  BigT = array[16, int]

proc p(raiseAt: int): BigT =
  for i in 0..high(result):
    if i == raiseAt: raise newException(ValueError, "interception")
    result[i] = i

proc main =
  var x: BigT
  try:
    x = p(8)
  except ValueError:
    doAssert x == [0, 1, 2, 3, 4, 5, 6, 7, 0, 0, 0, 0, 0, 0, 0, 0]

main()

이런 경우 컴파일러가 경고를 낼 수 있지만, 이 동작은 기본적으로 꺼져 있어요. 코드 구간에 warning[ObservableStores]push/pop 프라그마로 켤 수 있죠. 위 코드를 예로 들면:

{.push warning[ObservableStores]: on.}
main()
{.pop.}

첨자 연산자 오버로딩 (Overloading of the subscript operator)

배열/오픈배열/시퀀스용 [] 첨자 연산자는 []라는 이름의 루틴을 정의해 어떤 타입에든(일부 예외 있음) 오버로드할 수 있어요.

type Foo = object
  data: seq[int]

proc `[]`(foo: Foo, i: int): int =
  result = foo.data[i]

let foo = Foo(data: @[1, 2, 3])
echo foo[1] # 2

첨자에 대한 할당도 []=라는 이름의 루틴을 지정해 오버로드할 수 있어요. 이는 [] 결과에 할당하는 것보다 우선해요.

type Foo = object
  data: seq[int]

proc `[]`(foo: Foo, i: int): int =
  result = foo.data[i]
proc `[]=`(foo: var Foo, i: int, val: int) =
  foo.data[i] = val

var foo = Foo(data: @[1, 2, 3])
echo foo[1] # 2
foo[1] = 5
echo foo.data # @[1, 5, 3]
echo foo[1] # 5

첨자 연산자의 오버로드는 루틴이나 타입 심볼 자체에는 적용할 수 없어요. 제네릭 매개변수를 인스턴스화하는 문법, 즉 foo[int](1, 2, 3)Foo[int]와 충돌하기 때문이에요.

더 알아보기 (Learn more)