プログラミング要素の言語化: 写像(map)、抽出(filter)

エンジニア TS
2021年10月18日


この記事はプログラミングの基本的な要素について言語化する試みです。今回は写像(map)、抽出(filter)について説明します。
どういうときにどういうプログラミングの機能を使うのが良いのか、機能の特徴を整理します。

写像(map)

写像(map)とはコレクションオブジェクトの要素ごとに変換処理をし、変換後の要素を集めたコレクションオブジェクトを返す機能です。
要素変換に特化している分、同様の処理を反復(iteration)を使って記述するよりも間違いを埋め込みにくく、簡潔に記述できます。
mapが使えるケースでは積極的にmapを利用するべきでしょう。

オブジェクト指向言語であればmapという名前のコレクション操作メソッドとして実装されていることが多いです。

JavaScript

const src = [1, 2, 3, 4, 5];
const dst = src.map(e => e + e);
console.log(dst);

// => [2, 4, 6, 8, 10]

Kotlin

val src = listOf(1, 2, 3, 4, 5)
val dst = src.map { it + it }
println(dst)

// => [2, 4, 6, 8, 10]

Ruby

p [1, 2, 3, 4, 5].map {|x| x + x}
# => [2, 4, 6, 8, 10]

関数型言語ではmapはメソッドではなく関数として実装され、コレクションデータと変換関数を受け取る形で実装されていることが多いです。
機能としてはオブジェクト指向言語のmapメソッドと同等です。

Haskell

main = putStrLn $ show $ map (\x -> x + x) [1, 2, 3, 4, 5]
--                           ↑ 変換関数     ↑コレクションデータ

-- => [2,4,6,8,10]

mapの特徴

mapメソッドは以下の特徴を持ちます。

  • 変換元の要素数と変換先の要素数は等しい (追加や削除はしない。要素数が0個でも処理に問題なく、0個の要素を返す)

理解しやすい写像処理を記述する上で気を付けたいのは、mapに渡すブロック外の変数に副作用を与えないことが望ましいです。

以下はmapに渡したブロックからブロック外の変数にアクセスしたときに、読み解きにくいプログラムになってしまう実例です。

外部のカウンター変数を使って連番付きのコレクションオブジェクトを作っています。

Kotlin

data class ViewRow(val sequence: Int, val name: String) {}

val names = listOf("Alice", "Bob", "Joe")
var currentSequence = 0

val viewRows = names.map { name ->
    currentSequence += 1
    ViewRow(currentSequence, name)
}

println(viewRows)

// => [ViewRow(sequence=1, name=Alice), ViewRow(sequence=2, name=Bob), ViewRow(sequence=3, name=Joe)]

連番を作るために、反復処理のように外部変数を利用していますが、外部変数がどのように変わっていくのか理解が難しいプログラムになってしまっています。

回避法

外部変数を使うよりも、外部変数を使わずに連番付きのコレクションオブジェクトを生成するほうがベターでしょう。

たとえばブロックにindexを渡す機能が提供されているプログラム言語が多いので、それを使うのは良い選択です。

Kotlin

data class ViewRow(val sequence: Int, val name: String) {}

val names = listOf("Alice", "Bob", "Joe")

val viewRows = names.mapIndexed { i, name ->
    ViewRow(i + 1, name)
}

println(viewRows)

// => [ViewRow(sequence=1, name=Alice), ViewRow(sequence=2, name=Bob), ViewRow(sequence=3, name=Joe)]

または連番だけを含んだコレクションオブジェクトを作っておき、連番コレクションと要素コレクションをzipメソッドで結合する手法もあります。

zipメソッドは2つのコレクションオブジェクトを、各要素のペアのコレクションオブジェクトに変換する操作です。

Kotlin

data class ViewRow(val sequence: Int, val name: String) {}

val names = listOf("Alice", "Bob", "Joe")
val range = 0..names.size-1

val viewRows = names.zip(range).map { (name, i) ->
    ViewRow(i + 1, name)
}

println(viewRows)

// => [ViewRow(sequence=1, name=Alice), ViewRow(sequence=2, name=Bob), ViewRow(sequence=3, name=Joe)]

これらの機能を使ってブロック外に副作用を与えないようにしたほうが、副作用による意図しない挙動による不具合を回避できてベターです。

抽出(filter)

抽出(filter)とはコレクションオブジェクトの要素ごとに判定処理をし、真と判定された要素のみを集めたコレクションオブジェクトを返す機能です。
要素の抜き出しに特化している分、同様の処理を反復(iteration)を使って記述するよりも間違いにくく、簡潔に記述できます。
filterが使えるケースでは積極的にfilterを利用するべきでしょう。

filterの特徴

