Powered by AppSignal & Oban Pro

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

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

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

8.7 중첩된 딕셔너리 구조

여러 딕셔너리 타입 자료형을 사용해서 키와 값을 연결할 수 있는데, 딕셔너리 타입 역시 그 값이 될 수 있다. 예를 들어 버그 제보 시스템이 있다고 해보자. 버그 제보를 자료구조로 표현하면 다음과 같이 나타낼 수 있다.

defmodule Customer do
  defstruct name: "", company: ""
end

defmodule BugReport do
  defstruct owner: %Customer{}, details: "", severity: 1
end

간단한 버그 제고 구조체를 하나 생성해보자.

report = %BugReport{owner: %Customer{name: "Dave", company: "Pragmatic"}, details: "broken"}

구조체 owner 필드의 값은 Customer라는 다른 구조체다. 이렇게 중첩된 필드에도 온점(.)을 통해 접근할 수 있다.

report.owner.company
# "Pragmatic"

그런데 버그 제보자의 회사명이 틀렸다고 한다. Pragmatic이 아니라 PragProg여야 한다는 것이다. 한번 고쳐보자.

report = %BugReport{report | owner: %Customer{report.owner | company: "PragProg"}}

되긴 했지만, 코드가 참 못났다. 회사명 하나를 바꾸기 위해 owner 필드를 통째로 새 구조체로 바꿨고 코드는 길고 읽기 어려워졌다. 이런 방식을 사용하면 실수할 가능성도 높아진다. 다행스럽게도 엘릭서에는 중첩된 딕셔너리에 쉽게 접근하도록 해주는 함수들이 있다. 그중 하나인 put_in은 중첩된 자료구조 내에 값을 저장해준다.

put_in(report.owner.company, "PP")
# %BugReport{
#   owner: %Customer{name: "Dave", company: "PP"},
#   details: "broken",
#   severity: 1
# }

이 함수가 무슨 마법을 부리는 것은 아니다. 사실 그저 우리가 써야 했던 길고 긴 코드를 대신 만들어내는 매크로일 뿐이다. update_in 함수는 자료구조 내의 특정 값에 함수를 적용한다.

update_in(report.owner.name, &("Mr. " <> &1))
# %BugReport{
#   owner: %Customer{name: "Mr. Dave", company: "PragProg"},
#   details: "broken",
#   severity: 1
# }

get_inget_and_update_in이라는 함수도 있다. IEx로 제공되는 문서에서 필요한 정보를 모두 얻을 수 있다. 두 함수를 사용하면 중첩된 자료구조에서 값을 가져올 수 있다.

8.7.1 구조체가 아닌 경우의 데이터 접근

일반적인 맵이나 키워드 리스트에서 이 함수들을 사용하는 경우 아톰 키를 사용할 수도 있다.

report = %{owner: %{name: "Dave", company: "Pragmatic"}, serverity: 1}
# %{owner: %{name: "Dave", company: "Pragmatic"}, serverity: 1}
put_in(report[:owner][:company], "PragProg")
# %{owner: %{name: "Dave", company: "PragProg"}, serverity: 1}
update_in(report[:owner][:name], &("Mr. " <> &1))
# %{owner: %{name: "Mr. Dave", company: "Pragmatic"}, serverity: 1}

8.7.2 동적으로 (런타임)에 중첩된 자료구조에 접근하기

지금까지 본 중첩 데이터 접근 함수들은 사실 모두 매크로였다. 즉 컴파일 타임에 동작하며, 그로 인한 제약이 몇 가지 있다.

  • 함수 호출에 전달할 수 있는 키가 정적이다.
  • 키를 함수 파라미터로 전달할 수 없다.

이러한 제약들은 매크로가 컴파일 타임에 파라미터를 코드로 바꿔 쓰므로 자연스럽게 따라온 결과다. 제약을 없애기 위해 get_in put_in update_in get_and_update_in은 별도 파라미터로 키의 리스트를 받기도 한다. 이 방식은 내부적으로 매크로를 호출하지 않고 함수를 호출하므로 연산을 동적으로 수행한다.

매크로와 함수를 호출하는 파라미터는 각각 다음과 같다.

fn 매크로 호출 함수호출
get_in 없음 (딕셔너리, 키 리스트)
put_in (경로, 저장할 값) (딕셔너리, 키리스트, 저장할 값)
update_in (경로, 실행할 함수) (딕셔너리, 키리스트, 실행할 함수)
get_and_update_in (경로, 실행할 함수) (딕셔너리, 키리스트, 실행할 함수)

간단한 예제로 살펴보자.

