본문 바로가기

Julia

Julia의 함수

Julia에서 함수는 인자와 결과값 사이의 맵핑으로 이해된다. readline() 같이 인자를 주지 않고 호출하는 함수가 있긴 하지만, 이 함수들은 기본 인자를 가지고 있어서 인자를 생략할 수 있을 뿐이다. (물론 인자가 없는 함수를 만들 수 없는 것은 아니다. 권장되지 않는다.) 또, Julia에서는 어떤 객체에 묶여 있는 함수, 다른 OOP언어에서 '메소드'라 부르는 개념을 사용하지 않는다. 모든 함수는 자유함수이다. A.func() 와 같은 함수들이 있긴 하지만 이것은 객체에 바인드된 함수가 아니라 모듈을 반입한 방식에 따라서 이름 공간이 구분되어 있는 함수를 지칭하는 방법일 뿐이다.

참고로 Julia에서 '메소드'는 다른 의미로 사용된다. 인자의 구성이나 타입이 다르다면 같은 이름의 함수를 여러 개 정의하는 것이 가능하며, 이 때 하나의 이름 아래에서 호출되는 각각의 함수를 메소드라 한다.

함수를 정의하는 문법

함수를 정의하는 문법은 몇 가지가 있다.

먼저 function ... end 블럭을 사용하는 것이다. function 함수이름(인자) 의 형태로 함수의 이름을 정의한다. 줄리아의 모든 함수가 순수 함수는 아니지만, 기본적으로 줄리아에서는 함수를 인자와 결과 사이의 맵핑으로 보기 때문에 인자가 없는 함수는 의미가 없다. 전형적인 함수 선언 방식은 다음과 같다.

function addvalues(a, b)
  c = a + b
  return c
end

이상의 구문은 여타 다른 프로그래밍 언어와 크게 다를 것이 없다. return 구문은 다음에 오는 표현식의 값을 리턴하고 함수 내부의 실행을 중지한다. 만약 함수가 내부에서 end를 만난다면, 마지막으로 평가된 구문의 값을 자동으로 리턴한다. 따라서 위 함수에서는 실질적으로 return cc 로 대체될 수 있다. 또 c = a + b 에서 대입문은 파이썬과 달리 역시 우변을 평가한 값이므로 변수 c도 굳이 필요가 없다고 할 수 있다.

function addvalues(a, b)
  a + b
end

함수를 선언하는 또 다른 방법은 다음과 같은 것인데, 수학에서의 함수 선언 방법과 비슷하다. 이 문법은 기본적으로 함수의 본체를 한 줄에 쓸 수 있을 때 사용한다.

addvalues(a, b) = a + b

물론 위 문법에서 우변을 begin ... end 블럭을 사용해서 여러 줄에 쓰는 것도 얼마든지 가능하다. 그 외에도 람다 대수에 기반한 익명함수를 이름에 바인딩하는 방법도 있다.

addvalues = (a, b) -> a + b
f = (x) -> x * 2

# 인자가 하나인 경우 괄호를 생략할 수 있다.
g = x -> x * 2 + 3

함수 이름의 규칙

Julia의 이름 규칙에서는 타입이나 모듈의 이름이 아닌 변수 및 함수의 이름은 모두 소문자로 짓는다. 단어들은 기본적으로 붙여 쓰되, 읽기 어렵거나 혼동이 되는 경우에는 공백을 언더스코어로 대체하여 쓸 수 있다. 따라서 대부분은 약간 축약된 단어 형태로 결정된다.

인자로 전달 받은 값이 변경 가능한 타입이고, 실제로 함수 내에서 변경된다면 이 함수들은 !로 끝나게 짓도록 한다. 이는 mutating 함수라 하는 관례이며, 값의 내부를 변경한다는 것을 사용자에게 알려준다. 예를 들어 정렬에 사용되는 함수 sort()는 인자로 전달 받은 배열을 정렬한 사본을 리턴한다. sort!()의 경우에는 인자로 전달 받은 배열을 정렬하고 그것을 다시 리턴해준다.

