함수
함수 (Functions)
Julia에서 함수는 인자 값들의 튜플을 반환 값에 매핑하는 객체예요. Julia 함수는 순수 수학적 함수는 아닙니다. 프로그램의 전역 상태를 바꿀 수도 있고 그 영향도 받을 수 있으니까요. Julia에서 함수를 정의하는 기본 문법은 이렇습니다.
julia> function f(x, y)
x + y
end
f (generic function with 1 method)
이 함수는 두 인자 x와 y를 받아, 마지막으로 평가된 표현식인 x + y의 값을 반환해요.
Julia에는 함수를 정의하는 더 간결한 두 번째 문법도 있어요. 위에서 보여준 전통적인 함수 선언 문법은 다음의 간결한 "할당 형식"과 동등합니다.
julia> f(x, y) = x + y
f (generic function with 1 method)
할당 형식에서 함수 본문은 단일 표현식이어야 해요. 다만 복합 표현식(Compound Expressions 참고)일 수는 있습니다. 짧고 단순한 함수 정의는 Julia에서 흔해요. 짧은 함수 문법은 그래서 꽤 관용적이고, 타이핑과 시각적 소음 둘 다 상당히 줄여 줍니다.
함수는 전통적인 괄호 문법으로 호출합니다.
julia> f(2, 3)
5
괄호 없이 f라는 표현식은 함수 객체를 가리키고, 다른 값처럼 주고받을 수 있어요.
julia> g = f;
julia> g(2, 3)
5
변수처럼 함수 이름에도 Unicode를 쓸 수 있습니다.
julia> ∑(x, y) = x + y
∑ (generic function with 1 method)
julia> ∑(2, 3)
5
출처: Julia 공식 문서: 함수
인자 전달 동작 (Argument Passing Behavior)
Julia 함수 인자는 때때로 "공유로 전달(pass-by-sharing)"이라고 불리는 관례를 따릅니다. 값이 함수에 전달될 때 복사되지 않는다는 뜻이에요. 함수 인자 자체는 새 변수 바인딩(값을 가리킬 수 있는 새 "이름")으로 동작하는데, [할당](../variables/ argument_name = argument_value)과 아주 비슷해요. 그래서 그것들이 가리키는 객체는 전달된 값과 동일합니다. 함수 안에서 이뤄진 가변 값(예: Array)의 수정은 호출자에게 보일 거예요. (이것은 Scheme, 대부분의 Lisp, Python, Ruby, Perl을 포함한 다른 동적 언어들에서도 찾을 수 있는 동일한 동작이에요.)
예를 들어 다음 함수에서
function f(x, y)
x[1] = 42 # mutates x
y = 7 + y # new binding for y, no mutation
return y
end
x[1] = 42 문은 객체 x를 변형하므로, 이 변경은 호출자가 이 인자로 넘긴 배열에서 보일 거예요. 반면 할당 y = 7 + y는 바인딩(이름) y가 새 값 7 + y를 가리키도록 바꾸지, y가 가리키던 원래 객체를 변형하지 않아요. 따라서 호출자가 넘긴 대응 인자는 바뀌지 않습니다. f(x, y)를 호출해 보면 알 수 있어요.
julia> a = [4, 5, 6]
3-element Vector{Int64}:
4
5
6
julia> b = 3
3
julia> f(a, b) # returns 7 + b == 10
10
julia> a # a[1] is changed to 42 by f
3-element Vector{Int64}:
42
5
6
julia> b # not changed
3
Julia의 흔한 관례(문법 요구 사항은 아님)로, 이런 함수는 f(x, y)보다는 f!(x, y)라고 이름 붙입니다. 호출 지점에서 인자 중 적어도 하나(보통 첫 번째)가 변형된다는 시각적 알림으로요.
변형 함수의 동작은 변형된 인자가 다른 인자와 메모리를 공유할 때, 즉 앨리어싱(aliasing)이라 불리는 상황(예: 하나가 다른 것의 뷰일 때)에서 예상치 못할 수 있어요. 함수 docstring이 앨리어싱이 기대한 결과를 낸다고 명시적으로 나타내지 않는 한, 그런 입력에서 올바른 동작을 보장하는 건 호출자의 책임입니다.
인자 타입 선언 (Argument-type declarations)
함수 인자의 타입은 인자 이름에 ::TypeName을 붙여 선언할 수 있어요. Julia의 타입 선언에서 평소 하듯이요. 예를 들어 다음 함수는 피보나치 수를 재귀적으로 계산합니다.
fib(n::Integer) = n ≤ 2 ? one(n) : fib(n-1) + fib(n-2)
::Integer 지정은 n이 추상 Integer 타입의 서브타입일 때만 이 함수를 호출할 수 있다는 뜻이에요.
인자 타입 선언은 보통 성능에 영향이 없습니다. 어떤 인자 타입이 (있다면) 선언되었든, Julia는 호출자가 넘긴 실제 인자 타입에 특화된 함수 버전을 컴파일하니까요. 예를 들어 fib(1) 호출은 Int 인자에 특별히 최적화된 fib의 특화 버전 컴파일을 촉발하고, 그 버전은 fib(7)이나 fib(15)가 호출되면 재사용됩니다. (인자 타입 선언이 추가 컴파일러 특화를 촉발할 수 있는 드문 예외가 있어요. Julia가 특화를 피하는 시점을 인지하세요를 참고.) Julia에서 인자 타입을 선언하는 가장 흔한 이유는 오히려 다음과 같습니다.
- 디스패치: 메서드에서 설명하듯이, 서로 다른 인자 타입에 대한 함수의 서로 다른 버전("메서드")을 둘 수 있고, 그 경우 인자 타입으로 어떤 인자에 어떤 구현이 호출되는지 결정합니다. 예를 들어 어떤
Number타입에든 동작하는 완전히 다른 알고리즘fib(x::Number) = ...을 Binet 공식으로 정수가 아닌 값까지 확장해 구현할 수 있어요. - 정확성: 함수가 특정 인자 타입에서만 올바른 결과를 반환할 때 타입 선언이 유용할 수 있어요. 예를 들어 인자 타입을 생략하고
fib(n) = n ≤ 2 ? one(n) : fib(n-1) + fib(n-2)라고 쓰면,fib(1.5)가 말이 안 되는 답1.0을 조용히 줄 거예요. - 명확성: 타입 선언은 기대되는 인자에 대한 일종의 문서로 기능할 수 있어요.
다만 인자 타입을 과도하게 제한하는 건 흔한 실수로, 함수의 적용 가능성을 불필요하게 제한하고 당신이 예상치 못한 상황에서 재사용되는 것을 막을 수 있어요. 예를 들어 위의 fib(n::Integer) 함수는 Int 인자(머신 정수)와 BigInt 임의 정밀도 정수(BigFloats and BigInts 참고) 모두에서 똑같이 잘 동작합니다. 피보나치 수는 기하급수적으로 빠르게 커져서 Int 같은 고정 정밀도 타입을 금방 오버플로우시키기 때문에 특히 유용하죠(오버플로우 동작 참고). 만약 함수를 fib(n::Int)로 선언했다면, BigInt에의 적용이 이유 없이 막혔을 거예요. 일반적으로 인자에는 가장 일반적인 적용 가능한 추상 타입을 쓰고, 의심스러우면 인자 타입을 생략하세요. 필요해지면 인자 타입 지정을 나중에 언제든 추가할 수 있고, 생략한다고 성능이나 기능을 희생하지 않아요.
return 키워드 (The return Keyword)
함수가 반환하는 값은 마지막으로 평가된 표현식의 값이며, 기본적으로 함수 정의 본문의 마지막 표현식이에요. 이전 절의 예시 함수 f에서는 이 값이 x + y 표현식의 값이죠. 대안으로 다른 많은 언어처럼 return 키워드는 함수가 즉시 반환하게 해 주는데, 반환될 값을 주는 표현식을 제공합니다.
function g(x, y)
return x * y
x + y
end
함수 정의는 인터랙티브 세션에 입력할 수 있으므로, 이 정의들을 쉽게 비교할 수 있어요.
julia> f(x, y) = x + y
f (generic function with 1 method)
julia> function g(x, y)
return x * y
x + y
end
g (generic function with 1 method)
julia> f(2, 3)
5
julia> g(2, 3)
6
물론 g 같은 순수 선형 함수 본문에서는 return 사용이 무의미해요. x + y 표현식은 절대 평가되지 않으니까요. 그냥 x * y를 함수의 마지막 표현식으로 만들고 return을 생략하면 됩니다. 하지만 다른 제어 흐름과 결합하면 return은 진짜로 유용해요. 여기 길이가 x와 y인 변을 가진 직각삼각형의 빗변 길이를 오버플로우를 피해 계산하는 함수가 있습니다.
julia> function hypot(x, y)
x = abs(x)
y = abs(y)
if x > y
r = y/x
return x*sqrt(1 + r*r)
end
if y == 0
return float(x)
end
r = x/y
return y*sqrt(1 + r*r)
end
hypot (generic function with 1 method)
julia> hypot(3, 4)
5.0
이 함수에는 반환 지점이 세 곳 있습니다. x와 y의 값에 따라 서로 다른 세 표현식의 값을 반환하죠. 마지막 줄의 return은 마지막 표현식이므로 생략할 수 있어요.
반환 타입 (Return type)
:: 연산자로 함수 선언에서 반환 타입을 지정할 수 있습니다. 이는 반환 값을 지정된 타입으로 변환해요.
julia> function g(x, y)::Int8
return x * y
end;
julia> typeof(g(1, 2))
Int8
이 함수는 x와 y의 타입과 무관하게 항상 Int8을 반환합니다. 반환 타입에 대한 자세한 내용은 타입 선언을 참고하세요.
반환 타입 선언은 Julia에서 거의 쓰이지 않아요. 일반적으로 Julia 컴파일러가 반환 타입을 자동으로 추론할 수 있는 "타입-안정적" 함수를 쓰는 편이 낫습니다. 자세한 내용은 성능 팁 장을 참고하세요.
nothing 반환하기 (Returning nothing)
값을 반환할 필요가 없는 함수(부수 효과 때문에만 쓰이는 함수)의 경우, Julia 관례는 nothing 값을 반환하는 거예요.
function printx(x)
println("x = $x")
return nothing
end
이것은 관례라는 의미에서, nothing은 Julia 키워드가 아니라 타입 Nothing의 싱글턴 객체일 뿐이에요. 또한 위 예시 printx 함수는 인위적이라는 걸 눈치챘을 거예요. println이 이미 nothing을 반환하므로 return 줄이 중복되니까요.
return nothing 표현식에는 두 가지 축약 형태가 가능해요. 한편으로 return 키워드는 암시적으로 nothing을 반환하므로 혼자 쓸 수 있습니다. 다른 한편으로 함수는 마지막으로 평가된 표현식을 암시적으로 반환하므로, nothing이 마지막 표현식일 때 혼자 쓸 수 있어요. return nothing 표현식을 return이나 nothing 혼자보다 선호하는지 여부는 코딩 스타일의 문제입니다.
연산자는 함수다 (Operators Are Functions)
Julia에서 대부분의 연산자는 특수 문법을 지원하는 함수일 뿐이에요. (예외는 &&와 ||처럼 특수 평가 의미를 가진 연산자입니다. 단락 평가가 그 연산자의 평가 전에 피연산자가 평가되지 않을 것을 요구하므로, 이런 연산자는 함수가 될 수 없죠.) 따라서 다른 함수와 마찬가지로 괄호로 감싼 인자 목록으로도 적용할 수 있어요.
julia> 1 + 2 + 3
6
julia> +(1, 2, 3)
6
중위 형식은 함수 적용 형식과 정확히 동등합니다. 실제로 전자는 내부적으로 함수 호출을 만들어 내도록 파싱돼요. 이는 또한 +와 * 같은 연산자를 다른 함수 값처럼 할당하고 주고받을 수 있다는 뜻입니다.
julia> f = +;
julia> f(1, 2, 3)
6
다만 f라는 이름 아래에서는 함수가 중위 표기를 지원하지 않아요.
특수 이름을 가진 연산자 (Operators With Special Names)
몇 가지 특수 표현은 이름이 분명하지 않은 함수에 대한 호출에 해당합니다. 그 목록은 다음과 같아요.
| 표현식 | 호출 |
|---|---|
[A B C ...] |
hcat |
[A; B; C; ...] |
vcat |
[A B; C D; ...] |
hvcat |
[A; B;; C; D;; ...] |
hvncat |
A' |
adjoint |
A[i] |
getindex |
A[i] = x |
setindex! |
A.n |
getproperty |
A.n = x |
setproperty! |
[A; B;; C; D;; ...]와 비슷하지만 ;가 두 개보다 많이 연속되는 표현식도 hvncat 호출에 해당한다는 점을 기억하세요.
익명 함수 (Anonymous Functions)
Julia의 함수는 일급 객체예요. 변수에 할당할 수 있고, 할당된 변수에서 표준 함수 호출 문법으로 호출할 수 있습니다. 인자로 쓸 수 있고 값으로 반환할 수도 있어요. 또한 이름을 붙이지 않고 익명으로 만들 수도 있는데, 다음 두 문법 중 하나로요.
julia> x -> x^2 + 2x - 1
#2 (generic function with 1 method)
julia> function (x)
x^2 + 2x - 1
end
#5 (generic function with 1 method)
각 문장은 인자 x 하나를 받아 그 값에서 다항식 x^2 + 2x - 1의 값을 반환하는 함수를 만듭니다. 결과가 일반 함수지만, 연속 번호에 기반한 컴파일러 생성 이름을 갖는다는 점을 주목하세요.
익명 함수의 주된 용도는 다른 함수를 인자로 받는 함수에 전달하는 거예요. 전형적인 예는 배열의 각 값에 함수를 적용하고 결과 값들을 담은 새 배열을 반환하는 map입니다.
julia> map(round, [1.2, 3.5, 1.7])
3-element Vector{Float64}:
1.0
4.0
2.0
map의 첫 번째 인자로 넘길 이름 있는 함수가 이미 존재한다면 문제없어요. 하지만 보통은 바로 쓸 수 있는 이름 있는 함수가 없죠. 그런 상황에서 익명 함수 구성은 이름 없이 일회용 함수 객체를 쉽게 만들 수 있게 해 줍니다.
julia> map(x -> x^2 + 2x - 1, [1, 3, -1])
3-element Vector{Int64}:
2
14
-2
여러 인자를 받는 익명 함수는 (x,y,z)->2x+y-z 문법으로 쓸 수 있어요.
익명 함수의 인자 타입 선언은 이름 있는 함수와 동일하게 동작합니다. 예를 들어 x::Integer->2x처럼요. 익명 함수의 반환 타입은 지정할 수 없습니다.
0-인자 익명 함수는 ()->2+2로 쓸 수 있어요. 인자가 없는 함수라는 생각이 이상해 보일 수 있지만, 결과를 (또는 해서는 안 되는) 미리 계산할 수 없는 경우에 유용해요. 예를 들어 Julia에는 현재 시간을 초 단위로 반환하는 0-인자 time 함수가 있습니다. 그래서 seconds = ()->round(Int, time())는 이 시간을 가장 가까운 정수로 반올림해 변수 seconds에 할당하는 익명 함수예요. 이 익명 함수가 seconds()로 호출될 때마다 현재 시간이 계산되어 반환됩니다.
튜플 (Tuples)
Julia에는 함수 인자와 반환 값과 밀접하게 관련된 튜플(tuple)이라는 내장 데이터 구조가 있어요. 튜플은 고정 길이 컨테이너로, 어떤 값이든 담을 수 있지만 수정할 수는 없습니다(불변). 튜플은 콤마와 괄호로 만들고, 인덱싱으로 접근할 수 있어요.
julia> (1, 1+1)
(1, 2)
julia> (1,)
(1,)
julia> x = (0.0, "hello", 6*7)
(0.0, "hello", 42)
julia> x[2]
"hello"
길이-1 튜플은 콤마를 붙여 (1,)로 써야 한다는 점을 주목하세요. (1)은 괄호로 감싼 값일 뿐이니까요. ()는 빈(길이-0) 튜플을 나타냅니다.
이름 있는 튜플 (Named Tuples)
튜플의 구성 요소는 선택적으로 이름을 붙일 수 있는데, 그 경우 이름 있는 튜플이 만들어져요.
julia> x = (a=2, b=1+2)
(a = 2, b = 3)
julia> x[1]
2
julia> x.a
2
이름 있는 튜플의 필드는 일반 인덱싱 문법(x[1]이나 x[:a])에 더해 점 문법(x.a)으로도 이름을 붙여 접근할 수 있습니다.
분해 할당과 다중 반환 값 (Destructuring Assignment and Multiple Return Values)
콤마로 구분된 변수 목록(선택적으로 괄호로 감싼)이 할당의 왼쪽에 올 수 있습니다. 오른쪽의 값은 각 변수에 차례로 순회하며 할당함으로써 분해돼요.
julia> (a, b, c) = 1:3
1:3
julia> b
2
오른쪽 값은 왼쪽의 변수 수만큼은 최소한 길어야 하는 반복자(반복 인터페이스 참고)여야 합니다(반복자의 초과 요소는 무시됩니다).
이것으로 함수에서 여러 값을 반환할 수 있어요. 튜플이나 다른 반복 가능한 값을 반환하면 됩니다. 예를 들어 다음 함수는 두 값을 반환합니다.
julia> function foo(a, b)
a+b, a*b
end
foo (generic function with 1 method)
반환 값을 어디에도 할당하지 않고 인터랙티브 세션에서 호출하면, 반환된 튜플을 볼 수 있어요.
julia> foo(2, 3)
(5, 6)
분해 할당은 각 값을 변수로 추출합니다.
julia> x, y = foo(2, 3)
(5, 6)
julia> x
5
julia> y
6
또 다른 흔한 용도는 변수 교환입니다.
julia> y, x = x, y
(5, 6)
julia> x
6
julia> y
5
반복자의 요소 중 일부만 필요하다면, 흔한 관례는 무시할 요소를 밑줄만으로 이루어진 변수 _에 할당하는 거예요(밑줄은 그 외에는 유효하지 않은 변수 이름입니다. 허용되는 변수 이름 참고).
julia> _, _, _, d = 1:10
1:10
julia> d
4
다른 유효한 왼쪽 표현식도 할당 목록의 요소로 쓸 수 있는데, 그러면 setindex!나 setproperty!를 호출하거나, 반복자의 개별 요소를 재귀적으로 분해합니다.
julia> X = zeros(3);
julia> X[1], (a, b) = (1, (2, 3))
(1, (2, 3))
julia> X
3-element Vector{Float64}:
1.0
0.0
0.0
julia> a
2
julia> b
3
할당과 함께 쓰는 ...는 Julia 1.6이 필요합니다.
할당 목록의 마지막 심볼에 ...가 붙으면(슬러핑이라 부름), 나머지 오른쪽 반복자 요소들의 컬렉션이나 지연 반복자가 할당됩니다.
julia> a, b... = "hello"
"hello"
julia> a
'h': ASCII/Unicode U+0068 (category Ll: Letter, lowercase)
julia> b
"ello"
julia> a, b... = Iterators.map(abs2, 1:4)
Base.Generator{UnitRange{Int64}, typeof(abs2)}(abs2, 1:4)
julia> a
1
julia> b
Base.Iterators.Rest{Base.Generator{UnitRange{Int64}, typeof(abs2)}, Int64}(Base.Generator{UnitRange{Int64}, typeof(abs2)}(abs2, 1:4), 1)
특정 반복자에 대한 정확한 처리와 커스터마이징은 Base.rest를 참고하세요.
할당의 마지막이 아닌 위치의 ...는 Julia 1.9이 필요합니다.
할당에서의 슬러핑은 다른 위치에서도 일어날 수 있어요. 다만 컬렉션의 끝을 슬러핑하는 것과 달리, 이 경우는 항상 eager(eager)입니다.
julia> a, b..., c = 1:5
1:5
julia> a
1
julia> b
3-element Vector{Int64}:
2
3
4
julia> c
5
julia> front..., tail = "Hi!"
"Hi!"
julia> front
"Hi"
julia> tail
'!': ASCII/Unicode U+0021 (category Po: Punctuation, other)
이것은 함수 Base.split_rest로 구현됩니다.
가변 인자 함수 정의의 경우 슬러핑은 여전히 마지막 위치에서만 허용된다는 점을 기억하세요. 다만 이는 단일 인자 분해에는 적용되지 않아요. 그건 메서드 디스패치에 영향을 주지 않으니까요.
julia> f(x..., y) = x
ERROR: syntax: invalid "..." on non-final argument
Stacktrace:
[...]
julia> f((x..., y)) = x
f (generic function with 1 method)
julia> f((1, 2, 3))
(1, 2)
속성 분해 (Property destructuring)
반복에 기반한 분해 대신, 할당의 오른쪽을 속성 이름으로도 분해할 수 있어요. 이는 NamedTuples의 문법을 따르는데, 왼쪽의 각 변수에 오른쪽 할당의 같은 이름을 가진 속성을 getproperty로 할당하는 방식으로 동작합니다.
julia> (; b, a) = (a=1, b=2, c=3)
(a = 1, b = 2, c = 3)
julia> a
1
julia> b
2
인자 분해 (Argument destructuring)
분해 기능은 함수 인자 안에서도 쓸 수 있어요. 함수 인자 이름을 심볼 대신 튜플(예: (x, y))로 쓰면, (x, y) = argument 할당이 자동으로 삽입됩니다.
julia> minmax(x, y) = (y < x) ? (y, x) : (x, y)
minmax (generic function with 1 method)
julia> gap((min, max)) = max - min
gap (generic function with 1 method)
julia> gap(minmax(10, 2))
8
gap의 정의에서 괄호가 한 겹 더 있는 걸 주목하세요. 그게 없으면 gap은 2-인자 함수가 되어 이 예시가 동작하지 않을 거예요.
마찬가지로 속성 분해도 함수 인자에 쓸 수 있습니다.
julia> foo((; x, y)) = x + y
foo (generic function with 1 method)
julia> foo((x=1, y=2))
3
julia> struct A
x
y
end
julia> foo(A(3, 4))
7
익명 함수에서 단일 인자를 분해하려면 추가 콤마가 필요해요.
julia> map(((x, y),) -> x + y, [(1, 2), (3, 4)])
2-element Vector{Int64}:
3
7
가변 인자 함수 (Varargs Functions)
임의 개수의 인자를 받는 함수를 작성할 수 있으면 종종 편리해요. 그런 함수는 전통적으로 "varargs" 함수라고 불리는데, "variable number of arguments(가변 인자 수)"의 줄임말이에요. 마지막 위치 인자 뒤에 줄임표를 붙이면 varargs 함수를 정의할 수 있습니다.
julia> bar(a, b, x...) = (a, b, x)
bar (generic function with 1 method)
변수 a와 b는 평소처럼 처음 두 인자 값에 바인딩되고, 변수 x는 bar에 처음 두 인자 뒤로 전달된 0개 이상의 값들의 반복 가능한 컬렉션에 바인딩됩니다.
julia> bar(1, 2)
(1, 2, ())
julia> bar(1, 2, 3)
(1, 2, (3,))
julia> bar(1, 2, 3, 4)
(1, 2, (3, 4))
julia> bar(1, 2, 3, 4, 5, 6)
(1, 2, (3, 4, 5, 6))
이 모든 경우에서 x는 bar에 전달된 뒤따르는 값들의 튜플에 바인딩됩니다.
가변 인자로 전달되는 값의 개수를 제약하는 것도 가능합니다. 이는 나중에 파라메트릭하게 제약된 Varargs 메서드에서 다룰 거예요.
반대로, 반복 가능한 컬렉션에 담긴 값들을 함수 호출에 개별 인자로 "스패트"하는 것도 편리할 때가 많아요. 이렇게 하려면 ...를 쓰는데, 이번에는 함수 호출에서 씁니다.
julia> x = (3, 4)
(3, 4)
julia> bar(1, 2, x...)
(1, 2, (3, 4))
이 경우 값들의 튜플이 가변 인자가 가는 바로 그 자리에 varargs 호출로 스플라이스됩니다. 하지만 꼭 그래야 하는 건 아니에요.
julia> x = (2, 3, 4)
(2, 3, 4)
julia> bar(1, x...)
(1, 2, (3, 4))
julia> x = (1, 2, 3, 4)
(1, 2, 3, 4)
julia> bar(x...)
(1, 2, (3, 4))
게다가 함수 호출에 스패트되는 반복 가능한 객체가 튜플일 필요도 없어요.
julia> x = [3, 4]
2-element Vector{Int64}:
3
4
julia> bar(1, 2, x...)
(1, 2, (3, 4))
julia> x = [1, 2, 3, 4]
4-element Vector{Int64}:
1
2
3
4
julia> bar(x...)
(1, 2, (3, 4))
또한 인자가 스패트되는 함수가 varargs 함수일 필요도 없습니다(보통은 그렇지만).
julia> baz(a, b) = a + b;
julia> args = [1, 2]
2-element Vector{Int64}:
1
2
julia> baz(args...)
3
julia> args = [1, 2, 3]
3-element Vector{Int64}:
1
2
3
julia> baz(args...)
ERROR: MethodError: no method matching baz(::Int64, ::Int64, ::Int64)
The function `baz` exists, but no method is defined for this combination of argument types.
Closest candidates are:
baz(::Any, ::Any)
@ Main none:1
Stacktrace:
[...]
보시다시피, 스패트된 컨테이너에 잘못된 개수의 요소가 있으면 함수 호출이 실패합니다. 인자가 너무 많이 명시적으로 주어진 경우와 똑같이요.
선택 인자 (Optional Arguments)
함수 인자에 합리적인 기본값을 제공하는 게 종종 가능해요. 그러면 호출할 때마다 모든 인자를 넘겨야 하는 수고를 줄일 수 있죠. 예를 들어 Dates 모듈의 Date(y, [m, d]) 함수는 주어진 연도 y, 월 m, 일 d에 대한 Date 타입을 만듭니다. 그런데 m과 d 인자는 선택 사항이고 기본값이 1이에요. 이 동작은 다음과 같이 간결하게 표현됩니다.
julia> using Dates
julia> function date(y::Int64, m::Int64=1, d::Int64=1)
err = Dates.validargs(Date, y, m, d)
err === nothing || throw(err)
return Date(Dates.UTD(Dates.totaldays(y, m, d)))
end
date (generic function with 3 methods)
이 정의가 타입 UTInstant{Day}의 인자 하나를 받는 Date 함수의 다른 메서드를 호출한다는 점을 주목하세요.
이 정의로 함수를 인자 1개, 2개 또는 3개로 호출할 수 있고, 인자를 1개나 2개만 지정하면 1이 자동으로 전달됩니다.
julia> date(2000, 12, 12)
2000-12-12
julia> date(2000, 12)
2000-12-01
julia> date(2000)
2000-01-01
선택 인자는 사실 서로 다른 인자 개수를 가진 여러 메서드 정의를 쓰기 위한 편리한 문법일 뿐이에요(선택 및 키워드 인자에 대한 참고 참고). date 함수 예시에 methods 함수를 호출해 확인할 수 있습니다.
julia> methods(date)
# 3 methods for generic function "date" from Main:
[1] date(y::Int64, m::Int64, d::Int64)
@ REPL[2]:1
[2] date(y::Int64, m::Int64)
@ REPL[2]:1
[3] date(y::Int64)
@ REPL[2]:1
키워드 인자 (Keyword Arguments)
어떤 함수는 많은 인자가 필요하거나 많은 동작을 가집니다. 그런 함수를 호출하는 방법을 기억하는 건 어려울 수 있어요. 키워드 인자는 인자를 위치로만이 아니라 이름으로 식별할 수 있게 해서, 이런 복잡한 인터페이스를 쉽게 사용하고 확장하게 만들어 줍니다.
예를 들어 선을 그리는 plot 함수를 생각해 보죠. 이 함수는 선 스타일, 너비, 색 등을 제어하는 많은 옵션을 가질 수 있어요. 키워드 인자를 받는다면, 가능한 호출은 plot(x, y, width=2)처럼 보일 수 있는데, 여기서는 선 너비만 지정하기로 골랐습니다. 이는 두 가지 목적을 제공해요. 호출이 더 읽기 쉬워지죠. 인자에 그 의미를 붙여 표시할 수 있으니까요. 또한 많은 인자 중 어떤 부분 집합이든 어떤 순서로든 전달할 수 있게 됩니다.
키워드 인자를 가진 함수는 시그니처에서 세미콜론을 사용해 정의합니다.
function plot(x, y; style="solid", width=1, color="black")
###
end
함수가 호출될 때 세미콜론은 선택 사항이에요. plot(x, y, width=2) 또는 plot(x, y; width=2) 중 어느 쪽으로도 호출할 수 있지만, 전자 스타일이 더 흔합니다. 명시적 세미콜론은 아래에서 설명하는 varargs나 계산된 키워드를 전달할 때만 필요해요.
키워드 인자의 기본값은 필요할 때만(대응하는 키워드 인자가 전달되지 않았을 때) 왼쪽에서 오른쪽 순서로 평가됩니다. 따라서 기본 표현식은 이전 키워드 인자를 참조할 수 있어요.
키워드 인자의 타입은 다음과 같이 명시적으로 만들 수 있습니다.
function f(; x::Int=1)
###
end
키워드 인자는 varargs 함수에서도 쓸 수 있습니다.
function plot(x...; style="solid")
###
end
추가 키워드 인자는 varargs 함수처럼 ...로 수집할 수 있습니다.
function f(x; y=0, kwargs...)
###
end
f 안에서 kwargs는 이름 있는 튜플에 대한 불변 키-값 반복자가 될 거예요. 이름 있는 튜플(Symbol 키를 가진 사전, 그리고 첫 값이 심볼인 2-값 컬렉션을 만드는 다른 반복자도)은 호출에서 세미콜론을 사용해 키워드 인자로 전달할 수 있습니다. 예: f(x, z=1; kwargs...).
메서드 정의에서 키워드 인자에 기본값이 할당되지 않으면 그것은 필수예요. 호출자가 값을 할당하지 않으면 UndefKeywordError 예외가 던져집니다.
function f(x; y)
###
end
f(3, y=5) # ok, y is assigned
f(3) # throws UndefKeywordError(:y)
또한 세미콜론 뒤에 key => value 표현식을 전달할 수도 있어요. 예를 들어 plot(x, y; :width => 2)는 plot(x, y, width=2)와 동등합니다. 이는 키워드 이름이 런타임에 계산되는 상황에서 유용해요.
세미콜론 뒤에 맨 식별자나 점 표현식이 오면, 키워드 인자 이름은 식별자나 필드 이름으로 암시됩니다. 예를 들어 plot(x, y; width)는 plot(x, y; width=width)와 동등하고, plot(x, y; options.width)는 plot(x, y; width=options.width)와 동등합니다.
키워드 인자의 특성상 같은 인자를 두 번 이상 지정하는 게 가능합니다. 예를 들어 plot(x, y; options..., width=2) 호출에서 options 구조가 width 값도 담고 있을 수 있어요. 그런 경우 가장 오른쪽의 발생이 우선합니다. 이 예시에서 width는 확실히 2 값을 가질 거예요. 다만 같은 키워드 인자를 여러 번 명시적으로 지정하는 것, 예를 들어 plot(x, y, width=2, width=3)은 허용되지 않고 구문 오류가 됩니다.
기본값의 평가 스코프 (Evaluation Scope of Default Values)
선택 인자와 키워드 인자의 기본 표현식이 평가될 때는 이전 인자들만 스코프에 있어요. 예를 들어 다음 정의가 주어지면
function f(x, a=b, b=1)
###
end
a=b의 b는 바깥 스코프의 b를 가리키지, 뒤에 오는 인자 b를 가리키지 않아요.
함수 인자를 위한 do-블록 문법 (Do-Block Syntax for Function Arguments)
함수를 다른 함수의 인자로 전달하는 건 강력한 기법이지만, 그 문법이 항상 편리하지는 않아요. 함수 인자가 여러 줄을 필요로 할 때 그런 호출은 특히 쓰기 어색합니다. 예를 들어 몇 가지 경우가 있는 함수에 map을 호출하는 경우를 생각해 보죠.
map(x->begin
if x < 0 && iseven(x)
return 0
elseif x == 0
return 1
else
return x
end
end,
[A, B, C])
Julia는 이 코드를 더 명확하게 다시 쓰기 위한 예약어 do를 제공합니다.
map([A, B, C]) do x
if x < 0 && iseven(x)
return 0
elseif x == 0
return 1
else
return x
end
end
do x 문법은 인자 x를 가진 익명 함수를 만들고, 그 익명 함수를 "바깥" 함수(이 예시에서는 map)의 첫 번째 인자로 전달합니다. 마찬가지로 do a,b는 2-인자 익명 함수를 만들 거예요. do (a,b)는 인자가 분해될 튜플인 1-인자 익명 함수를 만든다는 점을 주의하세요. 맨 do는 뒤에 오는 것이 () -> ... 형태의 익명 함수라고 선언합니다.
이 인자들이 어떻게 초기화되는지는 "바깥" 함수에 달려 있어요. 여기서 map은 x를 A, B, C로 차례로 설정하고 각각에 익명 함수를 호출하는데, map(func, [A, B, C]) 문법에서 일어나는 것과 똑같아요.
이 문법은 호출이 평범한 코드 블록처럼 보이므로, 함수로 언어를 효과적으로 확장하는 데 쓰기 쉽게 해 줍니다. map과는 상당히 다른 많은 가능한 용도가 있는데, 예를 들어 시스템 상태 관리 같은 것이죠. 예를 들어 열린 파일이 결국 닫히는 것을 보장하는 코드를 실행하는 open 버전이 있습니다.
open("outfile", "w") do io
write(io, data)
end
이것은 다음 정의로 이루어집니다.
function open(f::Function, args...)
io = open(args...)
try
f(io)
finally
close(io)
end
end
여기서 open은 먼저 쓰기 위해 파일을 열고, 결과 출력 스트림을 당신이 do ... end 블록에서 정의한 익명 함수에 전달합니다. 당신의 함수가 종료된 후 open은 함수가 정상적으로 종료됐든 예외를 던졌든 스트림이 제대로 닫히도록 보장합니다. (try/finally 구조는 제어 흐름에서 설명할 거예요.)
do 블록 문법으로는 사용자 함수의 인자가 어떻게 초기화되는지 알기 위해 문서나 구현을 확인하는 게 도움이 돼요.
do 블록은 다른 내부 함수처럼 둘러싸는 스코프의 변수를 "캡처"할 수 있습니다. 예를 들어 위 open...do 예시의 data 변수는 바깥 스코프에서 캡처된 거예요. 캡처된 변수는 성능 팁에서 논의한 것처럼 성능 문제를 만들 수 있습니다.
함수 합성과 파이핑 (Function composition and piping)
Julia의 함수는 합성하거나 파이핑(체이닝)해서 결합할 수 있어요.
함수 합성은 함수들을 결합하고 그 결과 합성을 인자에 적용하는 것이에요. 함수 합성 연산자(∘)로 함수를 합성하므로, (f ∘ g)(args...; kw...)는 f(g(args...; kw...))와 같습니다.
합성 연산자는 REPL과 적절히 구성된 편집기에서 \circ<tab>으로 입력할 수 있어요.
예를 들어 sqrt와 + 함수는 이렇게 합성할 수 있습니다.
julia> (sqrt ∘ +)(3, 6)
3.0
이것은 먼저 숫자들을 더한 다음 결과의 제곱근을 구합니다.
다음 예시는 함수 세 개를 합성하고 문자열 배열에 그 결과를 매핑합니다.
julia> map(first ∘ reverse ∘ uppercase, split("you can compose functions like this"))
6-element Vector{Char}:
'U': ASCII/Unicode U+0055 (category Lu: Letter, uppercase)
'N': ASCII/Unicode U+004E (category Lu: Letter, uppercase)
'E': ASCII/Unicode U+0045 (category Lu: Letter, uppercase)
'S': ASCII/Unicode U+0053 (category Lu: Letter, uppercase)
'E': ASCII/Unicode U+0045 (category Lu: Letter, uppercase)
'S': ASCII/Unicode U+0053 (category Lu: Letter, uppercase)
함수 체이닝(때로는 데이터를 후속 함수로 보내는 "파이핑" 또는 "파이프 사용"이라 부름)은 함수를 이전 함수의 출력에 적용하는 것이에요.
julia> 1:10 |> sum |> sqrt
7.416198487095663
여기서 sum이 만든 합계가 sqrt 함수에 전달됩니다. 동등한 합성은 이렇습니다.
julia> (sqrt ∘ sum)(1:10)
7.416198487095663
파이프 연산자는 브로드캐스팅과 함께 .|>로 쓸 수도 있는데, 체이닝/파이핑과 점 벡터화 문법(아래 설명)의 유용한 결합을 제공합니다.
julia> ["a", "list", "of", "strings"] .|> [uppercase, reverse, titlecase, length]
4-element Vector{Any}:
"A"
"tsil"
"Of"
7
파이프를 익명 함수와 결합할 때는, 뒤따르는 파이프가 익명 함수 본문의 일부로 파싱되지 않게 하려면 괄호를 사용해야 합니다. 비교해 보죠.
julia> 1:3 .|> (x -> x^2) |> sum |> sqrt
3.7416573867739413
julia> 1:3 .|> x -> x^2 |> sum |> sqrt
3-element Vector{Float64}:
1.0
2.0
3.0
함수 벡터화를 위한 점 문법 (Dot Syntax for Vectorizing Functions)
기술 컴퓨팅 언어에는 함수의 "벡터화된" 버전이 흔합니다. 이는 주어진 함수 f(x)를 배열 A의 각 요소에 적용해서 f(A)를 통해 새 배열을 만드는 것이죠. 이런 문법은 데이터 처리에 편리하지만, 다른 언어에서 벡터화는 종종 성능을 위해서도 요구됩니다. 루프가 느리면 함수의 "벡터화된" 버전이 저수준 언어로 작성된 빠른 라이브러리 코드를 호출할 수 있으니까요. Julia에서 벡터화된 함수는 성능을 위해 필요하지 않습니다. 실제로 직접 루프를 쓰는 게 종종 유익하지만(성능 팁), 그래도 벡터화는 편리할 수 있어요. 따라서 어떤 Julia 함수 f든 f.(A) 문법으로 어떤 배열(또는 다른 컬렉션)에 요소별로 적용할 수 있습니다. 예를 들어 sin을 벡터 A의 모든 요소에 이렇게 적용할 수 있어요.
julia> A = [1.0, 2.0, 3.0]
3-element Vector{Float64}:
1.0
2.0
3.0
julia> sin.(A)
3-element Vector{Float64}:
0.8414709848078965
0.9092974268256817
0.1411200080598672
물론 f의 특화된 "벡터" 메서드, 예를 들어 f(A::AbstractArray) = map(f, A)를 써서 점을 생략할 수도 있고, 이는 f.(A)만큼 효율적입니다. f.(A) 문법의 장점은 어떤 함수가 벡터화 가능한지 라이브러리 작성자가 미리 결정할 필요가 없다는 거예요.
더 일반적으로, f.(args...)는 실제로 broadcast(f, args...)와 동등합니다. 이는 여러 배열(심지어 다른 모양이어도)이나 배열과 스칼라의 혼합에 대해 연산할 수 있게 해 줍니다(브로드캐스팅 참고). 예를 들어 f(x, y) = 3x + 4y가 있다면, f.(pi, A)는 A의 각 a에 대해 f(pi,a)로 이루어진 새 배열을 반환하고, f.(vector1, vector2)는 각 인덱스 i에 대해 f(vector1[i], vector2[i])로 이루어진 새 벡터를 반환합니다(벡터 길이가 다르면 예외가 던져져요).
julia> f(x, y) = 3x + 4y;
julia> A = [1.0, 2.0, 3.0];
julia> B = [4.0, 5.0, 6.0];
julia> f.(pi, A)
3-element Vector{Float64}:
13.42477796076938
17.42477796076938
21.42477796076938
julia> f.(A, B)
3-element Vector{Float64}:
19.0
26.0
33.0
키워드 인자는 브로드캐스팅되지 않고, 단순히 함수의 각 호출에 그대로 전달됩니다. 예를 들어 round.(x, digits=3)은 broadcast(x -> round(x, digits=3), x)와 동등합니다.
게다가 중첩된 f.(args...) 호출은 단일 broadcast 루프로 융합됩니다. 예를 들어 sin.(cos.(X))는 broadcast(x -> sin(cos(x)), X)와 동등하고, [sin(cos(x)) for x in X]와 비슷합니다. X에 대한 루프가 하나뿐이고 결과를 위해 배열 하나만 할당됩니다. [대조적으로, 전형적인 "벡터화된" 언어의 sin(cos(X))는 먼저 tmp=cos(X)를 위한 임시 배열 하나를 할당한 다음, 별도의 루프에서 sin(tmp)를 계산해 두 번째 배열을 할당합니다.] 이 루프 융합은 있을 수도 없을 수도 있는 컴파일러 최적화가 아니라, 중첩된 f.(args...) 호출을 만날 때마다 보장되는 문법적 약속이에요. 기술적으로 융합은 "점이 아닌" 함수 호출을 만나는 즉시 멈춥니다. 예를 들어 sin.(sort(cos.(X)))에서 sin과 cos 루프는 중간에 끼인 sort 함수 때문에 병합될 수 없어요.
마지막으로, 벡터화된 연산의 출력 배열을 미리 할당하면 최대 효율을 얻는 것이 보통입니다. 그래야 반복 호출이 결과를 위해 새 배열을 계속 할당하지 않으니까요(출력 미리 할당 참고). 편리한 문법은 X .= ...인데, 위에서처럼 broadcast! 루프가 중첩된 "점" 호출과 융합된다는 점을 빼면 broadcast!(identity, X, ...)와 동등합니다. 예를 들어 X .= sin.(Y)는 broadcast!(sin, X, Y)와 동등해서, X를 sin.(Y)로 제자리에서 덮어씁니다. 왼쪽이 배열 인덱싱 표현식이라면, 예를 들어 X[begin+1:end] .= sin.(Y)라면, view에 대한 broadcast!, 예를 들어 broadcast!(sin, view(X, firstindex(X)+1:lastindex(X)), Y)로 변환되어 왼쪽이 제자리에서 갱신됩니다.
표현식의 많은 연산과 함수 호출에 점을 추가하는 건 지루하고 읽기 어려운 코드로 이어질 수 있으므로, @. 매크로가 제공되어 표현식의 모든 함수 호출, 연산, 할당을 "점 표시" 버전으로 변환해 줍니다.
julia> Y = [1.0, 2.0, 3.0, 4.0];
julia> X = similar(Y); # pre-allocate output array
julia> @. X = sin(cos(Y)) # equivalent to X .= sin.(cos.(Y))
4-element Vector{Float64}:
0.5143952585235492
-0.4042391538522658
-0.8360218615377305
-0.6080830096407656
.+ 같은 이항(또는 단항) 연산자는 같은 메커니즘으로 처리됩니다. 그들은 broadcast 호출과 동등하고 다른 중첩 "점" 호출과 융합됩니다. X .+= Y 등은 X .= X .+ Y와 동등하고 융합된 제자리 할당을 결과로 줍니다. 점 연산자도 참고하세요.
점 연산과 함수 체이닝을 |>로 결합할 수도 있는데, 이 예시처럼요.
julia> 1:5 .|> [x->x^2, inv, x->2*x, -, isodd]
5-element Vector{Real}:
1
0.5
6
-4
true
융합된 브로드캐스트의 모든 함수는 결과의 모든 요소에 대해 항상 호출됩니다. 따라서 X .+ σ .* randn.()는 독립적이고 동일하게 샘플링된 난수 값들의 마스크를 배열 X의 각 요소에 더하지만, X .+ σ .* randn()은 같은 난수 샘플을 각 요소에 더할 거예요. 융합된 계산이 브로드캐스트 반복의 축 하나 이상을 따라 상수인 경우, 공간-시간 트레이드오프를 활용하고 중간 값을 할당해 계산 수를 줄일 수 있습니다. 성능 팁에서 더 보세요.
더 읽어보기 (Further Reading)
여기서 언급할 것은, 이것이 함수 정의의 완전한 그림과는 거리가 멀다는 점이에요. Julia는 정교한 타입 시스템을 갖고 있고 인자 타입에 대한 다중 디스패치를 허용합니다. 여기 주어진 예시 중 어느 것도 인자에 타입 어노테이션을 제공하지 않는데, 이는 모든 타입의 인자에 적용 가능하다는 뜻이에요. 타입 시스템은 타입에서, 런타임 인자 타입에 대한 다중 디스패치로 선택된 메서드로 함수를 정의하는 방법은 메서드에서 설명합니다.
더 알아보기 (Learn more)
- 타입 — Julia의 타입 시스템
- 메서드 — 다중 디스패치와 메서드 정의
- 제어 흐름 —
return,try/finally, 단락 평가 - Julia 공식 문서: 함수