【Swift】Arrayの便利な変換関数たち

はじめに

今回はArrayが持つ便利な変換関数たちを紹介したいと思います。

これまで、配列で保持した値に何かしら手を加えて別なデータに変換したいと思ったとき、基本的には変換後の入れ物となる変数を用意して、for文でぐるぐる回しながら変換 → 格納ということをやっていました。

ここで紹介する関数たちもやることは同じなのですが、これらは「変換後用の変数を用意してfor文でindexを0からcount – 1まで増やしていってindex番目の要素にアクセスして……」という煩雑なこと(for-in文ならもう少し楽になりますが)を省略してくれるため、コード量を減らして可読性を上げる力となってくれます。

さらには目的に合った関数(その関数の名前)を使い分けることで、毎度毎度の「配列からのfor文」という一本調子になりがちなソースの語彙力を高めることができるので、「俺のソース、かなりイケてね?」とテンションアップ、モチベーションアップの効果も期待することができます。(効果には個人差がございます)

それでは見ていきましょう。

開発・実行環境は Xcode 11.6 / Swift 5.2 です。

下記のサンプルソースはPlaygroundにて実行可能です。

map

mapは配列の要素を1つずつ変換し、新たな内容(値 or/and 型)の配列を作成する関数です。

func map<T>(_ transform: (Element) throws -> T) rethrows -> [T]

引数(transform)にクロージャーを渡します。

このクロージャーは要素の数だけ呼ばれます。
引数を1つ取り、ここに配列の先頭から1つずつの要素の値が渡されます。

そして、その引数に何かしらの加工を施して戻り値として返すと、それが新しい配列の要素になります。

↓サンプル

// 数字を書式化してくれるクラス
let formatter = NumberFormatter()
formatter.locale = Locale(identifier: "ja_JP")
formatter.numberStyle = .currency

// 値段を表すInt配列
let prices = [2000, 19800, 548000]

// 変換
let mapped = prices.map { value in
    formatter.string(from: NSNumber(value: value))!
}

print(mapped)

mapのイメージとして、↑の処理は↓のように書いた場合と同等になります。

// 数字を書式化してくれるクラス
let formatter = NumberFormatter()
formatter.locale = Locale(identifier: "ja_JP")
formatter.numberStyle = .currency

// 値段を表すInt配列
let prices = [2000, 19800, 548000]

// 変換
var mapped = [String]()

for value in prices {
    mapped.append(formatter.string(from: NSNumber(value: value))!)
}

print(mapped)

↓実行結果

["¥2,000", "¥19,800", "¥548,000"]

NumberFormatterはDateFormatterに似た、数字を書式化してくれるとても便利なクラスです。

mapに渡したクロージャーでは、prices(Int配列)の各要素を受け取って、このフォーマッターにかけて返しています。

それぞれStringに変換して返しているため、結果として日本の通貨の書式となったStringの配列が出来上がりました。

compactMap

compactMapはmapに似ていますが、こちらはクロージャーでnilを返すと、その要素は新しい配列から省かれるという動作をします。

func compactMap<ElementOfResult>(_ transform: (Element) throws -> ElementOfResult?) rethrows -> [ElementOfResult]

↓サンプル

// String配列
let array = ["1", "2", "3", "ダー!"]

// compactMapで変換
let compactMapped = array.compactMap { value in
    Int(value)
}

print("compactMapped: \(compactMapped)")

// 比較のため、mapでも変換してみる
let mapped = array.map { value in
    Int(value)
}

print("mapped: \(mapped)")

↓実行結果

compactMapped: [1, 2, 3]
mapped: [Optional(1), Optional(2), Optional(3), nil]

String配列に対し、要素をそれぞれInt型にしようとしています。

Intのinit(text: StringProtocol)は整数に直せる(整数を表す)文字列であればIntの値を返しますが、そうでないものの場合はnilを返します。

compactMapはnilで返された要素を新しい配列には含めないため、”ダー!”だけが省かれた形となりました。

比較としてmapでも同様のことを試してみると、こちらは要素数は変わらず、だけどnilが返されたことでnilを含む Int? のオプショナルな型の配列に変換されました。

compactMapはイメージとして、↓のように書いた場合と同等になります。

let array = ["1", "2", "3", "ダー!"]

var compactMapped = [Int]()

for value in array {
    if let result = Int(value) {
        compactMapped.append(result)
    }
}

print("compactMapped: \(compactMapped)")

もうひとつ実験として、↓のようなソースを書いてみます。

// 型もバラバラ、nilも混在なAny?配列
let array: [Any?] = [1, nil, 2, 3, nil, "ダー!"]

// compactMapで要素をそのまま返してみる
let compactMapped = array.compactMap { value in
    value
}

print("compactMap: \(compactMapped)")

↓実行結果

compactMap: [1, 2, 3, "ダー!"]