함수 인자

함수 인자는 괄호 속에 순서대로 콤마로 구분하여 지정한다. 만약 인자에 기본값을 지정하려면 arg=value 의 형식으로 지정해 줄 수 있다. 기본인자가 있는 값은 호출 시 생략이 가능하기 때문에, 기본값이 없는 인자보다 앞서서 있으면 안된다. 기본값을 갖는 인자는 키워드인자와 구분되는데, 키워드 인자와 위치 인자는 세미 콜론으로 구분된다.

이 지점이 파이썬과 다른 부분인데, 줄리아에서 키워드 인자는 선언한 순서를 무시하기 때문에 호출할 때에는 키워드이름을 반드시 지정해주어야 한다.

가변인자

인자의 개수가 정해지지 않은 함수를 정의해야할 필요가 있는데, 이 때 사용되는 것이 가변인자이다. 가변인자는 위치 인자 및 키워드 인자에서 선언할 수 있으며, 각 인자의 그룹에서 맨 마지막에 위치하고 ... 으로 끝난다.

가변인자로 선언된 인자는 튜플(위치 인자) 및 이름이 있는 튜플(키워드 인자)로 취급된다.

foo(a, b, c...) = (a, b, c)

foo(1, 2)
# (1, 2, ())

foo(1, 2, 3)
# (1, 2, (3,))

foo(1, 2, 3, 4, 5)
# (1, 2, (3, 4, 5))

bar(a, b, c=3) = (a, b, c)
bar(1, 2)      # (1, 2, 3)
bar(1, 2, 4)   # (1, 2, 4)
bar(1, 2, c=4)
ERROR: MethodError: no method matching bar(::Int64, ::Int64; c=3)

선택적 인자는 키워드 인자와 달라서, 호출 시에 이름을 지정해서는 안된다. 키워드 인자는 명시적으로 ; 으로 구분하여 선언한다. 경우에 따라서는 위치 인자 없이 키워드 인자로만 인자가 구성될 수 있다. 키워드 인자는 기본적으로 선택적 인자이며, 호출시에는 순서에 구애받지 않는다. 또 키워드 인자에도 가변 인자를 마지막에 선언할 수 있으며, 이는 함수 내부에서 이름이 있는 튜플의 이터레이터로 사용된다.

baz(a, b; c=4)

baz(1, 2)
# (1, 2, 4)

baz(1, 2, 3)
ERROR: MethodError

baz(1, 2, c=5)
# (1, 2, 5)

fizz(;a=1, b=2, c...)

fizz()
# (1, 2, Base.Iterators.Pairs{Union{},Union{},Tuple{},NamedTuple{(),Tuple{}}}())

fizz(c=1)
(1, 2, Base.Iterators.Pairs(:c => 1))

fizz(c=1, a=2, b=3, d=4)
(2, 3, Base.Iterators.Pairs(:c => 1,:d => 4))

가변 키워드 인자는 키-값 쌍의 이름있는 튜플(NamedTuple)의 이터레이터로 받아들여진다.

buzz(;a=1, b...) = begin
  for (k, v) in b
    println(v + 1)
  end
end

buzz(x=2, y=3, z=4)
# 3 (for x)
# 4 (for y)
# 5 (for z)

타입 어노테이션

줄리아에서 변수나 함수 뒤에 겹콜론을 쓰고 그 타입을 지정하면 변수의 타입을 미리 한정할 수 있다. 이는 함수에 대해서도 마찬가지이며, 인자의 경우도 마찬가지다. 아래 예는 정수타입만을 인자로 받는 함수를 정의한 것이다. 이렇게 정의된 경우 선언한 형과 다른 타입의 인자를 넘겼을 때, 해당 인자의 타입 구성으로 찾을 수 있는 메소드가 없으므로 오류가 발생한다.

g(a::Integer, b::Integer)::Integer = a * 2 + b

g(1.2, 3.4)
# ERROR: MethodError no method matching g(::Float64, ::Float64)

만약 인자의 타입을 지정하지 않았다면 해당 함수는 제네릭으로 지정되고 가능한 모든 형에 대해서 실행되려고 한다.

