Julia 함수형 프로그래밍: 연산자와 배열 처리
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()을 대체할 수 있습니다.
브로드캐스팅 vs 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
x ^ 2
end
브로드캐스팅은 명시적인 루프가 없기 때문에 간단하고 단순한 연산에 대해서는 적용하기가 좋고 성능 측면에서도 더욱 유리합니다. 그러나 논리적으로 모든 매핑을 브로드캐스팅으로 대체할 수 있다 하더라도 map()을 사용하는 것이 더 나은 경우도 있습니다.
적용하려는 연산이 단순하지 않고 복잡한 로직을 요구하거나 여러 단계의 연산으로 구성된 경우에는 map()을 사용하는 것이 더 좋습니다. 특히 map()은 타입 추론에 더욱 유리하므로 타입 안정성이 중요하다면 map()을 사용하는 것이 더 추천됩니다.
여러 배열을 동시에 매핑하기
특히 Julia의 map() 함수는 매핑하려는 함수의 인자 개수만큼 배열 인자를 받아서 한 번에 적용할 수 있습니다. 예를 들어 두 배열에서 첫 번째 배열의 원소는 2를 곱하고, 두 번째 배열의 원소에는 3을 곱한 후 더한 값의 배열을 만들 때, 다른 언어에서는 zip() 함수를 사용합니다. Julia에서는 별도의 zip() 없이(물론 zip()을 사용해도 됩니다) 다음과 같이 단축하여 적을 수 있습니다.
A = [1, 2, 3]
B = [4, 5, 6]
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
# 복잡한 연산
result = x * y + z
return result
end
filter()로 조건에 맞는 요소 선택하기
filter() 함수는 어떤 값이 특정한 조건을 만족하는지를 평가하는 함수를 기준으로, 배열에서 조건을 만족하는 요소만을 골라내는 함수입니다. filter()의 특이한 점으로는 평가 함수만 인자로 전달하면 부분 적용된 함수를 반환할 수 있다는 점입니다. 이 특징을 사용하여 자주 사용하는 필터 함수를 간단하게 만들어서 사용할 수 있습니다.
A = Array(1:3:40)
less_than_10 = filter(<(10))
B = less_than_10(A)
# B = [1, 4, 7]
# 짝수만 필터링
evens = filter(iseven, [1, 2, 3, 4, 5, 6])
# evens = [2, 4, 6]
reduce or fold
reduce()는 여러 개의 값을 하나의 값으로 합칠 때 사용하는 함수입니다. 배열의 합계나 원소의 곱 같은 것을 구할 때 사용합니다.
A = Array(1:10)
reduce((acc, x) -> acc + x, A) |> println
# 55
# 곱셈 예제
reduce(*, [1, 2, 3, 4, 5])
# 120
reduce() 함수 자체에 특이한 부분은 없습니다. 그런데 reduce()는 언어에 따라서는 fold라는 이름으로 불리기도 합니다. 배열의 각 원소를 순서대로 접어나가면서 하나의 값으로 만드는 동작을 하기 때문인데요, 주로 함수형 언어에서 fold라는 이름을 많이 씁니다. 그런데 fold는 다시 방향에 따라서 foldl과 foldr로 나뉩니다. 왜 이런 이야기를 하냐면 Julia에는 reduce, foldl, foldr이 모두 존재하기 때문입니다.
표면적인 차이로는 foldl(), foldr()은 각각 연산의 방향에 따라 최적화되어 있다는 점입니다. 단순한 합계나 누적곱을 처리하는 것보다 좀 더 복잡하거나 특별한 자료 구조를 다룰 때에는 어떤 함수를 사용하는지가 중요하게 고민해야 하는 요소가 됩니다.
foldl(): 문자열 연결과 같이 앞에서부터 연산해야 하는 경우에 유리합니다. 특히 크기가 큰 데이터를 다룰 때 메모리를 효율적으로 사용할 수 있는 것으로 알려져 있습니다.foldr(): 함수의 배열에 대해서 순차적으로 적용하는 등 오른쪽부터 연산해야 하는 경우에 사용되며, 특히 지연 평가를 통해 원하는 만큼만 계산하기에 좋은 것으로 알려져 있습니다.reduce(): 단순 덧셈이나 곱셈과 같이 결합법칙을 적용할 수 있는, 즉 연산의 순서가 중요하지 않은 작업에 사용될 수 있습니다.
# foldl 예제 - 왼쪽에서 오른쪽으로
foldl((acc, x) -> "($acc + $x)", [1, 2, 3], init="0")
# "((0 + 1) + 2) + 3"
# foldr 예제 - 오른쪽에서 왼쪽으로
foldr((x, acc) -> "($x + $acc)", [1, 2, 3], init="0")
# "(1 + (2 + (3 + 0)))"
push! / pop!
push!()와 pop!()은 어떤 집합에 새로운 요소를 추가하거나, 마지막에 추가된 요소를 제거하는 함수입니다. 이름 뒤에 ! 기호가 붙는 것은 이 함수가 인자로 받는 객체의 내부를 변경한다는 의미입니다. (map()이나 filter()가 새로운 집합을 만드는 것과 대조적으로 이 함수들은 실제로 기존 집합에 원소를 더하거나 빼는 동작입니다.)
스택의 push, pop 동작처럼 배열의 맨 끝에서 원소를 추가하거나 제거합니다. 그 외에 몇 가지 variation이 존재하니 참고해두는 것이 좋습니다. 참고로 배열 중간에 요소를 삽입할 때에는 pushat!()이 아니라 insert!()를 사용합니다.
A = [1, 2, 3]
# 배열 끝에 추가
push!(A, 4) # A = [1, 2, 3, 4]
# 배열 끝에서 제거
pop!(A) # 4 반환, A = [1, 2, 3]
# 배열 앞에 추가
pushfirst!(A, 0) # A = [0, 1, 2, 3]
# 배열 앞에서 제거
popfirst!(A) # 0 반환, A = [1, 2, 3]
# 특정 위치의 요소 제거
popat!(A, 2) # 2 반환, A = [1, 3]
# 특정 위치에 요소 삽입
insert!(A, 2, 5) # A = [1, 5, 3]
주요 함수 목록:
push!(A, item): 배열 끝에 추가pop!(A): 배열 끝에서 제거pushfirst!(A, item): 배열 앞에 추가popfirst!(A): 배열 앞에서 제거popat!(A, index): 특정 위치의 요소 제거insert!(A, index, item): 특정 위치에 요소 삽입
first / last / findfirst / 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()가 있습니다. Julia의 벡터에는 Python의 index()와 같이 특정한 값이 몇 번째에 위치하는지를 알려주는 함수가 없으므로, findfirst(==(x), A)와 같은 식으로 사용할 수 있습니다. 비교 연산자를 함수적으로 쓰면서 인자를 하나만 전달하면 부분 적용 함수가 된다는 사실을 다시 한번 상기하도록 합시다.
A = [1, 5, 2, 8, 4, 6]
# 짝수인 첫 번째 요소의 인덱스
findfirst(iseven, A)
# -> 3
# 값이 8인 요소의 인덱스
findfirst(==(8), A)
# -> 4
# 문자열에서 문자 찾기
findfirst('o', "hello world")
# -> 5
# 문자열에서 부분 문자열 찾기
findfirst("wo", "hello world")
# -> 7:8 (범위로 반환)
# 조건을 만족하는 마지막 요소 찾기
findlast(iseven, A)
# -> 5 (값 4의 인덱스)
연산자를 부분 적용 함수로 만들기
Base.Fix1(), Base.Fix2() 함수는 각각 이항 연산자의 첫 번째, 두 번째 인자를 고정하여, 주어진 연산자를 함수로 변환하는 데 사용될 수 있습니다. 비교 연산자가 아닌 이항 연산자들은 인자를 하나만 전달했을 때 부분 적용 함수가 되지 않으므로 이와 같은 방법을 사용할 수도 있습니다. 대신 클로저를 사용하는 방식이 더 간단하고 직관적으로 느껴질 수도 있을 것 같습니다.
# 첫 번째 인자를 5로 고정
add5 = Base.Fix1(+, 5)
# 다음과 같이 정의한 것과 같음
# add5 = x -> 5 + x
add5(3) == 8
# true
map(add5, [1, 2, 3])
# [6, 7, 8]
# 두 번째 인자를 2로 고정
divided_by_2 = Base.Fix2(/, 2)
# divided_by_2 = y -> y / 2
divided_by_2(10) == 5.0
# true
map(divided_by_2, [10, 20, 30])
# [5.0, 10.0, 15.0]
# 실용적인 예: 모든 요소에 특정 값을 곱하기
multiply_by_10 = Base.Fix1(*, 10)
map(multiply_by_10, [1, 2, 3, 4])
# [10, 20, 30, 40]
zip과 splat 연산자
zip() 함수는 여러 개의 배열의 각 원소들을 순번대로 묶어서 병렬로 순회하게 해주는 함수입니다. 이미 map() 함수는 첫 번째 인자가 여러 개의 인자를 받는 함수인 경우에는 그 인자의 개수만큼 배열을 받아서 zip() 함수가 하는 동작을 포함하고 있습니다만, 여전히 여러 개의 배열의 원소들을 동시에 처리할 때에는 편리하게 사용할 수 있습니다.
A = [1, 2, 3]
B = [4, 5, 6]
C = [7, 8, 9]
# zip으로 여러 배열 묶기
for (a, b, c) in zip(A, B, C)
println("$a, $b, $c")
end
# 1, 4, 7
# 2, 5, 8
# 3, 6, 9
# 배열의 배열로 변환
collect(zip(A, B))
# [(1, 4), (2, 5), (3, 6)]
splat 연산자 (…)
zip() 함수는 여러 배열의 각 원소들을 순서대로 짝지어 하나의 튜플로 만들어줍니다. 단 이 튜플을 함수에 전달하려는데, 그 함수가 튜플이 아닌 여러 인자를 받도록 디자인되어 있다면 인터페이스가 호환되지 않습니다. 이 경우에는 두 가지 해결책이 있습니다.
f(xs...)와 같이 튜플 뒤에...을 써서 튜플의 내용을 언패킹하여 인자의 목록으로 변환하여 넘겨주는 방법이 있습니다.- 다른 방법으로는
splat()함수가 있습니다. 이 함수는 여러 인자를 받는 함수를 튜플 하나를 받는 함수로 변환해주는 함수입니다.
# 여러 인자를 받는 함수
function add_three(x, y, z)
return x + y + z
end
# 튜플을 언패킹하여 전달
tuple_args = (1, 2, 3)
add_three(tuple_args...)
# 6
# splat() 함수를 사용한 변환
add_three_from_tuple = splat(add_three)
add_three_from_tuple((1, 2, 3))
# 6
# map과 함께 사용
pairs = [(1, 2, 3), (4, 5, 6), (7, 8, 9)]
map(splat(add_three), pairs)
# [6, 15, 24]
# 같은 결과를 splat 연산자로
map(p -> add_three(p...), pairs)
# [6, 15, 24]
xs...와 같이 사용되는 ...을 splat 연산자라고 하는데, 튜플뿐만 아니라 다른 집합에 대해서도 적용이 가능합니다. 예를 들어 두 개의 1차원 벡터는 다음과 같이 하나로 합칠 수 있습니다.
A = [1, 2, 3]
B = [7, 8, 9]
[A..., B...]
# [1, 2, 3, 7, 8, 9]
# vcat과 동일한 결과
vcat(A, B)
# [1, 2, 3, 7, 8, 9]
# 다른 요소와 함께 사용
[0, A..., 5, B..., 10]
# [0, 1, 2, 3, 5, 7, 8, 9, 10]
성능 팁: 경험상 배열을 합치는 경우에는 splat 연산자보다는
vcat()을 사용하는 편이 더 좋은 성능을 보입니다.
정리
Julia의 함수형 프로그래밍 기능들은 간결하고 표현력 있는 코드를 작성할 수 있게 해줍니다. 특히:
- 연산자를 함수로 사용할 수 있는 유연성
- 부분 적용과 고차 함수의 자연스러운 통합
- 브로드캐스팅을 통한 벡터화 연산
map(),filter(),reduce()등의 강력한 배열 처리 함수
이러한 기능들을 적절히 활용하면 더 읽기 쉽고 유지보수하기 좋은 코드를 작성할 수 있습니다.