追記:マップ

この章について

こんにちは。しばらくぶりです。これは追加の章で、Learn You Some Erlang for great good!の印刷版にはまだ含まれていません。なぜこれが存在するのでしょうか?印刷された本は、ひどい情報の山だったのでしょうか?そうではないことを願っています(ただし、正誤表を参照して、どれほど間違っているかを確認できます)。

この章が存在する理由は、R17リリースでErlangのデータ型の種類が少し増え、新しい型を組み込むために全体を書き直すのは大変な作業になるからです。R17リリースには多くの変更が含まれており、ここ数年で最大のリリースの1つです。実際、R17リリースは17.0リリースと呼ばれるべきです。なぜなら、Erlang/OTPはその後、R<メジャー>B<マイナー>のバージョン管理方式から別のバージョン管理方式に変更したからです。

いずれにせよ、これまでの言語のアップデートと同様に、ウェブサイト全体を最新の状態に保つために、あちこちにメモとコンテンツを追加しました。しかし、新たに追加されたデータ型は、それ自体で1つの章を割くに値します。

A weird road-runner representing EEP-43

イェーイ、イェーイ!

言語にマップを追加するまでの道のりは、長く曲がりくねったものでした。何年もの間、Erlangコミュニティでは、レコードとキー/バリューデータ構造について、さまざまな面で不満の声が上がっていました。

最終的に、2つの競合する提案が出されました。1つはRichard O'Keefeのフレームで、レコードを置き換える方法について深く考え抜かれた提案です。もう1つはJoe ArmstrongのStructsです(O'Keefeの提案では、Structsについて簡単に説明し、フレームと比較しています)。

その後、OTPチームはErlang Enhancement Proposal 43 (EEP-43)を提案しました。これは、「類似した」データ型に関する独自の提案です。しかし、コミュニティの不満と同様に、2つの提案は全く異なる問題に取り組んでいました。マップは、dictgb_treesを置き換える非常に柔軟なハッシュマップとなることを意図しており、フレームはレコードを置き換えることを意図しています。マップは理想的には、レコードの肯定的な特性を維持しながら(メキシカンスタンドオフで説明します)、一部のユースケースではレコードを置き換えることができますが、すべてではありません。

OTPチームによって実装されることが選ばれたのはマップであり、フレームは独自の提案とともに、まだどこかで保留されています。

マップの提案は包括的で、多くのケースを網羅し、言語に適合させる際の多くの設計上の問題点を highlighted しています。そのため、実装は段階的に行われます。仕様はマップのあるべき姿で説明されており、暫定的な実装は初期リリースのための短い足で説明されています。

マップのあるべき姿

モジュール

マップは、意図的にdictデータ構造に似たデータ型であり、同様のインターフェースとセマンティクスを持つモジュールが提供されています。以下の操作がサポートされています。

また、listsモジュールの関数と同様に動作する、fold/3map/2などの優れた関数型標準も含まれています。さらに、size/1is_map/1(ガードでもあります!)、from_list/1to_list/1keys/1values/1、メンバシップをテストしてマップを融合するためのis_key/2merge/2などの集合関数など、一連のユーティリティ関数があります。

それほどエキサイティングではなく、ユーザーはもっと多くのことを望んでいます!

構文

マップの最も期待されている側面は、速度の向上にもかかわらず、ネイティブ構文です。モジュール呼び出しと同等の操作を以下に示します。

マップモジュール マップ構文
maps:new/1 #{}
maps:put/3 Map#{Key => Val}
maps:update/3 Map#{Key := Val}
maps:get/2 Map#{Key}
maps:find/2 #{Key := Val} = Map

この構文のすべてがまだ実装されているわけではないことに注意してください。幸いなことに、マップユーザーにとって、パターンマッチングのオプションはこれよりも広範です。マップから複数のアイテムを一度にマッチさせることができます。

1> Pets = #{"dog" => "winston", "fish" => "mrs.blub"}.
#{"dog" => "winston","fish" => "mrs.blub"}
2> #{"fish" := CatName, "dog" := DogName} = Pets.
#{"dog" => "winston","fish" => "mrs.blub"}