앞서, 함수의 이름이 같지만 인자의 구성이 다르다면 중복으로 선언하는 것이 가능하다고 했다. 이렇게 여러 메소드를 가진 함수의 각 메소드를 확인하려면 methods() 함수를 사용하면 된다. 예를 들어 sort() 함수에 대해서는 다음과 같은 메소드들을 확인할 수 있다.

julia> methods(sort)
# 5 methods for generic function "sort":
[1] sort(r::AbstractUnitRange) in Base at range.jl:1014
[2] sort(r::AbstractRange) in Base at range.jl:1017
[3] sort(x::SparseArrays.SparseVector{Tv,Ti}; kws...) where {Tv, Ti} in SparseArrays at d:\Program Files\Julia 1.5.2\share\julia\stdlib\v1.5\SparseArrays\src\sparsevector.jl:1912
[4] sort(v::AbstractArray{T,1} where T; kws...) in Base.Sort at sort.jl:780
[5] sort(A::AbstractArray; dims, alg, lt, by, rev, order) in Base.Sort at sort.jl:1038

함수의 벡터화

배열이나 벡터의 모든 원소에 특정한 연산을 주입하는 것을 맵핑이라하는데, map() 함수가 이 역할을 한다. 다만, 벡터나 배열의 각 원소에 대해서 map 함수를 사용하지 않더라도 함수를 바로 적용하는 방법이 있다. 함수 이름 뒤에 . 을 붙여서 호출하는 것이다. 다음 예는 어떤 변환을 map() 함수를 쓰지 않고 Range 내의 모든 값에 적용하여 1차원 배열을 만드는 방법을 보여준다.

h = x -> x * 2 + 1
h.(1:5)
# 5-element Array{Int64, 1}:
# 3 5 7 9 11

이 문법을 사용하면 공백으로 구분하여 입력받은 숫자들을 간단하게 정수의 배열로 변환할 수 있다.

(x -> parse(Int, x)).(readline() |> split)

파이프

함수를 호출할 때 인자 |> 함수이름 의 형식으로 호출하는 것이 가능하다. 이를 사용하면 함수의 실행 결과를 다른 함수에 넘겨주는 방식으로 파이프를 구성할 수 있다.

f(x) = x * 3
g(x) = x + 2
f(2) |> g 
# 8
# 6 |> g = 6 + 2

앞 함수의 결과를 뒤 함수에 전달하는 것은 다시 두 함수를 합성하는 것과 같다고 볼 수 있다. 함수를 합성하는 연산자는 ∘ 이다. (이 연산자는 줄리아 REPL에서 \circ 를 입력하고 탭 키를 누르면 입력할 수 있다.) 즉 f(x) |> g 는 (g∘f)(x)와 같다.

함수와 do 블럭

함수 중에는 다른 함수를 인자로 받는 경우가 있다. map()이나 filter() 같은 함수들이 그런 예이다. 그런데 이 함수를 사용할 때에는 미리 정의된 함수를 사용하기 보다는 익명함수를 사용하는 경우도 많다. 줄리아에는 익명함수를 정의하는 문법이 있고, 문법의 여러 잇점을 활용하면 함수의 본체를 짧고 간단하게 정의할 수 있는 경우도 많지만, 여러 줄의 코드로 함수의 본체가 구성되어야 한다면 사용하기가 좀 지저분하다. 이것을 간단하게 만들어주는 것이 do 블럭 문법이다.

첫번째 인자가 함수인 함수를 첫번째 인자를 생략하고 호출한 후에, () 뒤에 do 변수명 으로 시작하는 블럭을 지정해서 사용한다. 보통 open()과 같은 함수에서 이런 방법을 많이 사용한다. 아래 두 코드는 정확하게 하는 일이 같은데, 어느쪽이 읽기 쉬운가?

open(io -> begin
  line = readline(io)
  print(line)
end, "filename.txt", "r")


open("filename.txt", "r") do io
  line = readline(io)
  print(line)
end