Powered by AppSignal & Oban Pro

처음배우는 엘릭서 프로그래밍

처음배우는엘릭서프로그래밍/11-0.livemd

처음배우는 엘릭서 프로그래밍

11. 문자열 과 바이너리

이 장에서 살펴볼 내용은 다음과 같다.

  • 문자열과 문자열 리터럴
  • 문자 리스트(작은따옴표 리터럴)
  • 패턴매칭과 문자열 처리

그 동안 문자열을 사용하기는 했지만 자세히 살펴보지는 않았으니 이번 장에서 짚고 가도록 하자.

11.1 문자열 리터럴

엘릭서에는 두 종류의 문자열이 있다. 하나는 작은따옴표로 감싼것이고 다른 하나는 큰따옴표로 감싼것이다. 둘은 내부 구현이 매우 다르지만 공통점도 많다. 양쪽 모두 다음과 같은 특징이 있다.

  • UTF-8 인코딩된 문자를 저장할 수 있다.
  • 이스케이프 문자를 포함할 수 있다.
이스케이프문자 이스케이프문자
\a BEL (0x07)
\b BS (0x08)
\d DEL (0x7f)
\e ESC (0x1b)
\f FF (0x0c)
\n NL (0x0a)
\r CR (0x0d)
\s SP (0x20)
\t TAB (0x09)
\v VT (0x0b)
\uhhh 1~6자리 hex
\xhh 2자리 hex
  • #{...} 문법으로 엘릭서 표현식을 삽입할 수 있다.
name = "dave"
IO.puts "Hello, #{String.capitalize name}!"

11.1.1 히어독스

모든 문자열은 여러 줄로 늘어날 수 있다. 문자열을 출력할 때는 IO.putsIO.write라는 두 함수를 사용할 수 있는데, puts는 항상 줄바꿈을 추가한다. 이 절에서 여러 줄인 문자열을 출력할 때는 줄바꿈을 추가하지 않는 write 함수를 사용한다.