ここでは、キーの順序に関係なく、一度に任意の数のアイテムの内容を取得できます。要素は=>で設定され、:=でマッチされることに注意してください。:=演算子は、マップ内の*既存の*キーを更新するためにも使用できます。

3> Pets#{"dog" := "chester"}.
#{"dog" => "chester","fish" => "mrs.blub"}
4> Pets#{dog := "chester"}.
** exception error: bad argument
     in function  maps:update/3
        called as maps:update(dog,"chester",#{"dog" => "winston","fish" => "mrs.blub"})
     in call from erl_eval:'-expr/5-fun-0-'/2 (erl_eval.erl, line 257)
     in call from lists:foldl/3 (lists.erl, line 1248)

仕様にはさらにマッチングがありますが、17.0ではまだ利用できません。

5> #{"favorite" := Animal, Animal := Name} = Pets#{"favorite" := "dog"}.
#{"dog" => "winston","favorite" => "dog","fish" => "mrs.blub"}
6> Name.
"winston"

同じパターン内で、既知のキーの値を使用して、別のキーとして使用可能な変数(Animal)を定義し、その別のキーを使用して目的の値(Name)をマッチさせることができます。この種のパターンの制限は、サイクルがあってはならないことです。たとえば、#{X := Y, Y := X} = Mapのマッチングは、XをマッチさせるためにYを知る必要があり、YをバインドするためにXを知る必要があるため、実行できません。また、同じ値を持つキーが複数存在する可能性があるため、値によるキーのマッチング(#{X := val} = Map)も実行できません。

**注意:**単一の値にアクセスするための構文(Map#{Key})は、EEPではそのように記述されていますが、実装後は変更される可能性があり、別の解決策が採用される場合は完全に削除される可能性があります。

マップとは関係ありませんが、興味深いものが追加されました。Starting Out For Realで、リスト内包表記を紹介したのを覚えているでしょうか。

7> Weather = [{toronto, rain}, {montreal, storms}, {london, fog},   
7>            {paris, sun}, {boston, fog}, {vancouver, snow}].
[{toronto,rain},
 {montreal,storms},
 {london,fog},
 {paris,sun},
 {boston,fog},
 {vancouver,snow}]
8> FoggyPlaces = [X || {X, fog} <- Weather].
[london,boston]

マップ内包表記が利用可能になれば、同じ種類のことができます。

9> Weather = #{toronto => rain, montreal => storms, london => fog,
9>             paris => sun, boston => fog, vancouver => snow}.
#{boston => fog,
  london => fog,
  montreal => storms,
  paris => sun,
  toronto => rain,
  vancouver => snow}
10> FoggyPlaces = [X || X := fog <- Weather].
[london,boston]

ここで、X := fog <- Weatherは、Key := Val <- Mapの形式のマップジェネレーターを表します。これは、リストジェネレーターやバイナリジェネレーターと同じように、構成したり置き換えたりすることができます。マップ内包表記は、新しいマップを生成することもできます。

11> #{X => foggy || X <- [london,boston]}.
#{boston => foggy, london => foggy}

または、mapsモジュール自体のマップ操作を実装するには、次のようにします。

map(F, Map) ->
    #{K => F(V) || K := V <- Map}.

そして、それがほとんどです!この新しいデータ型がErlangに加わるのは非常に喜ばしいことであり、ユーザーにも喜んでもらえるでしょう。

A bee looking over a map

詳細

詳細はそれほど*複雑*ではなく、少しだけです。言語にマップを含めることは、いくつかのことに影響します。EEP-43では、分散Erlangプロトコル、演算子の優先順位、後方互換性、Dialyzer拡張機能の提案(サポートがEEPの推奨する範囲まで及ぶかはまだわかりません)など、まだ確定していない潜在的な変更について詳細に定義しています。