最初はAny?の配列だったものが、nilが取り除かれると共にオプショナルではないAnyの配列へと変換されました。(関数の宣言に書いてあるとおりですが)

これを使えば、中身がごっちゃな配列を純粋なIntの配列にならしたい、みたいなことも容易に行えますね。

flatMap

flatMapは、クロージャーの中で変換して返した値を1つの配列(Sequence)とし、その個々の要素を結果となる新しい配列にバラしながら加えていくことをやってくれる関数です。

func flatMap<SegmentOfResult>(_ transform: (Element) throws -> SegmentOfResult) rethrows -> [SegmentOfResult.Element] where SegmentOfResult : Sequence

説明だけだとなんのこっちゃわかりづらいかと思いますので、↓サンプルソースで見てみます。

// カンマ区切りのStringが入った配列
let array = ["a", "b,c,d", "e,f"]

// 最初にmapで変換してみる
let mapped = array.map { value in
    // 文字列をカンマで分割
    value.components(separatedBy: ",")
}

print("map: \(mapped)")

// 次に本命のflatMapで変換
let flatMapped = array.flatMap { value in
    value.components(separatedBy: ",")
}

print("flatMap: \(flatMapped)")

↓実行結果

map: [["a"], ["b", "c", "d"], ["e", "f"]]
flatMap: ["a", "b", "c", "d", "e", "f"]

最初にmapで変換を行っていますが、こちらはあくまで各要素を1対1で変換するものなので、結果はString配列の配列という2次元配列となりました。

一方のflatMapの場合、こちらはクロージャー内で返した値(String配列)を新たな配列にバラしながら格納してくれるため、結果はきれいに一本のString配列になりました。

flatMapはイメージとして、↓のように書いた場合と同等になります。

let array = ["a", "b,c,d", "e,f"]

var flatMapped = [String]()

// 変換
for value in array {
    let subarray = value.components(separatedBy: ",")

    for subvalue in subarray {
        flatMapped.append(subvalue)
    }
}

print("flatMap: \(flatMapped)")

2次元配列が生じるような場合でも1次元に均してくれるから“flat”なんですね。

ちなみに、単に2次元配列を1次元にしたいだけならjoinedという関数が使えます↓

// Intの2次元配列
let array = [[1,2,3], [4,5], [6]]

// flatMapでそのまま返す
let flatMapped = array.flatMap { value in
    value
}

print("flatMap: \(flatMapped)")

// joinedでも結果は一緒(でもArrayへの変換が必要)
let joined = Array(array.joined())

print("joined: \(joined)")

↓実行結果

flatMap: [1, 2, 3, 4, 5, 6]
joined: [1, 2, 3, 4, 5, 6]

reduce

reduceは、クロージャーを通してすべての要素をひとつの値もしくは変数にまとめる関数です。

値を渡すか変数を渡すかで、2種類宣言があります。

// 初期値を渡すバージョン
func reduce<Result>(_ initialResult: Result, _ nextPartialResult: (Result, Element) throws -> Result) rethrows -> Result

// 変数を渡すバージョン
func reduce<Result>(into initialResult: Result, _ updateAccumulatingResult: (inout Result, Element) throws -> ()) rethrows -> Result

reduceというと「減らす」「縮小させる」といった意味がまず浮かびますが、他には「まとめる」「単純化する」といった意味もあるようです。

この関数の挙動から察するに、おそらく後者の意味で使われているのだと思われます。

そしてその意味のように、reduceは「すべての要素を足し合わせる」とか「1つの変数(Dictionary等)にまとめる」といったことをするために使用します。

↓サンプル①

// Intの配列
let array = [1, 2, 3, 4, 5]

// 初期値0に対し、各要素を足し合わせていく
let reduced1 = array.reduce(0) { (result, value) in
    result + value
}

print("reduced1: \(reduced1)")

// 合計だけなら第2引数に「+」だけ書いてもOK
// これはIntで定義されているstaticな関数↓を使用している
//   public static func + (lhs: Int, rhs: Int) -> Int
let reduced2 = array.reduce(0, +)

print("reduced2: \(reduced2)")

↓実行結果①

reduced1: 15
reduced2: 15

reduceの引数initialResultに値を渡すと、それがクロージャーの第1引数に渡ってきます。(ここでは「result」としています。第2引数の「value」は配列の各要素)

クロージャーにて返された値は、次回呼ばれたときの第1引数となります。

ここではresultとvalueを足し算して返しているので、結果としてすべての要素を合計する処理となっています。

そしてすべての要素が処理されたら、最後のクロージャーで返された値がreduceの戻り値として返ります。

イメージとして、このreduceの処理は↓のように書いた場合と同等になります。

let array = [1, 2, 3, 4, 5]

var result = 0

for value in array {
    result = result + value
}

let reduced1 = result