IO.puts("start")
IO.write("
  my
  string
")
IO.puts("end")

여러 줄 문자열의 앞뒤 줄바꿈, 줄간 위치한 줄의 들여쓰기가 그대로 유지되었다. 히어독스(heredocs) 표기법은 이러한 문제를 해결해준다. 다음처럼 문자열 구분자를 세 번 입력하고(‘’’ 또는 “””) 내용의 들여쓰기에 맞추어서 닫는 구분자를 넣어주면 된다.

IO.puts("start")

IO.write("""
my
string
""")

IO.puts("end")

히어독스는 함수나 모듈의 문서를 추가할 때 많이 사용한다.

11.1.2 시길

루비와 비슷하게, 엘릭서는 일부 리터럴에 대한 대체 문법을 제공한다. 그중 하나로, 4.2.5절에서 살펴봤듯이 ~r{...}로 정규식을 생성할 수 있다. 이와 같이 ~로 시작하는 리터럴을 엘릭서에서는 시길(sigil)이라 부른다.

시길은 틸드(~)로 시작하며 영문 대문자나 소문자 중 한 글자, 구분자로 싸인 내용, 부가적인 옵션이 차례로 이어진다. 구분자는 <...> {...} [...] (...) |...| /.../ "..." ... 등이 될 수 있다. 틸드 뒤에 오는 문자는 시길의 종류를 결정한다.

~C 이스케이프 또는 문자열 삽입을 수행하지 않는 문자 리스트
~c 이스케이프 및 문자열 삽입을 수행하는 문자 리스트로, 작은따옴표(') 문자열과 같음
~D yyyy-mm-dd 형식의 날짜로, 타입은 Date
~N yyyy-mm-dd hh:mm:ss[,ddd] 형식의 NaiveDateTime
~R 이스케이프 또는 문자열 삽입을 수행하지 않는 정규식
~r 이스케이프 및 문자열 삽입을 수행하는 정규식
~S 이스케이프 또는 문자열 삽입을 수행하지 않는 문자열
~s 이스케이프 및 문자열 삽입을 수행하지는 문자열로, 큰따옴표(") 문자열과 같음
~T hh:mm:ss[,dddd] 형식의 시간으로 타입은 Time
~U ISO-8601 형식의 날짜로, 타입은 DateTime
~W 공색 문자로 값을 구분하는 리스트로, 이스케이프 또는 문자열 삽입을 수행하지 않음
~w 공백 문자로 값을 구분하는 리스트로, 이스케이프 및 문자열 삽입을 수행함

다음은 다양한 구분자를 사용하 시길의 예시다.

IO.puts(~C[1\n2#{1+2}])
IO.puts(~c"1\n2#{1 + 2}")
IO.puts(~S[1\n2#{1+2}])
IO.puts(~s/1\n2#{1 + 2}/)
IO.inspect(~W[the c#{'a'}t sat on the mat])
IO.inspect(~w[the c#{~c"a"}t sat on the mat])
IO.puts(~D<1999-12-31>)
IO.puts(~T[12:34:56])
IO.puts(~N{1999-12-31 23:59:59})

~W~w 시길은 옵션으로 a, c, s를 받는다. 이 옵션은 값을 아톰, 문자 리스트, 문자열 중 무엇으로 반환할지를 결정한다. ~r 시길의 옵션은 정규식에서 살펴봤다.

내용을 감싸는 구분자는 ‘단어가 아닌(noword)’ 문자라면 무엇이든 괜찮다. 괄호 종휴 ( [ { < 로 시작했다면 그에 대응하는 닫힘 문자를 종료 구분자로 사용해야 한다. 구분자가 활호가 아니라면 이스케이프되지 않은 상태로 두 번째로 나오는 시작 구분자가 종료 구분자가 된다. 엘릭서는 구분자의 중첩을 확인하지 않는다. 따라서 ~s{a{b}와 같은 시길은 a{b라는 세글자 짜리 문자열이 된다. 만약 시작 구분자가 작은따옴표나 큰따옴표 세 개면, 이 시길은 히어독스로 취급된다.

IO.inspect(~w"""
the
cat
sat
""")

히어독스 시길에 옵션을 넣고 싶을 때도 (대부분 ~r을 사용할 때다) 마찬가지로 종료 구분자 뒤에 넣어주면 된다.

IO.inspect(~r"""
hello
"""i)

흥미록게도 시길을 직접 만들 수도 있다.

11.2 ‘문자열’이라는 이름

더 깊은 내용으로 들어가기 전에 설명할 것이 하나 있다. 다른 언어는 대부분 ‘cat’과 “cat”을 모두 문자열이라고 하며 이 책에서도 지금까지 그렇게 설명해왔다. 하지만 엘릭서에서는 조금다르다.

엘릭서에서는 관습적으로 큰따옴표 문자열만 ‘문자열’이라 부른다. 작은따옴표를 사용하는 경우는 ‘문자 리스트’라고 한다. 이는 상당히 중요하다. 작은 따옴표 문자열과 큰따옴표 문자열은 매우 다르며, 문자열을 받는 라이브러리는 큰따옴표 문자열에 대해서만 동작한다. 두 형식이 어떻게 다른지 조금 더 자세히 살펴보자.

11.3 작은따옴표 문자열은 문자 코드의 리스트다

작은따옴표 문자열은 내부적으로 정수의 리스트로 표현되는데, 이때 각 정숫값은 문자열의 유니코드 코드포인트다. 작은따옴표 문자열은 문자 리스트라고 부르는 이유가 바로 이 때문이다.

str = ~c"wombat"
IO.inspect(str)
IO.inspect(is_list(str))
IO.inspect(length(str))
IO.inspect(Enum.reverse(str))

조금 혼란스럽기는 하다. 'str'이 리스트라고 하는데, 그러면서도 마치 문자열인 것처럼 출력된다. 이렇게 동작하는 이유는 정수 리스트를 이루는 값이 모두 출력 가능한 문자의 코드포인트일 때 IEx가 리스트를 문자열로 출력하기 때문이다. 리스트를 만들어서 직접 확인해보자.

[67, 65, 84]

다양한 방법으로 문자 리스트의 내부 표현을 확인할 수 있다.

str = ~c"wombat"
:io.format("~w~n", [str])
List.to_tuple(str)
str ++ [0]

~w 포맷 문자열은 str이 얼랭 표준 문법에 따른 값(이 경우 정수 리스트)으로 출력되도록 강제한다. ~n은 줄바꿈을 의미한다. 마지막 줄에서는 널바이트를 리스트 끝에 넣어 새로운 문자 리스트를 만들었다. 이제 리스트에 있는 값을 모두 출력 가능하다고 간주할 수 없게 되었으므로 문자가 아닌 정수 코드포인트가 그대로 표시되었다.

이와 같이 문자 리스트가 출력 가능하지 않다고 판단되는 값을 포함하는 경우 문자열 대신 리스트를 보게된다.

[8706, 120, 47, 8706, 121]

문자 리스트 역시 리스트이므로 패턴 매칭과 List 모듈의 함수를 사용할 수 있다.

IO.inspect(~c"pole" ++ ~c"vault")
IO.inspect(~c"pole" -- ~c"vault")
IO.inspect(List.zip([~c"abc", ~c"123"]))
[head | tail] = ~c"cat"
IO.inspect(head)
IO.inspect(tail)
IO.inspect([head | tail])

‘cat’의 head 값이 왜 c가 아니고 99일까? 문자 리스트는 정수 문자 코드의 리스트이므로 각 항목은 숫자다. 따라서 소문자 c를 나타내는 문자 코드인 99가 출력된다.

한편 ?c 표기법을 사용해 문자 c의 코드를 얻을 수도 있다. 이 표기법은 문자 리스트에서 패턴 매칭을 사용해 정보를 추출할 때 유용하다. 다음 예제는 10진수가 담긴 문자 리스트를 받아 숫자로 파싱한다(부호는 있을 수도 있고 없을 수도 있다).

defmodule Parse do
  def number([?- | tail]), do: _number_digits(tail, 0) * -1
  def number([?+ | tail]), do: _number_digits(tail, 0)
  def number(str), do: _number_digits(str, 0)

  defp _number_digits([], value), do: value

  defp _number_digits([digit | tail], value)
       when digit in ~c"0123456789" do
    _number_digits(tail, value * 10 + digit - ?0)
  end

  defp _number_digits([non_digit | _], _) do
    raise "Invalid digit '#{[non_digit]}'"
  end
end
IO.inspect(Parse.number(~c"123"))
IO.inspect(Parse.number(~c"-123"))
IO.inspect(Parse.number(~c"+123"))
IO.inspect(Parse.number(~c"+9"))
IO.inspect(Parse.number(~c"+a"))

연습문제

11-1

작은따옴표 문자열을 받아서, 문자열이 출력 가능한 아스키 문자(공백부터 틸드(~)까지)만으로 이루어졌으면 true를 반환하는 함수를 만들어보자.

defmodule TestFn do
  def is_printable_ascii(str) do
    str
    |> String.codepoints()
    |> Enum.all?(fn ch -> ch |> String.to_charlist() |> hd() |> in_range?(32, 126) end)
  end

  defp in_range?(value, min, max) do
    min <= value and value <= max
  end
end

ExUnit.start()

defmodule Ex11_1_test do
  use ExUnit.Case

  test "1번문제" do
    target = ~c"test~"
    result = TestFn.is_printable_ascii(target)
    assert result = true
  end
end

11-2

파라미터로 받은 두 값이 서로 애너그램 관계이면 true를 반환하는 anagram?(word1, word2) 함수를 만들어보자.

11-3

IEx에서 다음과 같이 입력해보자

['cat' | 'dog']
# ['cat' | 100, 111, 103]

왜 ‘cat’은 문자열로 출력되고 ‘dog’은 숫자로 출력되었을까?

11-4 [어려움]

숫자 [+-*/] 숫자 형식의 문자 리스트를 받아 계산한 결과를 반환하는 함수를 만들어보자. 각 숫자는 부로를 포함하지 않는다.

calculate('123 + 27') # => 150