앞서서 Elixir의 기초 문법에 대해서 정리를 했으니, 이제는 수업시간에 다뤘던 실제 코드를 분석해보는 시간을 가져보겠다.
defmodule Lec1 do
@pi 3.14159
def fact(n) do
if n <= 0 do
1
else
n * fact(n - 1)
end
end
def fact2(n), do: fact2(n, 1)
defp fact2(n, acc) do
if n <= 0, do: acc, else: fact2(n - 1, n * acc)
end
def fact3(n) when n <= 0, do: 1
def fact3(n), do: n * fact3(n - 1)
def fizzbuzz1(n), do: fizzbuzz1(1, n)
def fizzbuzz1(i, n) when i > n, do: :ok
def fizzbuzz1(i, n) do
cond do
rem(i, 15) == 0 -> IO.puts("fizzbuzz")
rem(i, 5) == 0 -> IO.puts("buzz")
rem(i, 3) == 0 -> IO.puts("fizz")
true -> IO.puts(i)
end
fizzbuzz1(i + 1, n)
end
def area(shape) do
case shape do
{:square, width} -> width * width
{:circle, radius} -> @pi * radius * radius
{:rectangle, width, height} -> width * height
_ -> :unknown
end
end
end
여러 개의 모듈들로 구성이 되어 있고, 이제 코드를 하나하나 뜯어보도록 하겠다.
실행 방법
가장 기본적인 Elixir의 실행 방법 부터 알아보고 들어가보자.
내가 이 언어를 학습함에 있어서 실행 방법은 크게 2가지로 나눠볼 수 있겠다.
- Elixir 스크립트로 실행
- 터미널에서 elixir <filename>
- Iex에서 실행
- Iex(interactive Elixir)는 Elixir의 REPL(Read-Eval-Print Loop) 환경이다. 이를 실행한 후, c/1 함수를 사용해서 .ex 파일을 컴파일하고 로드할 수 있다.
- 예를 들어, lec01.ex 파일을 iex에서 로딩하고 fact(10) 함수를 호출하기 위해서는 아래와 같이 하면 된다.
- iex
- c("lec01.ex")
- Lec1.fact(10)
모듈이 뭔가?
모듈은 관련된 함수나 매크로를 그룹화해서 하나의 단위로 묶은 것이다. Elixir나 Erlang 같은 함수형 프로그래밍 언어에서는 모듈이 중요한 코드 조직화 도구다. 모듈을 사용하면 코드를 재사용할 수 있고, 관리하기도 쉬워진다.
예를 들어, 문자열을 다루는 여러 함수가 있다고 하자. 이 함수들을 StringUtils라는 이름의 모듈에 넣으면, 이 모듈을 불러와서 해당 함수들을 쉽게 사용할 수 있다.
defmodule StringUtils do
def reverse_string(str) do
# 문자열 뒤집기 로직
end
def capitalize_string(str) do
# 문자열 대문자로 만들기 로직
end
end
이렇게 하면 StringUtils.reverse_string("hello") 같은 형태로 모듈의 함수를 호출할 수 있다.
모듈은 코드의 논리적인 부분을 구분지어 줄 뿐만 아니라, 필요한 함수나 기능을 쉽게 찾을 수 있게 해주는 역할도 한다. 이는 코드의 가독성과 유지보수성을 높여준다.
defmodule
defmodule은 Elixir에서 새로운 모듈을 정의할 때 사용하는 키워드다. 모듈은 함수나 매크로 등을 그룹으로 묶어 놓은 코드 블록이며, defmodule을 이용해 이런 코드 블록에 이름을 붙일 수 있다. 이렇게 만든 모듈은 다른 Elixir 코드에서 호출해서 사용할 수 있다.
예를 들어, 간단한 모듈을 하나 만들어 보자.
defmodule MyModule do
def say_hello do
IO.puts "Hello, World!"
end
end
여기서 MyModule이라는 이름의 모듈을 만들고, 그 안에 say_hello라는 함수를 정의했다. 이제 이 say_hello 함수는 MyModule.say_hello() 형태로 호출해서 사용할 수 있다.
모듈은 Elixir 코드의 기본 단위로, 함수를 재사용 가능하게 만들거나 코드를 논리적으로 구조화하는 데 도움이 된다.
defmodule Lec1 do
@pi 3.14159
def fact(n) do
if n <= 0 do
1
else
n * fact(n - 1)
end
end
def fact2(n), do: fact2(n, 1)
defp fact2(n, acc) do
if n <= 0, do: acc, else: fact2(n - 1, n * acc)
end
def fact3(n) when n <= 0, do: 1
def fact3(n), do: n * fact3(n - 1)
def fizzbuzz1(n), do: fizzbuzz1(1, n)
def fizzbuzz1(i, n) when i > n, do: :ok
def fizzbuzz1(i, n) do
cond do
rem(i, 15) == 0 -> IO.puts("fizzbuzz")
rem(i, 5) == 0 -> IO.puts("buzz")
rem(i, 3) == 0 -> IO.puts("fizz")
true -> IO.puts(i)
end
fizzbuzz1(i + 1, n)
end
def area(shape) do
case shape do
{:square, width} -> width * width
{:circle, radius} -> @pi * radius * radius
{:rectangle, width, height} -> width * height
_ -> :unknown
end
end
end
즉, 위와 같이 코드를 선언하면, 코드의 body에 있는 것들은 전부 Lec1 이라는 모듈에 속하게 된다는 의미가 된다. 모듈은 defmodule를 이용해서 한 파일에 여러개 만들 수 있다. 하지만, 코드의 관리나 가독성 측면에서는 한 파일에 하나의 모듈만을 정의하는 것이 더 좋다고 한다.
@ 기호
이 기호는 모듈 속성을 선언하거나 사용할 때 사용한다. 이 모듈 속성은 모듈 내부에서 사용되는 상수나 설정 같은 것들을 정의할 때 주로 사용하게 된다. 쉽게 말해, @는 모듈 레벨에서 어떤 값이나 설정을 고정시키거나 메타데이터를 추가할 때 사용하는 것이다. 주로 아래의 경우에 사용된다고 보면 된다.
상수로서 사용
defmodule Example do
@my_const 42
def show_const do
IO.puts @my_const
end
end
컴파일 타임 설정
defmodule Example do
@env_var System.get_env("HOME")
def show_home do
IO.puts @env_var
end
end
함수 문서화
defmodule Example do
@doc """
이 함수는 두 숫자를 더한다.
"""
def add(a, b) do
a + b
end
end
def 키워드
def fact(n) do
if n <= 0 do
1
else
n * fact(n - 1)
end
end
def fact2(n), do: fact2(n, 1)
defp fact2(n, acc) do
if n <= 0, do: acc, else: fact2(n - 1, n * acc)
end
def fact3(n) when n <= 0, do: 1
def fact3(n), do: n * fact3(n - 1)
- def: 이 키워드는 public 함수를 정의할 때 사용한다. 이 함수는 모듈 바깥에서도 호출할 수 있다. 예를 들어 fact(n) 함수는 n의 팩토리얼을 계산하는 함수다. 여기서 do ... end 블록은 함수의 본문을 의미한다.
- do ... end: 이 블록은 함수의 본문이나 제어문(if, else 등)의 본문을 정의한다. if n <= 0 do 1 end은 n이 0 이하일 경우 1을 반환한다는 의미다.
- when: 이 키워드는 guard 문을 정의할 때 사용한다. guard 문은 함수가 실행될 수 있는 조건을 제한한다. 예를 들어 fact3(n) when n <= 0, do: 1에서 when n <= 0은 n이 0 이하일 때만 이 함수가 호출될 수 있음을 의미한다.
- defp: 이 키워드는 private 함수를 정의한다. 이 함수는 같은 모듈 내에서만 호출할 수 있다. fact2(n, acc)는 누적자(acc)를 사용해 팩토리얼을 계산한다.
- , do:: 이 형식은 한 줄로 함수를 정의할 때 사용한다. def fact2(n), do: fact2(n, 1)는 fact2(n) 함수가 fact2(n, 1)을 호출한다는 의미다.
fact, fact2, fact3은 모두 팩토리얼을 계산하지만, 구현 방식이나 성능 등이 다를 수 있다. 예를 들어, fact2는 꼬리 재귀를 사용해서 더 효율적인 실행이 가능하다.
여기서 주목할 점은 do 다음에 변수가 나오는 경우, 그 변수의 값이 해당 블록의 결과로 리턴이 된다는 점이다. 예를 들어,
if true do
x = 5
end
이 코드에서 if문은 true이기 때문에 do ... end 블록이 실행된다. 블록 내에서 x = 5가 실행되며, 이 블록의 결과로 5가 반환된다.
Elixir에서는 대부분의 코드 블록이 값을 반환하며, 이 반환값이 다른 곳에서 사용될 수 있다. 여기서 x = 5라는 표현식 자체가 5를 반환하기 때문에 해당 if 블록도 5를 반환한다.
Elixir에서 대입 연산자 =은 패턴 매칭 연산자이다. x = 5라고 하면 x라는 변수에 5를 매칭한다는 의미다. 이 과정에서 패턴 매칭이 성공하면 매칭에 사용된 값인 5가 반환된다.
간단히 말하면, x = 5는 x에 5를 저장하는 것뿐만 아니라 이 표현식 자체가 5라는 값을 가지게 된다. 그래서 이런 대입문을 사용하면 해당 값이 반환되어 다른 표현식이나 함수 등에서 사용될 수 있다.
fact2/1의 동작
fact2(n)이라는 함수를 호출하면 이 함수는 fact2(n, 1)을 다시 호출한다. 여기서 1은 acc의 초기값이다. 그리고 이 함수는 계속 자기 자신을 다시 호출한다. 매번 호출할 때마다 n 값은 줄고, acc 값은 커진다.
예를 들어 fact2(3)을 호출하면 아래와 같이 일어난다.
- fact2(3)를 호출하면, fact2(3, 1)로 이동한다. 여기서 acc는 1.
- fact2(2, 3)을 호출한다. 3은 이전 acc 값 1에 3을 곱한 것.
- fact2(1, 6)을 호출한다. 6은 이전 acc 값 3에 2를 곱한 것.
- fact2(0, 6)을 호출한다. 여기서 n이 0이므로 6을 결과로 돌려준다.
이렇게 acc는 매번 업데이트되면서 마지막에는 결과 값 6이 나오게 된다.
face3/1의 동작
def fact3(n) when n <= 0, do: 1
def fact3(n), do: n * fact3(n - 1)
얼핏 보면 fact3/1는 def로 두번 정의되는 것처럼 보인다. 하지만 첫번째 fact3/1는 guard문인 when이 있기 때문에 두개의 함수는 각각 다른 조건에 맞춰서 동작하게 된다.
when n <= 0이라는 조건이 붙은 fact3(n) 함수는 n이 0이거나 그보다 작을 때만 호출된다. 그 외의 경우에는 두 번째 fact3(n) 함수가 호출된다.
예를 들어, fact3(3)을 호출하면 두 번째 fact3(n) 함수가 실행되고 3 * fact3(2)를 계산한다. 이때 다시 fact3(2)가 호출되고 2 * fact3(1), 다시 fact3(1)이 호출되어 1 * fact3(0)을 계산한다. 이제 n이 0이 되었으니, 첫 번째 fact3(n) when n <= 0이 호출되어 1을 반환한다.
이렇게 두 함수는 n의 값에 따라 적절히 나뉘어 호출되기 때문에 중복되지 않는다.
fizzbuzz의 동작
def fizzbuzz1(n), do: fizzbuzz1(1, n)
def fizzbuzz1(i, n) when i > n, do: :ok
def fizzbuzz1(i, n) do
cond do
rem(i, 15) == 0 -> IO.puts("fizzbuzz")
rem(i, 5) == 0 -> IO.puts("buzz")
rem(i, 3) == 0 -> IO.puts("fizz")
true -> IO.puts(i)
end
fizzbuzz1(i + 1, n)
end
이 코드는 FizzBuzz 게임을 구현한 것이다. 1부터 n까지의 숫자를 출력하는데, 특정 조건에 따라 숫자 대신에 "fizz", "buzz", "fizzbuzz"를 출력한다.
- fizzbuzz1(n) 함수가 처음 호출되면, 이 함수는 fizzbuzz1(1, n)을 다시 호출한다. 여기서 1은 시작 숫자고, n은 끝 숫자다.
- fizzbuzz1(i, n) when i > n, do: :ok 함수는 i가 n보다 클 때 호출된다. 이 함수는 :ok라는 값을 반환하면서 프로그램을 종료한다.
- fizzbuzz1(i, n) 함수는 i가 n보다 작거나 같을 때 호출된다. cond do ... end 블록 안에서 아래 조건들을 검사한다:
- rem(i, 15) == 0: i가 15로 나누어 떨어지면 "fizzbuzz" 출력
- rem(i, 5) == 0: i가 5로 나누어 떨어지면 "buzz" 출력
- rem(i, 3) == 0: i가 3으로 나누어 떨어지면 "fizz" 출력
- true: 위의 모든 조건이 아니면 i 자체를 출력
이렇게 하나의 숫자 i에 대해 조건을 검사하고 출력한 후에는 i를 1 증가시켜 다시 fizzbuzz1(i, n)을 호출한다. 이 과정을 i가 n보다 클 때까지 반복한다.
cond do
cond do ... end는 Elixir에서 여러 조건을 검사할 때 사용하는 구문이다. if나 case와 같은 제어문이지만, 여러 개의 조건을 체크할 수 있어서 더 복잡한 로직을 간결하게 표현할 수 있다.
cond do 블록 내부에는 ->로 구분되는 키-값 쌍이 온다. 조건은 왼쪽에, 그 조건이 참일 때 실행될 코드는 오른쪽에 위치한다. cond do 블록은 위에서부터 아래로 실행되며, 참인 조건을 만나면 해당 코드를 실행하고 블록을 빠져나온다.
예를 들어:
cond do
1 + 1 == 3 -> "This won't match"
2 * 2 == 3 -> "This won't match either"
2 * 2 == 4 -> "This will match"
true -> "This would be executed if no earlier condition were true"
end
이 코드에서는 2 * 2 == 4 조건이 참이므로 "This will match"가 반환된다. 만약 어떤 조건도 참이 아니라면, true -> ... 부분이 실행된다. 이러한 경우를 대비해서 마지막에 true를 넣어주는 것이 일반적이다.
주의!! cond do는 각 조건이 독립적으로 평가되므로, 하나의 조건이 참이면 나머지는 검사하지 않는다. 이런 이유로 조건의 순서가 중요할 수 있다.
rem
rem은 Elixir에서 나머지를 계산하는 연산자다. 예를 들어, rem(10, 3)을 실행하면 결과는 1이다. 왜냐하면 10을 3으로 나누었을 때 나머지가 1이기 때문이다.
rem은 주로 나머지가 특정 값인지 확인할 때 사용한다. 예를 들어, 어떤 수가 짝수인지 홀수인지 확인하려면 rem(n, 2)를 사용하면 된다. 만약 결과가 0이면 짝수, 그렇지 않으면 홀수다.
rem을 이용한 예시:
- rem(10, 3)은 1을 반환한다.
- rem(4, 2)은 0을 반환한다.
- rem(9, 4)은 1을 반환한다.
이렇게 rem은 나머지를 계산할 때 사용되며, 이는 수학, 알고리즘, 조건문 등 다양한 상황에서 유용하게 쓰인다.
IO.puts
IO.puts는 Elixir에서 표준 출력(일반적으로 콘솔이나 터미널)에 문자열을 출력하는 함수다. 이 함수는 주어진 문자열 뒤에 줄바꿈 문자(\n)를 추가해서 출력한다.
예를 들어, IO.puts("Hello, World!")를 실행하면 콘솔에 "Hello, World!"라는 문자열이 출력되고, 그 뒤에 줄이 바뀐다.
이 함수는 디버깅, 로깅, 사용자와의 상호작용 등 다양한 상황에서 사용될 수 있다. IO.puts는 :ok를 반환하며, 이는 출력이 성공적으로 이루어졌음을 나타낸다.
간단한 예시:
IO.puts("Hello, World!")
IO.puts("Another line.")
이 코드를 실행하면 다음과 같이 출력된다.
Hello, World!
Another line.
이렇게 IO.puts는 문자열을 콘솔에 출력하고 줄을 바꾸는 역할을 한다.
그 외에 자주 사용되는 IO 함수들
Elixir의 IO 모듈에는 여러 유용한 함수들이 있다. 일반적으로 많이 사용되는 몇 가지를 소개한다.
- IO.gets/1
- 사용자로부터 표준 입력을 받아 문자열로 반환한다. 예를 들어, IO.gets("Enter your name: ")을 실행하면 사용자로부터 이름을 입력받을 수 있다.
- IO.inspect/1
- 디버깅을 도와주는 함수로, 어떤 Elixir 값이든 받아 출력하고, 그 값을 그대로 반환한다. 이렇게 하면 데이터의 현재 상태를 쉽게 확인할 수 있다.
- IO.write/1
- IO.puts/1와 비슷하지만, 줄바꿈 문자(\n)를 추가하지 않는다.
- IO.read/2
- 표준 입력에서 문자나 문자열을 읽는다. 예를 들어, IO.read(:line)은 한 줄을 읽고, IO.read(:all)은 모든 입력을 읽는다.
- IO.binwrite/1
- 바이너리 데이터를 출력한다. 문자열과는 다르게 바이너리 데이터를 그대로 출력할 때 사용한다.
- IO.iodata_to_binary/1
- I/O 데이터를 바이너리 데이터로 변환한다. 주로 문자열과 리스트를 섞은 복잡한 I/O 데이터를 다룰 때 사용한다.
- IO.ANSI
- ANSI 코드를 다루는 서브모듈로, 콘솔에서 텍스트의 색상이나 스타일을 변경할 때 사용한다.
예시:
name = IO.gets("What is your name? ")
IO.puts("Hello, #{String.trim(name)}")
data = [1, 2, 3]
IO.inspect(data)
IO.write("No new line here")
IO.write(". See?")
input = IO.read(:line)
IO.puts("You entered: #{input}")
IO.binwrite(<< 1, 2, 3 >>)
이 함수들을 적절히 조합하면 표준 입출력을 통한 다양한 작업을 수행할 수 있다.
case do 문
def area(shape) do
case shape do
{:square, width} -> width * width
{:circle, radius} -> @pi * radius * radius
{:rectangle, width, height} -> width * height
_ -> :unknown
end
end
case 구문은 표현식의 값에 따라 분기를 하는 Elixir의 제어문이다. case 뒤에 오는 표현식의 결과값과 일치하는 패턴을 찾아 해당 블록을 실행한다. 예시로 들면:
x = 3
case x do
1 ->
"One"
2 ->
"Two"
3 ->
"Three"
_ ->
"Other"
end
여기서는 x가 3이므로 "Three"가 출력된다. _는 "기타 모든 값"을 의미하며, 일치하는 패턴이 없을 때 실행된다.
cond do와 비교
cond 구문은 주어진 조건 중 가장 먼저 true가 되는 것을 선택하여 해당 블록을 실행한다. if와 비슷하지만, 여러 조건을 한 번에 처리할 수 있다. 예를 들면:
x = 10
cond do
x < 5 ->
"Less than five"
x < 10 ->
"Less than ten"
x == 10 ->
"Is ten"
true ->
"Other"
end
여기서는 x가 10이므로 "Is ten"이 출력된다. 마지막의 true는 일치하는 조건이 없을 때 실행된다.
공통점
- case와 cond 둘 다 여러 분기를 제공한다.
- case와 cond 둘 다 각 분기에 해당하는 블록을 실행한다.
차이점
- case는 표현식의 결과값을 기반으로 분기한다. cond는 각 분기의 조건이 참인지 거짓인지를 기반으로 분기한다.
- case는 패턴 매칭을 사용한다. cond는 단순한 논리 표현식을 사용한다.
- case는 특정 값을 분석할 때 유용하다. cond는 여러 조건을 검사할 때 유용하다.
즉, 값에 따라 분기를 하려면 case를, 조건에 따라 분기를 하려면 cond를 사용한다.
Pattern matching by Case
case {1, 2, 3} do
{4, 5, 6} ->
"This will not match"
{1, x, 3} ->
"This will match and x would be 2"
_ ->
"This will match any value"
end
여기서 case 뒤의 튜플 {1, 2, 3}는 두 번째 패턴 {1, x, 3}과 일치한다. 그래서 "This will match and x would be 2"가 출력될 것이다. x는 2라는 값을 자동으로 매칭하고 할당한다.
이렇게 case를 사용하면 다양한 값과 패턴에 대한 분기 처리를 할 수 있다.
이 포스팅을 포함한 총 3개의 포스팅이 바로 어제 하루에 수업시간 중 다뤘던 내용들이다. 만약에 내가 이렇게 시간을 따로 내서 복습 겸 정리하는 시간을 갖지 않았을 경우, 나중에 final exam 때 가서 얼마나 개고생을 할지 빤히 보인다. 그래서 이렇게 정리하기로 결정하고 결행한 점은 정말 잘한 일이라 생각한다.
솔직히 모든 과목을 이렇게 정리하는 것은 불가능하다는 것을 깨달았다. 다만, 시험 준비를 하면서, 그리고 인터뷰 준비를 하면서 도움이 되겠다 싶은 과목들은 이렇게 꾸준하게 정리해 나가려 한다.
댓글 없음:
댓글 쓰기