Powered by AppSignal & Oban Pro

모듈과 기명 함수

livebooks/모듈과_기명_함수.livemd

모듈과 기명 함수

모듈 컴파일하기

예제 파일을 컴파일해서 IEx에 로드하는 방법은 두 가지가 있다. 첫 번째 방법은 iex를 실행하면서 불러올 엘릭서 소스 파일을 넣어주는 것이다.

$ iex times.exs

또는 이미 iex가 실행 중인 경우 c 헬퍼 함수를 이용해 명령줄로 나가지 않더라도 파일을 컴파일할 수 있다.

iex> c "times.exs"

함수의 본문은 블록이다.

do...end 블록은 여러 줄의 표현식을 한데 묶어 다른 코드로 전달하는 한 가지 방법이다. 하지만 do..end는 엘릭서의 기본 문법이 아닌 개발자의 편의를 위한 문법이다.

defmodule Times do
  def double(n), do: n * 2
end
defmodule InlineTimes, do: def(double(n), do: n * 2)

따라서 위 두 코드는 동일하게 평가되지만, 후자의 경우 추천하지 않는 방식이다.

함수 호출과 패턴 매칭

앞서 익명 함수를 알아보면서 파라미터를 실제 인자에 바인딩할 때 패턴 매칭이 어떻게 적용되는지 살펴봤고, 기명 함수에도 같은 규칙이 적용된다. 다른 점은 기명 함수의 경우 여러 구현을 정의할 때 각각의 파라미터와 본문을 가진 함수를 따로 작성해야 한다는 점이다.

defmodule Factorial do
  def of(0), do: 1
  def of(n), do: n * of(n - 1)
end

Factorial.of(10)

위 코드처럼 엘릭서에서 재귀를 이용해 설계하고 코딩하는 패턴은 매우 일반적이지만, 정의를 옮길 때 어떤 순서로 쓰는지에 따라 결과가 달라질 수 있다. 엘릭서는 함수들에 대해 패턴 매칭을 수행할 때 코드의 위에서 아래 순서로 확인하기 때문에 주의해야 한다.

defmodule BadFactorial do
  def of(n), do: n * of(n - 1)
  def of(0), do: 1
end

BadFactorial의 경우 가장 처음 만나는 집합이 하위 집합을 모두 포함하기 때문에 엘릭서의 패턴 매칭은 다음 패턴에 매칭될 수 없다.

가드 조건절

엘릭서는 전달받은 인자를 이용한 패턴 매칭을 통해 어떤 함수를 실행할지를 결정한다. 하지만, 조건이나 인자의 타입에 따라 실행할 함수를 결정하고 싶다면 가드 조건절을 사용한다. 가드 조건절은 함수 정의부에 when 키워드를 붙일 수 있는 명제다. 이렇게 붙은 가드 조건절은 패턴 매칭을 수행할 때 조건이 참이어야만 함수가 실행된다.

defmodule Guard do
  def what_is(x) when is_number(x) do
    IO.puts("#{x} is a number")
  end

  def what_is(x) when is_list(x) do
    IO.puts("#{inspect(x)} is a list")
  end

  def what_is(x) when is_atom(x) do
    IO.puts("#{x} is an atom")
  end
end

Guard.what_is(99)
Guard.what_is(:cat)
Guard.what_is([1, 2, 3])

가드를 이용해 앞서 선언한 팩토리얼 함수를 좀 더 안전하게 작성할 수 있다.

defmodule SafeFactorial do
  def of(0), do: 1

  def of(n) when is_integer(n) and n > 0 do
    n * of(n - 1)
  end
end

SafeFactorial.of(-1)

이런 가드 조건절에는 엘릭서 표현식 중 일부만 사용할 수 있다.

  • 비교 연산자
  • 이진 및 부정 연산자
  • 산술 연산자
  • 연결 연산자
  • in 연산자
  • 타입 확인 함수

등 더 상세한 조건은 엘릭서 공식 사이트를 참고하자.

기본 파라미터

익명 함수와 마찬가지로 기명 함수에도 param \\ value 문법으로 기본값을 지정할 수 있다. 또 한 앞서 살펴본 규칙도 그래도 적용된다.

defmodule Example do
  def func(p1, p2 \\ 2, p3 \\ 3, p4) do
    IO.inspect([p1, p2, p3, p4])
  end
end

Example.func("a", "b")
Example.func("a", "b", "c")
Example.func("a", "b", "c", "d")

기본 파라미터를 정의한 함수를 패턴 매칭 시 신기한 동작을 보인다. 다음과 같은 코드를 작성하면 컴파일러는 에러를 출력한다.

def func(p1, p2 \\ 2, p3 \\ 3, p4) do
  IO.inspect([p1, p2, p3, p4])
end

def func(p1, p2) do
  IO.inspect([p1, p2])
end

에러가 출력되는 이유는 두 함수의 필수값이 2개로 동일하기 때문이다. 따라서 엘릭서는 어떤 함수와 매칭될지 결정할 수 없기 때문이다. 그리고 애매한 상황인 경우 엘릭서는 경고를 주게 된다.

defmodule DefaultParams1 do
  def func(p1, p2 \\ 123) do
    IO.inspect([p1, p2])
  end

  def func(p1, 99) do
    IO.puts("you said 99")
  end
end