print("reduced1: \(reduced1)")

もう1つ、変数を渡す方のreduceも見てみます。

↓サンプル②

// 年齢を表すInt配列
let ages = [27, 18, 41, 7, 65, 33]

// [String: [Int]]のDictionary変数を渡して、20以上と未満に分けていく
let reduced = ages.reduce(into: [String: [Int]]()) { (result, age) in
    if age < 20 {
        result["under", default: []].append(age)
    }
    else {
        result["over", default: []].append(age)
    }
}

print("reduced: \(reduced)")

↓実行結果②

reduced: ["over": [27, 41, 65, 33], "under": [18, 7]]

動作の流れは同じですが、こちらは「into」の引数ラベルを指定し、初期値の代わりに変数を渡します。(もしくは、上記サンプルのように直接インスタンスを作って渡してもOK)

ここで渡した変数がクロージャーの第1引数に引き継がれるので、あとは各要素をよしなにして変数に突っ込んでいけばオーケーという仕様です。

こちらはイメージとしては、↓のように書いた感じとなります。

let ages = [27, 18, 41, 7, 65, 33]

var result = [String: [Int]]()

for age in ages {
    if age < 20 {
        result["under", default: []].append(age)
    }
    else {
        result["over", default: []].append(age)
    }
}

let reduced = result

print("reduced: \(reduced)")

enumerated

enumeratedは変換関数ではないのですが(forEachと同じ、イテレーション系の関数)、プログラムを書いているとどうしても「mapで処理したいけど、要素だけじゃなくてindex(要素の位置)もほしい!」というケースが出てきます。

その場合にmapを諦めて↓のようにfor文で書いてしまいがちですが、

// エリア名とポイント(要素数は同じ前提)
let areas = ["Tokyo", "Osaka", "Hokkaido"]
let points = [111, 222, 333]

var result = [String]()

for (index, value) in areas.enumerated() {
    result.append("\(value): \(points[index])")
}

print(result)

それを↓こう書いてもできますよ、というお話。

// エリア名とポイント(要素数は同じ前提)
let areas = ["Tokyo", "Osaka", "Hokkaido"]
let points = [111, 222, 333]

let result = areas.enumerated().map { value in
    "\(value.element): \(points[value.offset])"
}

print(result)

↓実行結果

["Tokyo: 111", "Osaka: 222", "Hokkaido: 333"]

なんてことはなく、単に私が最近までこれに気付いていなかったってだけのことです💦

ああ〜、for文書いてたわー

もっとも、必ずしもfor文は良くないってわけではないので、別に構わないんですけどね。

おまけ:NSExpressionで統計量を求める

IntやDoubleなどの配列から最大値や最小値を求めたい場合、Arrayにはmaxminといった関数があるのでそれを使えばいいのですが、平均値や中央値などを求めたいとなると自分で計算しないといけません。

……と、そんなことはなく、

そんなことをせずとも「NSExpressionを使えばいけまっせ」というのを最後に紹介したいと思います。

↓サンプル

// 身長を表すDoubleの配列に対し
let heights = [168.1, 154.2, 177.6, 149.0, 162.3]

// NSExpressionを使って平均を出してみる
let average = NSExpression(format: "average(%@)", heights)

if let result = average.expressionValue(with: nil, context: nil) as? Double {
    print("average: \(result)")
}
else {
    print("failed")
}

// 中央値も出してみる
let median = NSExpression(format: "median(%@)", heights)

if let result = median.expressionValue(with: nil, context: nil) as? Double {
    print("median: \(result)")
}
else {
    print("failed")
}

↓実行結果

average: 162.24
median: 162.3

NSExpressionの詳細は公式ドキュメントをご参照ください。

これは今回の記事のテーマである「Arrayの変換関数」からは外れてしまうのですが、「配列から何かの値に変換する」といった観点からすればこれも類するものなので、合わせて覚えておけば何かの役に立つかなと思い、ご紹介いたしました。

おわりに

いかがでしたでしょうか。

冒頭では「語彙力を高める」と書きましたが、mapやreduceといった関数を使うことは、すなわちプログラムを「宣言的(Declarative)に記述する」ことに繋がります。

これは、対義語である「命令的(Imperative)に記述する」のと違い、それぞれの関数の役割を把握していないとなかなかやっていることが理解しづらいという短所があります。

しかし、RxSwiftのようなライブラリを始め、他言語なども含めて今やこういった宣言的な記述(宣言型プログラム)が主流となっていますので、これまであまり意識したことのない方は、まずは簡単なArrayのmapやreduceなどから使っていくようにして、宣言的に書けるスタイルを身に着けていくといいかもしれません。

ちなみに、この宣言的・命令的といった言葉の意味について、過去に書いたRxSwiftの記事にて少し触れていますので、よければ合わせてご参照ください。

それでは。