多くの変更は、ユーザーがあまり深く考える必要がないように提案されています。しかし、避けられない変更の1つは、ソートです。以前の本では、ソート順は次のように定義されていました。

number < atom < reference < fun < port < pid < tuple < list < bit string

マップはここに収まります。

number < atom < reference < fun < port < pid < tuple < map < list < bit string

タプルよりも大きく、リストよりも小さい。興味深いことに、マップはキーと値に基づいて比較できます。

2> lists:sort([#{ 1 => 2, 3 => 4}, #{2 => 1}, #{2 => 0, 1 => 4}]).
[#{2 => 1},#{1 => 4,2 => 0},#{1 => 2,3 => 4}]

ソートは、リストやタプルと同様に、まずサイズ、次に含まれる要素によって行われます。マップの場合、これらの要素はキーのソート順になっており、値自体でタイブレークします。

クールエイドを飲み過ぎないでください
マップのキー1.0をキー1で更新することはできませんが、両方を等しいと比較することは可能です。これは、Erlangの長年の欠点の1つです。たとえば、リストをソートするときに1.09121よりも大きくならないように、すべての数値を等しいと比較できるのは非常に便利ですが、パターンマッチングに関しては混乱を招く可能性があります。

たとえば、11.0と等しいと予想できますが(=:=のように*厳密に等しい*わけではありません)、1 = 1.0を実行してパターンマッチングできるとは限りません。

マップの場合、これはMap1 == Map2Map1 = Map2と同義ではないことを意味します。ErlangマップはErlangのソート順に従うため、#{1.0 => true}のようなマップは#{1 => true}と等しいと比較されますが、互いにマッチさせることはできません。

このセクションの内容はEEP-43に基づいて記述されていますが、実際の実装は遅れている可能性があるため、注意してください。

初期リリースのための短い足

A Corgi with a cape, flying

Erlang 17.xと18.0に付属するマップの実装は完全ですが、mapsモジュールの範囲内でのみです。主な違いは構文にあります。構文のごく一部のみが利用可能です。

マッチや宣言における単一の値へのアクセス(Map#{key})を含む残りの部分は、まだありません。マップ内包表記とDialyzerのサポートも同じ運命です。実際、構文は変更され、最終的にはEEPと異なる可能性があります。

マップは、ほとんどのErlang開発者とOTPチームが望むほど高速ではありません。それでも、進歩は進んでおり、マップが言語に追加されるまでにかかった時間と比較して、特にそれほど長くはかからないはずです。すぐに、はるかに良くなるでしょう。

この初期リリースは、マップに慣れるには十分であり、さらに重要なことには、マップをいつどこで使用するかを知ることができます。

メキシカンスタンドオフ

誰もが、ネイティブ辞書が欲しい、またはレコードの代替品を優先したいという意見を持っていました。マップが発表されたとき、多くのErlang開発者は、マップが言語に登場すれば、解決したい問題が解決すると考えました。

そのため、Erlangでマップをどのように使用すべきかについて、混乱が生じています。

マップ対レコード対辞書

最初に明確にしておくと、マップは辞書の代替であり、レコードの代替ではありません。これは混乱を招く可能性があります。本章の冒頭で、一般的な不満点を挙げましたが、レコードに関する不満の多くはマップによって解決されることがわかりました。

マップによって解決される問題 レコードの問題点 辞書の問題点
レコード名が煩雑  
優れたネイティブのキーバリューストアがない  
高速なキーバリューストア   バージョン18以上
ネイティブ型で変換が容易
より強力なパターンマッチング  
レコードのアップグレード おそらく  
インクルードなしでモジュール間で使用可能  
squid aiming two guns mexican-standoff-like, while eating a taco

評価は非常に拮抗しています。マップが高速であるという点は、必ずしもまだ当てはまっていませんが、最適化によってより良いレベルになるはずです。OTPチームは、古いスローガン「まず動作させ、次に美しく、必要であれば高速にする」を尊重しています。彼らはまずセマンティクスと正確性を重視しています。

レコードのアップグレードが「おそらく」とされているのは、code_change機能に関係しています。多くのユーザーは、pq_player.erlとそのアップグレードコードで行ったように、バージョンを変更する際にレコードを明示的に変換しなければならないことに悩まされています。マップを使用すれば、エントリに必要なフィールドを追加するだけで、処理を続けることができます。これに対する反論は、レコードではアップグレードの失敗が早期にクラッシュする(良いこと!)のに対し、マップではアップグレードの失敗がデータの破損に相当する状態で残り、手遅れになるまで表面化しない可能性があるということです。

では、なぜマップをレコードとしてではなく、辞書として使用する必要があるのでしょうか?そのためには、2つ目の表が必要です。これは、*セマンティクス*、つまりどのデータ構造またはデータ型に機能が適用されるかについてです。

操作 レコード マップ 辞書
イミュータブル
任意の型のキー
マップ/フォールドで使用可能
他のモジュールからは内容が不透明
使用するモジュールがある
パターンマッチングをサポート
すべてのキーがコンパイル時に既知
他のインスタンスとマージ可能
キーの存在確認
キーによる値の抽出
キーごとのDialyzer型チェック*
リストとの相互変換
要素ごとのデフォルト値
実行時にスタンドアロンのデータ型
高速な直接インデックスアクセス

* EEPは、コンパイル時に既知のキーに対してこれを可能にすることを推奨していますが、いつ、あるいはそれが実現するかどうかについては、ETAはありません。

この表から、マップとレコードの構文は似ていますが、マップとレコードよりも辞書とマップの方が*セマンティクス*的に近いことは明らかです。したがって、マップでレコードを置き換えることは、C言語のような言語の「構造体」をハッシュマップで置き換えようとするようなものです。不可能ではありませんが、目的が異なることが多いことを考えると、良い考えとは言えません。

キーがコンパイル時に既知であることは、特定の値への高速アクセス(動的に可能なものよりも高速)、追加の安全性(状態を破損させるのではなく、早期にクラッシュ)、そして容易な型チェックという利点をもたらします。これらは、より冗長なcode_change関数を記述する負担が時折あるにもかかわらず、プロセスの内部状態にレコードを非常に適したものにしています。

一方、Erlangユーザーが複雑なネストされたキーバリューデータ構造(オブジェクト指向言語のオブジェクトに奇妙に似ている)を表現するためにレコードを使用し、それが頻繁にモジュール境界を越える場合、マップは非常に役立ちます。レコードはその仕事には不適切なツールでした。

簡単に言うと、レコードが場違いで煩わしいと感じられる場所はマップで置き換えられる*可能性*がありますが、ほとんどのレコードの使用はそうすべきでは*ありません*。

過度の期待は禁物
複雑なネストされたデータ構造を持ち込み、それを1つの大きな透過的なオブジェクトとして関数やプロセスに渡したり、そこから受け取ったりし、パターンマッチングを使用して必要なものを抽出することは、魅力的に思えることがよくあります。

しかし、メッセージパッシングでこれらを行うにはコストがかかることを覚えておいてください。Erlangのデータはプロセス間でコピーされるため、この方法で作業するとコストがかかる可能性があります。

同様に、大きな透過的なオブジェクトを関数に渡したり、関数から受け取ったりする際には注意が必要です。分かりやすいインターフェース(またはAPI)を構築することはすでに困難です。内部データ表現に固執してしまうと、実装の柔軟性が大幅に失われる可能性があります。

インターフェースとメッセージベースのプロトコルをよく考えて、共有される情報と責任の量を制限する方が良いでしょう。マップは辞書の代替であり、適切な設計の代替ではありません。

パフォーマンスへの影響も予想されます。Richard O'Keefeは彼の提案の中で次のように述べています。

フレームが意図しているレコードのような用途に辞書を適したものにしようとすると、既存の用途には適さなくなります。

また、OTPチームのEEPでも同様のことが述べられています。

マップとレコードを比較すると、欠点はマップによって容易に解消できますが、値がコンパイル時ではなく実行時に決定される組み込みデータ型では、プラスの効果を複製するのは容易ではありません。

  • インデックスと結果の値がコンパイル時に決定される直接インデックス配列よりも高速にすることは困難です。実際、それは不可能です。
  • レコードに近い効率を持つマップのメモリモデルは、フレームで示されているように、キー用のタプルと値用のタプルの2つを使用することで実現できます。これは、多数のエントリを持つマップの更新のパフォーマンスに影響を与え、辞書アプローチの能力を制限します。

プロセスループのコア部分では、存在するはずのすべてのキーがわかっている場合、レコードはパフォーマンスの観点から賢明な選択です。

マップとプロップリストの比較

マップが勝る可能性のあるものの1つは、プロップリストです。プロップリストは、モジュールに渡されるオプションに非常に適した、かなり単純なデータ構造です。

inet:setopts/2呼び出しは、[{active, true}, binary]のような形式のオプションのリストを取ることができ、ファイル処理関数は、例えば[read, write, append, {encoding, utf8}]のような引数リストを取ることができます。これらのオプションリストはどちらもproplistsモジュールを使用して読み取ることができ、writeのような項は、{write, true}と記述されたかのように展開されます。

a scale measuring proplists vs. maps

マップは、単一キーアクセス(実装されている場合はいつでも)により、プロパティを定義する同様の方法を提供します。例えば、[{active, true}]は、マップでは#{active => true}と表現できます。これは同じくらい煩雑ですが、モジュール呼び出しを行う必要がないため(Opts#{active}のおかげで)、オプションの読み取りがはるかに簡単になります。

*ほとんど*がペアであるオプションリストは、マップに置き換えられることが予想されます。一方、readwriteappendなどのリテラルオプションは、ユーザーの観点からは、プロップリストの方がはるかに使いやすくなります。

現在、オプションを必要とするほとんどの関数がプロップリストを使用していることを考えると、一貫性のために、その方法を続けることは興味深いことかもしれません。一方、オプションがほとんどペアである場合、proplistsモジュールを使用すると、すぐに煩雑になる可能性があります。最終的には、ライブラリの作者は、実装の内部的な明確さとエコシステム全体の一貫性のどちらを選択するかを決定する必要があります。あるいは、作者は、プロップリストの廃止に向けた段階的な移行を可能にするために、両方の方法を同時にサポートすることを決定するかもしれません(もしそう感じているなら)。

一方、以前はプロップリストを関数の戻り値として渡していた関数は、マップに切り替えるべきでしょう。そこにはレガシーはほとんどなく、ユーザーにとっての使い勝手ははるかに向上するはずです。

**注:** マップは巧妙なトリックを使用して、多くのデフォルト値を一度に簡単に設定できます。プロップリストではproplists:get_value(Key, List, Default)を使用する必要がありますが、マップではmerge/2関数を使用できます。

仕様によると、merge/2は2つのマップを融合し、2つのキーが同じであれば、2つ目のマップの値が優先されます。これにより、maps:merge(Default, YourMap)を呼び出して、目的の値を取得できます。例えば、maps:merge(#{key => default, other => ok}, #{other => 42})は、マップ#{key => default, other => 42}になります。

これは、デフォルト値を手動で属性付けし、キーが欠落していることを心配することなく、結果のマップを使用するための非常に便利な方法です。

この本をマップ用に改訂する方法

このセクションを追加したのは、現時点では、マップを組み込むために、本書全体を更新する時間がないからです。

しかし、本書のほとんどの部分は、変更はほとんどありません。主に、dictモジュールとgb_trees(最小値/最大値が頻繁に必要ない場合)への呼び出しをインラインマップ構文に置き換えるだけです。セマンティクスの都合上、本書で使用されているレコードはほとんど変更されません。

マップが安定し、完全に実装されたら、いくつかのモジュールを再検討するかもしれませんが、それまでの間、マップの実装が部分的であるため、多くの例をそのように記述することは実用的ではありません。