nested = %{
  buttercup: %{
    actor: %{
      first: "Robin",
      last: "Wright"
    },
    role: "princess"
  },
  westley: %{
    actor: %{
      first: "Cary",
      # 오타
      last: "Elwis"
    },
    role: "farm boy"
  }
}

IO.inspect(get_in(nested, [:buttercup]))
# => %{actor: %{first: "Robin", last: "Wright"}, role: "princess"}
IO.inspect(get_in(nested, [:buttercup, :actor]))
# => %{first: "Robin", last: "Wright"}
IO.inspect(get_in(nested, [:buttercup, :actor, :first]))
# => "Robin"
IO.inspect(put_in(nested, [:westley, :actor, :last], "Elwes"))
# %{
#   buttercup: %{actor: %{first: "Robin", last: "Wright"}, role: "princess"},
#   westley: %{actor: %{first: "Cary", last: "Elwes"}, role: "farm boy"}
# }

동적 버전 get_inget_and_update_in을 사용할 떄 활용할 수 있는 트럭이 있다. 키 자리에 함수를 전달하면 함수가 실행되며 해당 자리에 맞는 값을 반환한다.

authors = [
  %{name: "Jose", language: "Elixir"},
  %{name: "Matz", language: "Ruby"},
  %{name: "Larry", language: "Perl"}
]

languages_with_an_r = fn :get, collection, next_fn ->
  for row <- collection do
    if String.contains?(row.language, "r") do
      next_fn.(row)
    end
  end
end

IO.inspect(get_in(authors, [languages_with_an_r, :name]))
# => ["Jose", nil, "Larry"]

Access 모듈

Access모듈에는 get_in get_and_update_in의 파라미터로 사용 가능한 함수 몇 가지가 미리 정의되어 있다. 이 함수들은 자료구조를 탐색할 때 원하는 항목을 찾아내는 일종의 필터 역할을 한다. all과 at 함수는 리스트에만 사용할 수 있다. all은 리스트의 모든 항목을 반환하며, at은 0번째부터 시작해 n번째 값을 반환한다.

cast = [
  %{
    character: "Buttercup",
    actor: %{
      first: "Robin",
      last: "Wright"
    },
    role: "princess"
  },
  %{
    character: "Westley",
    actor: %{
      first: "Cary",
      last: "Elwes"
    },
    role: "farm boy"
  }
]
IO.inspect(get_in(cast, [Access.all(), :character]))
# => ["Buttercup", "Westley"]
IO.inspect(get_in(cast, [Access.at(1), :role]))
# => "farm boy"
IO.inspect(
  get_and_update_in(cast, [Access.all(), :actor, :last], fn val -> {val, String.upcase(val)} end)
)

# => {["Wright", "Elwes"],
#  [
#    %{actor: %{first: "Robin", last: "WRIGHT"}, character: "Buttercup", role: "princess"},
#    %{actor: %{first: "Cary", last: "ELWES"}, character: "Westley", role: "farm boy"}
#  ]
# }

튜플에는 elem 함수를 사용할 수 있다.

cast = [
  %{
    character: "Buttercup",
    actor: {"Robin", "Wright"},
    role: "princess"
  },
  %{
    character: "Westley",
    actor: {"Cary", "Elwes"},
    role: "farm boy"
  }
]
IO.inspect(get_in(cast, [Access.all(), :actor, Access.elem(1)]))
IO.inspect(
  get_and_update_in(cast, [Access.all(), :actor, Access.elem(1)], fn val ->
    {val, String.reverse(val)}
  end)
)

딕셔너리 타입(맵, 구조체)에는 keykey! 함수를 사용할 수 있다.

cast = %{
  buttercup: %{
    actor: {"Robin", "Wright"},
    role: "princess"
  },
  westley: %{
    actor: {"Carey", "Elwes"},
    role: "farm boy"
  }
}
IO.inspect(get_in(cast, [Access.key(:westley), :actor, Access.elem(1)]))
IO.inspect(get_and_update_in(cast, [Access.key(:buttercup), :role], fn val -> {val, "Queen"} end))

마지막으로 Access.pop은 맵이나 키워드 리스트에서 특정 키가 있는 항목을 제거한다. 그리고 제거한 키에 저장되어 있던 값, 해당 항목을 제거한 나머지 데이터로 이루어진 튜플을 반환한다. 원본 디셔너리에 지정한 키가 존재하지 않으면 nil을 반환한다. 스택의 pop 액션과 이름은 같지만 전혀 관련 없다.

Access.pop(%{name: "Elixir", creator: "Valim"}, :name)
Access.pop([name: "Elixir", creater: "Valim"], :name)
Access.pop(%{name: "Elixir", creator: "Valim"}, :year)