Julia의 함수 사용팁

연산자의 함수적 표기

Julia의 연산자는 기본적으로 함수이며, 함수 호출 표기와 같은 방식으로 호출하는 것이 가능합니다. 또한 그 자체로 함수이기 때문에 filter(), map() 과 같이 함수를 인자로 받는 함수에도 연산자를 그대로 적용하는 것이 가능합니다. 특히 + 연산자는 sum() 함수와 같이 여러 인자를 받아 인자들의 합을 구할 수 있습니다.

2 + 3
# = 5

+(2, 3)
# = 5

+(2, 3, 4)
# = 9

>(3, 2)
# = true

이 때, 비교 연산자들은 첫번째 인자만 전달된 경우에는 부분 적용된 함수를 생성합니다. 이는 특히 함수를 인자로 전달하는 함수에 사용될 때, 표현을 간결하게 하는데 유용합니다.

>(10, 5)
# = true

>(10)
# == x -> 10 > x

filter(x -> x > 10, A)

# 위 표현은 아래와 같이 단축하여 표기할 수 있습니다. 
filter(>(10), A)

map / filter / reduce

배열과 관련해서 가장 많이 사용되는 세 가지 함수는 map(), filter(), reduce() 일 것입니다. map은 특정한 함수나 연산을 배열이나 벡터의 각 요소에 적용하는 함수인데, 이 함수가 하는 일은 Julia의 연산자 브로드캐스팅과 매우 유사하여, 많은 경우 브로드 캐스팅으로 map을 대체할 수 있습니다. 브로드캐스팅은 함수의 뒤나 연산자 앞에 . 을 찍어서 자동으로 적용되도록 할 수 있습니다. 예를 들어 아래 코드는 40 이하의 3으로 나누면 1이 남는 자연수들의 배열인 A의 모든 원소에 대해 각각을 제곱한 B를 구하는 코드입니다.

A = Array(1:3:40)
B = A .^ 2

# 만약 map()을 사용한다면 익명함수나 블럭 표기를 사용
B = map(x -> x ^ 2, A)

B = map(A) do x in
    x ^ 2
    end

브로드캐스팅은 명시적인 루프가 없기 때문에 간단하고 단순한 연산에 대해서는 적용하기가 좋고 성능 측면에서도 더욱 유리합니다. 그러나 논리적으로 모든 맵핑을 브로드캐스팅으로 대체할 수 있다하더라도 map() 을 사용하는 것이 더 나은 경우도 있습니다. 적용하려는 연산이 단순하지 않고 복잡한 로직을 요구하거나 여러 단계의 연산으로 구성된 경우에는 map()을 사용하는 것이 더 좋습니다. 특히 map()은 타입 추론에 더욱 유리하므로 타입 안정성이 중요하다면 map()을 사용하는 것이 더 추천됩니다.

특히 Julia의 map()함수는 사상하려는 함수의 인자의 개수만큼 배열 인자를 받아서 한 번에 적용할 수 있습니다. 예를 들어 두 배열에서 첫번째 배열의 원소는 2를 곱하고, 두 번째 배열의 원소에는 3을 곱한 후 더한 값의 배열을 만들때에는 zip() 함수를 사용합니다. 줄리아에서는 별도의 zip 없이 (물론 zip()이 이러한 용도를 위해 존재하고 이걸 사용해도 됩니다) 다음과 같이 단축하여 적을 수 있습니다.

A = ...
B = ...

C = map((x, y) -> 2x + 5y, zip(A, B))

# 다음과 같이 축약

C = map((x, y) -> 2x + 5y, A, B)

# 여러 배열의 각 원소에 대해 복잡한 코드를 적용하여 연산할 때
W = map(X, Y, Z) do x, y, z in
   ....
end

filter() 함수는 어떤 값이 특정한 조건을 만족하는지를 평가하는 함수를 기준으로, 배열에서 조건을 만족하는 요소만을 골라내는 함수입니다. filter()의 특이한 점으로는 평가함수만 인자로 전달하면 부분 적용된 함수를 리턴할 수 있다는 점입니다. 이 특징을 사용하여 자주 사용하는 필터 함수를 간단하게 만들어서 사용할 수 있습니다.

A = Array(1:3:10)
less_than_10 = filter(<(10))
B = less_than_10(A)
# B = [1, 4, 7]

reduce or fold

reduce()는 여러 개의 값을 하나의 값으로 합칠 때 사용하는 함수입니다. 배열의 합계나 원소의 개수 같은 것을 구할 때 사용합니다.

A = Array(1:10)
reduce((acc, x) -> acc + x, A) |> println
# 55

reduce() 함수 자체에 특이한 부분은 없습니다. 그런데 reduce는 언어에 따라서는 fold라는 이름으로 불리기도 합니다. 배열의 각 원소를 순서대로 접어나가면서 하나의 값으로 만드는 동작을 하기 때문인데요, 주로 함수형 언어에서 fold라는 이름을 많이 씁니다. 그런데, fold는 다시 방향에 따라서 foldl과 foldr로 나뉩니다. 왜 이런 이야기를 하냐면 Julia에는 reduce, foldl, foldr이 모두 존재하기 때문입니다.