filterメソッドは以下の特徴を持ちます。

  • 変換元の要素数と同じか、少ない要素数のコレクションオブジェクトを返す。(最小0個)
  • 各要素は操作されず、変換元のコレクションオブジェクトに含まれる要素の部分集合を返す。

Kotlin

val src = listOf(1, 2, 3, 4, 5)
val even = src.filter { it % 2 == 0 }
println(even)

// => [2, 4]

filterに渡す条件式を書き換えることで、任意の条件での抽出に対応できます。

理解しやすい抽出処理を記述するという観点からは、写像(map) と同様にブロック外の変数、や渡されたパラメータに対しても副作用を与えないことが望ましいです。

mapとfilterを併用する

コレクションの抽出と変換は、コレクションへの操作の中でも頻出する処理です。

抽出と変換を続けて行うケースもよくあります。例えば検索ページではデータから条件にあったものを抽出してから表示用のオブジェクトに変換するといった処理を行います。

基本的な反復の機能でもこの処理を実現できますが、抽出と変換を利用するほうが間違いにくく、読み解きやすいコードになります

まず反復を使って検索結果の抽出と変換処理を記述してみます。

Kotlin

val result = mutableListOf()

for (e in src) {
    if (!e.available()) {
        continue
    }

    val row = convertRow(e)
    result.add(row)
}

continueを行う部分とadd() を行う部分を書き忘れてしまうと不具合になりますので、注意して書く必要があります。

処理の読み解きやすさの観点からは、抽出と変換の箇所が見分けにくいため、やや読み解きにくい、また、バグを含んでいないか直感的に判断しにくいと感じます。

次に filterとmapを使った同じ処理を見てみましょう。

Kotlin

val result: List = src.filter { it.available() }.map { convertRow(it) }

filterとmapを使った例の方が、コレクションへの操作を行っていることが明確です。抽出と変換が別の処理として記述されているため、個々の処理が理解しやすいと感じます。
また、resultに要素ごとにadd()で追加する必要もなくなり、書き間違いによる不具合の埋め込みを減らせる効果があります。

これまで反復機能しか使ったことがなく、コレクション操作メソッドの記述に慣れてない人にとっては、filter や map の記法には違和感があるかもしれません。個人的にはこれは慣れだけの問題で、馴染んでしまえばコレクション操作メソッドで記述したほうが読みやすくなるのではないかと思います。

使えるケース例

filter と map がどういうケースで使えるか、いくつか事例を挙げてみます。

  • 1) 全国の市区群リストから愛知県の市区群のみを抜き出し、値にJISコードを、コンテンツに市区群名をセットしたプルダウンを表示する。
  • 2) 銀行支店リストからりそな銀行の支店、かつ「あ」行の支店のみを抜き出し、支店番号と支店名のリストを表示する。
  • 3) 従業員リストから年収600万円以下の従業員を抜き出し、従業員番号、氏名、氏名カナ のリストを検索結果として出力する。
  • 4) 空港リストから日本の空港を抜き出し、空港コードをキーにしたMapオブジェクトに変換する。

抽出と写像は汎用性が高いので、使い方が理解できるようになるといろいろな処理で活用できるようになります。

上記の処理をfilterとmapを使って書いてみます。

1)

val cityOptions = cities.filter { it.prefectureCode == 23 }.
        map { OptionElement(it.jisCode, it.name) }

2)

val pat = Regex(pattern = """\A[あ-お]""")
val filtered = branches.filter {it.bankCode == 10 && pat.containsMatchIn(it.kana)}
val mapped = filtered.map { Pair(it.branchCode, it.name) }

3)

val result = employees.filter { it.income <= 6000000 }.
        map { Triple(it.number, it.name, it.nameKana) }

空港リストから日本の空港を抜き出し、空港コードをキーにしたMapオブジェクトに変換する。

4)

val result = airports.filter { it.countryCode == "JP" }.
        map { it.code to it.name }.toMap()

まとめ

今回は map と filter という、最もよく使われるコレクション操作メソッドを説明しました。

コレクション操作メソッドには多くのメリットがありますので使える場面では、反復処理よりもコレクション操作メソッドを使う方が望ましいです。

  • 意図違いの副作用を埋め込んでしまう危険が減る
  • 既存の変数に副作用を与えないという意図をコードで表現できる。
    反復が使われていると「副作用を与えることが必要な処理をするためにあえて反復を使っているのかな」と考えられるので、解読する手間が増えます。
  • それに対してmapが使われていれば要素の変換をしてるんだな、filterが使われていれば
    要素の抽出をしてるんだな、と処理内容を解読するのが楽になります。

map や filterをうまく使うとプログラムの可読性を上げられますので、使えるケースを理解して便利に使いましょう。

2021年10月18日

エンジニア TS |

« »
このページのトップへ