만약 위 경우처럼 기본값이 필요한 상황이라면, 기본값이 정의된 함수를 본문 없이 정의하고, 나머지 함수를 기본값 없이 정의하면 된다. 그러면 나머지 모든 함수에 기본값이 적용된다.

defmodule Params do
  def func(p1, p2 \\ 123)

  def func(p1, p2) when is_list(p1) do
    "You said #{p2} with a list"
  end

  def func(p1, p2) do
    "You passed in #{p1} and #{p2}"
  end
end

IO.puts(Params.func(99))
IO.puts(Params.func(99, "cat"))
IO.puts(Params.func([99]))
IO.puts(Params.func([99], "dog"))

프라이빗 함수

객체지향 프로그래밍을 접한 개발자라면 함수의 가시성을 제한하는 것에 익숙해져 있다. 엘릭서도 함수의 가시성을 제한할 수 있는 프라이빗 함수를 정의할 수 있는 defp 키워드를 제공한다. 하지만 하나의 함수에 퍼블릭과 프라이빗이 공존할 수 없다.

defmodule BadPrivateFunc do
  def fun(a) when is_list(a), do: true
  defp fun(a), do: false
end

끝내주는 파이프 연산자: |>

파이프 연산자는 함수형 프로그래밍에서 가장 맛있는(?) 부분이다. 함수형이 아닌 언어에 익숙한 개발자는 다음 코드가 저 익숙할 수 있다.

people = DB.find_customers()
orders = Orders.for_customers(people)
tax = sales_tax(orders, 2018)
filing = prepare_filing(tax)

또는 더 심한 코드도 본 적이 있을 것이다.

filing = prepare_filing(sales_tax(Orders.for_customers(DB.find_customers()), 2018))

이런 형식의 코드는 정황하고, 또 읽기 어려운 코드가 나오기 쉽다. 하지만 파이프 연산자를 도입하면 다음과 같이 예쁜 코드가 나오게 된다.

filing =
  DB.find_customers()
  |> Orders.for_customers()
  |> sales_tax(2018)
  |> prepare_filing

앞서 살펴본 두 코드의 장점(위에서 아래로, 결과를 담아둘 변수를 매번 만들지 않는)을 모두 갖춘 코드가 완성된다. 이렇게 파이프 연산자는 위 실행 결과를 받아서 함수의 오른쪽 인자부터 채워 넣는 형태로 동작한다 따라서 위 sales_tax는 sales_tax(Orders.for_customers(...생략...), 2018) 형태가 된다. 또 다음과 같이 한 줄에 이어 쓸 수도 있다.

1..10 |> Enum.map(&(&1 * &1)) |> Enum.filter(&(&1 < 40))

이런식으로 파이프를 통해 코드를 작성하면 코드의 흐름을 깔끔하게 표현할 수 있고, 파이프의 구조 자체가 함수를 잘게 나눠야 하기 때문에 자연스럽게 좋은 코드를 작성할 수 있는 기회가 된다.

모듈

엘릭서에서 모듈은 정의한 것에 대한 네임 스페이스를 만들어준다. 대부분의 개발은 혼자 작성한 코드로 모든 것을 해결하는 행위가 아니라 여러 사람이 동시에 또는 이미 누군가 만들어 둔 코드를 재사용하는 경우가 필수이다. 이런 상황에서 함수의 이름이 중첩되는 경우 코드는 어떤 함수를 호출하는지 결정하지 못하게 된다. 이를 방지하기 위해 엘릭서는 모듈을 통해 네임스페이스를 만들어 함수 정의의 중첩을 방지한다.

defmodule Mod do
  def func1 do
    IO.puts("in func1")
  end

  def func2 do
    func1
    IO.puts("in func2")
  end
end

Mod.func1()
Mod.func2()

위 코드처럼 모듈은 defmodule 블록 안에 정의한 함수들을 동일한 공간에 정의하고, 함수를 호출하기 위해서 해당 모듈의 이름을 통해 접근할 수 있다. 알아야 할 점은 모듈 내 함수들 끼리는 같은 공간에 존재하기 때문에 동일한 공간의 함수를 호출하는 경우 모듈 이름을 붙이지 않아도 호출할 수 있지만, 그 외의 경우는 호출할 함수가 포함된 모듈을 작성해야한다.

defmodule Outer do
  defmodule Inner do
    def inner_func do
    end
  end

  def outer_func do
    Inner.inner_func()
  end
end

defmodule Outer.Inline do
  def inline_func do
  end
end

이런 모듈은 또 다른 모듈 또는 함수, 매크로, 구조체, 프로토콜 등이 포함될 수 있다. 하지만 자주 사용되는 모듈을 매번 입력하기는 개발자의 입장에서 상당히 번거로울 수 있다. 이럴 때 import 키워드를 사용해 현재 스코프로 불러와 사용할 수 있다.

defmodule ImportExample do
  def func1 do
    List.flatten([1, [2, 3], 4])
  end

  def func2 do
    import List, only: [flatten: 1]
    flatten([5, [6, 7], 8])
  end
end

import 키워드를 통해 특정 모듈에서 함수를 불러올 때는 함수명 : 인자 수 쌍의 리스트를 넣어주면 된다. 가능한 작은 스코프에서 import를 사용하고, only:를 이용해 필요한 함수만 가져오는 것을 권장한다.