표면적인 차이로는 foldl(), foldr()은 각각 연산의 방향에 따라 최적화되어 있다는 점입니다. 단순한 합계나 누적곱을 처리하는 것보다 좀 더 복잡하거나 특별한 자료 구조를 다룰 때에는 어떤 함수를 사용하는지가 중요하게 고민해야 하는 요소가 됩니다. foldl()은 문자열 연결과 같이 앞에서부터 연산해야하는 경우에 유리합니다. 특히 크기가 큰 데이터를 다룰 때 메모리를 효율적으로 사용할 수 있는 것으로 알려져 있습니다. foldr()은 함수의 배열에 대해서 순차적으로 적용하는 등 오른쪽부터 연산해야 하는 경우에 사용되며, 특히 지연된 평가를 통해 원하는 만큼만 계산하기에 좋은 것으로 알려져 있습니다. reduce()는 단순 덧셈이나 곱셈과 같이 결합법칙을 적용할 수 있는, 즉 연산의 순서가 중요하지 않은 작업에 사용될 수 있습니다.

push!, pop!

push!()pop!()은 어떤 집합에 새로운 요소를 추가하거나, 마지막에 추가된 요소를 제거하는 함수입니다. 이름 뒤에 !기호가 붙는 것은 이 함수가 인자로 받는 객체의 내부를 변경한다는 의미입니다. (map()이나 filter() 가 새로운 집합을 만드는 것과 대조적으로 이 함수들은 실제로 기존 집합에 원소를 더하거나 빼는 동작입니다.)

스택의 push, pop 동작처럼 배열의 맨 끝에서 원소를 추가하거나 제거합니다. 그 외에 몇 가지 variation이 존재하니 참고해두는 것이 좋습니다. 참고로 배열 중간에 요소를 삽입할 때에는 pushat!() 이 아니라, insert!()를 사용합니다.

  • push!(A, item)
  • pop!(A)
  • pushfirst!(A, item)
  • popfirst!(A)
  • popat!(A, index)
  • insert!(A, index, item)

first, findfirst, last, findlast

first() 함수는 벡터나 튜플의 첫번째 요소를 구할 수 있는 함수인데, 이름에 함정이 있어서, first(A, n) 과 같이 두 번째 인자에 인덱스를 받아서 앞에서부터 n개의 원소로 된 부분 집합을 얻을 수 있습니다. 함수형 언어에서 보통 take() 에 해당하는 함수라 할 수 있습니다. first()와 반대로 맨 끝의 원소나 뒤에서부터 n개의 원소를 얻고 싶을 때에는 last() 함수를 사용할 수 있습니다.

A = [1,5,2,8,4,6]

first(A) # 1
first(A, 3) # [1,5,2]

last(A) # 6
last(A, 2) # [4,6]

특정한 조건식을 주고 이를 만족하는 첫 값의 위치를 찾는 명령으로는 findfirst()가 있습니다. 줄리아의 벡터에는 파이썬의 index()와 같이 특정한 값이 몇 번째에 위치하는지를 알려주는 함수가 없으므로, findfirst(==(x), A) 와 같은 식으로 사용할 수 있습니다. 비교 연산자를 함수적으로 쓰면서 인자를 하나만 전달하면 부분적용함수가 된다는 사실을 다시 한 번 상기하도록 합시다.

A = [1, 5, 2, 8, 4, 6]
findfirst(iseven, A)
# -> 3

findfirst(==(8), A)
# -> 4

findfirst('o', "hello world")
# -> 5

findfirst("wo", "hello world")
# -> 7:8

연산자를 부분 적용 함수로 만들기

Base.Fix1(), Base.Fix2() 함수는 각각 이항 연산자의 첫번째, 두번째 인자를 고정하여, 주어진 연산자를 함수로 변환하는데 사용될 수 있습니다. 비교 연산자가 아닌 이항 연산자들은 인자를 하나만 전달했을 때 부분 적용 함수가 되지 않으므로 이와 같은 방법을 사용할 수도 있습니다. 대신 클로저를 사용하는 방식이 더 간단하고 직관적으로 느껴질 수도 있을 것 같습니다.

add5 = Base.Fix1(+, 5)
# 다음과 같이 정의한 것과 같음
# add5 = x -> 5 + x

add5(3) == 8
# true

divided_by_2 = Base.Fix2(/, 2)
# divided_by_2 = y -> y / 2

zip

zip 함수는 여러 개의 배열의 각 원소들을 순번대로 묶어서 병렬로 순회하게 해주는 함수입니다. 이미 map() 함수는 첫번째 인자가 여러 개의 인자를 받는 함수인 경우에는 그 인자의 개수만큼 배열을 받아서 zip() 함수가 하는 동작을 포함하고 있습니다만, 여전히 여러 개의 배열의 원소들을 동시에 처리할 때에는 편리하게 사용할 수 있습니다.

zip() 함수는 여러 배열의 각 원소들을 순서대로 짝지어 하나의 튜플로 만들어줍니다. 단 이 튜플을 함수에 전달하려는데, 그 함수가 튜플이 아닌 여러 인자를 받도록 디자인되어 있다면 인터페이스가 호환되지 않습니다. 이 경우에는 두 가지 해결책이 있습니다.

  1. f(xs...) 과 같이 튜플 뒤에 ... 을 써서 튜플의 내용을 언패킹하여 인자의 목록으로 변환하여 넘겨주는 방법이 있습니다.
  2. 다른 방법으로는 splat() 함수가 있습니다. 이 함수는 여러 인자를 받는 함수를 튜플 하나를 받는 함수로 변환해주는 함수입니다.

xs... 과 같이 사용되는 ... 을 스플랫 연산자(splat operator)라고 하는데, 튜플 뿐만 아니라 다른 집합에 대해서도 적용이 가능합니다. 예를 들어 두 개의 1차원 백터는 다음과 같이 하나로 합칠 수 있습니다.

A = [1, 2, 3]
B = [7, 8, 9]

[A..., B...]

# == vcat(A, B)

(단, 경험상 이렇게 배열을 합치는 경우에는 스플랫 연산자보다는 vcat을 사용하는 편이 더 좋은 성능을 보였던 것 같습니다.)