LoginSignup
90
64

More than 3 years have passed since last update.

JavaScriptからletを「絶滅」させるために足りないもの

Last updated at Posted at 2020-08-05

"JavaScriptからletを絶滅させ、constのみにするためのレシピ集" という投稿を読みました。半分はネタだと思いますが、 JavaScript で const を追求すると可読性が厳しそう だなと感じました。

一方で、他の言語だと同じことにチャレンジしても、もう少しマシに書けそうに思いました。僕が普段一番よく使っている言語は Swift です。そこで、試しに Swift で同じ内容のコードを書いてみて、 JavaScript で let を「絶滅」させるために足りないもの が何かを考えてみました。

なお、 JavaScript のコードは注釈がない限り上記の投稿からの引用です。

変数・定数宣言のためのキーワード

変数 定数
JavaScript let const
Swift var let

JavaScirpt と Swift では変数・定数宣言のためのキーワードが異なります。同じ let というキーワードが JavaScript では変数宣言に、 Swift では定数宣言に用いられていてややこしいです。そのため、本投稿ではそれぞれ「 let (変数)」・「 let (定数)」として区別します。

初級

10回繰り返したいfor文

// JavaScript ( Lodash を利用)
_.range(10).forEach(i => {
  console.log(i)
})

これを Swift で書くと次のようになります。

// Swift
for i in 0 ..< 10 {
    print(i)
}

let (定数)が省略されていますが、 ilet (定数)で宣言されます。 0 ..< 10 は標準ライブラリの Range を生成します。

元記事の JavaScript のコードと同じように forEach を使って書くこともできます。しかし、 forEachbreakcontinue, return などと相性が良くないので、 for 文などの制御構文が使える場合は、制御構文を優先した方が良いと思います。

なお、 JavaScript でも for...of 文を使えば次のように書けます。

// JavaScript (※引用でない、 Lodash を利用)
for (const i of _.range(10)) {
    console.log(i);
}

元記事の想定環境は ES2017 ということですが、 for...of は ES2015 で導入されているので上記で良いように思います。ただ、僕は普段 JavaScript をバリバリ書いているわけではないので、 ES2017 前提で for...of より forEach を優先した方が良い理由があれば教えてもらえるとうれしいです。

数値配列の合計値を算出

// JavaScript( Lodash を利用)
const arr = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3]
const sum = _.sum(arr)
// JavaScript (reduceが分かる人向け)
const sum = arr.reduce((accumulator, currentValue) => accumulator + currentValue, 0)

Swift の + は関数なので、次のようにして reduce+ を渡して簡潔に書けます。

// Swift
let sum = arr.reduce(0, +)

また、標準ライブラリに sum はないですが、次のように extension を書けばメソッドを追加できます。

// Swift
extension Sequence where Element: AdditiveArithmetic {
    func sum() -> Element {
        reduce(.zero, +)
    }
}
// Swift
let sum = arr.sum()

この sum メソッドは Array が準拠する Sequence に対して追加しているので、 Array の他に SetRange など任意の Sequence に対して利用できます。

// Swift
let sum = (1 ... 10).sum() // 55

なお、 Swift では標準ライブラリの型に独自のメソッドを追加しても、 JavaScript と比べると衝突の心配は小さいです。 Swift では同名のメソッドを追加した複数のライブラリを同時に利用しても、それぞれ異なるシンボル名にコンパイルされているので干渉し合うことはありません。同一ファイル上でそれらを複数 import した場合はコード上でどれを指しているか曖昧になるのでコンパイルエラーになりますが、それは別名を付けるなどして回避可能です。1

JavaScript でも prototype にメソッドを追加することはできますが、 Array などの標準の型への追加は推奨されません。 let (変数)を「絶滅」させるには、 let (変数)を書きたくなるようなケースで気軽に extension でカバーできると良いように思います。 let (変数)の利用を extension 側に閉じ込められれば、ユーザーコードから let (変数)を「絶滅」させる助けになるでしょう。

足りない機能を関数として提供することはできますが、標準ではメソッドを使うことが多いので、スタイルが混ざるのはコードを書く上でも読む上でも望ましくありません。たとえば、 ES2019 で flatMap が追加されましたが、 flatMap が関数ではなくメソッドとして提供されるのはうれしいのではないでしょうか。

JavaScript はその仕組み上、標準の型を拡張しながら衝突を避けるのは難しいので、標準で十分な道具(まさに flatMap のような)が提供されることが let の「絶滅」に役立つのではないかと思います。

オブジェクトの配列の合計値を算出

// JavaScript( Lodash を利用)
const users = [{ name: 'person1', age: 10 }, { name: 'person2', age: 20 }, { name: 'person3', age: 30 }]
const sumOfAge = _.sumBy(users, 'age')
console.log(sumOfAge)
// JavaScript (reduceが分かる人向け)
const sumOfAge = users.reduce((accumulator, currentUser) => accumulator + currentUser.age, 0)

reduce だけで書くと、 User から age を取り出すコードと、それらの合計を計算するコードが一体化して可読性が下がります。 map して ageArray に変換してから合計した方が読みやすいでしょう。しかし、その場合、 map は中間計算のためだけに Array インスタンスを生成することになり無駄です(何百万個の要素を持つ巨大な Array かもしれません)。 LazySequence を使えばそのような問題を解決できます。

// Swift
let sumOfAge = users.lazy.map(\.age).reduce(0, +)

LazySequencemap による変換は即時実行されません。上記コードでは reduce 時に遅延して要素を取り出し、要素ごとに map の変換処理が行われます。そのため、中間計算のために巨大な実体を持ったコレクションが生成されることを防げます。 JavaScript でも、これに相当する手段を標準で提供してくれれば、パフォーマンスを犠牲にせず可読性を向上させることができるでしょう。

またこの例のように、 UserArrayageArray に変換したいようなケースは多いと思います。 Swift の KeyPath\.age )はそのようなケースで役立ちます。 Lodash を使った一つ目の例では 'age' を文字列として渡して似たようなことをしていますが、 \.age は文字列ではなく静的型検査可能なのでより安全です(そもそも JavaScript は動的型付けですが)。

if文

// JavaScript
const tax = isTakeout ? 0.08 : 0.1

Swift でも同様に三項演算子が使えます。

// Swift
let tax = isTakeout ? 0.08 : 0.1

しかし、 Swift では if 文で分岐しても let (定数)が使えます。

// Swift
let tax: Double
if isTakeout {
  tax = 0.08
} else {
  tax = 0.1
}

もし else が存在しないなど、定数が網羅的に初期化されないとコンパイルエラーになります。

この例では三項演算子が適切でしょうが、これができることが後で大きな差となって利いてきます。

じゃあswitch文どうするのよ

// JavaScript
const getMessageByStatus = (status) => {
  switch (status) {
  case 200:
    return 'OK'
  case 204:
    return 'No Content'
  // ...省略
  }
}

const message = getMessageByStatus(response.status)

前述のように、 Swift では制御構文による分岐と let (定数)の初期化を組み合わせられるので次のように書けます。わざわざ switch 一つのために関数を作る必要はありません。

// Swift
let message: String
switch (response.status) {
case 200:
    message = "OK"
case 204:
    message = "No Content"
// ...省略
}
print(message)

中~上級

try-catchとの兼ね合い

元記事では、 const徹底しない JavaScript のコードは次のようになっていました。

// JavaScript
let response
try {
  response = await requestWeatherForecast() // 天気予報APIを叩く
} catch (err) {
  console.error(err)
  response = '曇り' // APIから取得できなかった場合は適当に曇りとか言ってごまかす
}
console.log(response)

しかし、 let (変数)を「絶滅」させるために、上記コードが次のように改変されていました。

// JavaScript
const response = await requestWeatherForecast().catch(err => {
  console.log(err)
  return '曇り'
})

せっかく try/catchasync/await を組み合わせられる仕組みを言語が提供しているにも関わらず、 const を使うために半分 Promise に戻ってしまいました。これはかなり辛いです。

残念ながら Swift には Swift 5.2 現在で async/await がありませんが、 Swift 6 (おそらく 2021 年リリースの次期メジャーバージョン)で導入されそうです 2 3 4 。これが使えると仮定すると、次のように書くことができます。

// Swift
let response: String
do {
    response = try await requestWeatherForecast()
} catch {
    print(error)
    response = "曇り" // APIから取得できなかった場合は適当に曇りとか言ってごまかす
}
print(response)

let (定数)を使っているにも関わらず、 JavaScript の let (変数)を使ったコードとほぼ同じになりました。ここでも制御構文と let (定数)を組み合わせて、初期化を遅延させられることが利いています。 ifswitch による分岐だけでなく、 try/catch による分岐も考慮して、定数の初期化の網羅性が判定されているわけです。

例外catchしたら早期returnしたいんだが

// JavaScript
const shouldReturn = Symbol('shouldReturn') // 普通に文字列の'shouldReturn'でも良いか?
const response = await requestWeatherForecast().catch(err => {
  console.error(err)
  return shouldReturn
})
if (response === shouldReturn) return
console.log(response)

これはさらに辛いです。 let (変数)をなくすために shouldReturn を導入するのは明らかにやりすぎでしょう(これは完全にネタでしょうが)。

これも、 let (定数)と制御構文を組み合わせられれば素直に書くことができます。

// Swift
let response: String
do {
    response = try await requestWeatherForecast() // 天気予報APIを叩く
} catch {
    print(error)
    return
}
print(response)

この場合、エラーケースは早期リターンするので、 requestWeatherForecast が成功した場合だけ response が初期化されるコードになっていても、網羅的に定数が初期化されると判断されます。

リトライ処理

「リトライ処理」について、元記事の const徹底しない コードは↓でした。

// 天気予報APIを叩く。エラーが出たら10回までリトライする
const MAX_RETRY_COUNT = 10
let retryCount = 0
let response
while(retryCount <= MAX_RETRY_COUNT) {
  try {
    response = await requestWeatherForecast() // 天気予報APIを叩く
    break
  } catch (err) {
    console.error(err)
    retryCount++
  }
}
console.log(response)

これを、 let (変数)を「絶滅」させるために、再帰関数を使って↓のように書き換えていました。

// JavaScript
// 与えられた関数をmaxRetryCount回までリトライする関数。
const retryer = (maxRetryCount, fn, retryCount = 0) => {
  if (retryCount >= maxRetryCount) return undefined

  return fn().catch(() => retryer(maxRetryCount, fn, retryCount + 1)) // retryCountを1増やして再帰呼び出し
}

const response = await retryer(MAX_RETRY_COUNT, requestWeatherForecast)

これについては、 Swift で書く場合も var を使わないのは少し難しそうです。 let (定数)と制御構文を組み合わせて定数の初期化を遅延させられるからといって、ループとの組み合わせにはループが一度も実行されないかもしれない難しさがあります。また、ループの中で初期化が必ず一度だけ行われるかをコンパイラが判断するのが困難です。 Swift コンパイラはそこまでやってくれません。

どうしても、 let (定数)で書きたいということであれば、たとえば次のように書くことはできるでしょう。

// Swift
let response: String?
do {
  response = try await (0 ..< maxRetryCount).reduce(nil) { (result, _) in
    if result != nil { return result }
    return try? await requestWeatherForecast()
  }
} catch {
  response = nil
}
print(response)

ただ、こういうケースでは可読性のために var を使って書いた方が良いです。

// Swift
var response: String?
for _ in 0 ..< maxRetryCount {
  do {
    response = try requestWeatherForecast()
    break
  } catch {}
}
print(response)

もしくは、リトライを宣言的に書けるようなライブラリ( Rx とか( Swift なら) Combine とか)を使いましょう。

番外編(不変じゃないconst)

constは再代入できないだけで、constで宣言した配列に要素を追加したり、constで宣言したオブジェクトにプロパティを追加することはできてしまいます。

これらの行為はconstという唯一神をletと同じ地位まで貶める愚行です。

// JavaScript
const arr = []
arr.push(1) // arr: [1]
const obj = {}
obj.a = 1 // obj: { a: 1 }

Swift では let (定数)を使って宣言された Array 型変数に対して変更を加えることはできません。

// Swift
let arr = []
arr.append(1) // ⛔ コンパイルエラー

Swift の Array は値型なので varlet かを変更するだけでミュータビリティをコントロールすることができます。また、 Value Semantics を持つように実装されているので、次のようなコードを書いても定数に格納された Array インスタンスが変更されることはありません。

// Swift
let a = [2, 3, 5]
var b = a
b.append(7) // a は変更されない

print(a) // [2, 3, 5]
print(b) // [2, 3, 5, 7]

配列から条件に合うものだけ抜き出す

// JavaScript
const result = arr.filter(n => n % 2 === 0)

このコードには、特に可読性に関する辛さはないと思います。

Swift でも同様に filter を使って書きます。

// Swift
let result = arr.filter { $0.isMultiple(of: 2) }

% 2 を使っても良いですが、専用のメソッド( isMultiple(of:) )があるのでそれを使った方が良いでしょう。

変数がundefinedじゃないときだけオブジェクトに追加

// JavaScript
const header = {
  'Content-Type': 'application/json',
  ...(token === undefined ? {} : { Authorization: `Bearer ${token}` })
}

これはあえて 定数にこだわるところではない気がしますが、次のように書くことはできます。

// Swift
let header = ["Content-Type": "application/json"]
    .merging(token.map { ["Authorization": "Bearer \($0)"] } ?? [:]) { _, new in new }

ただ、可読性を考えると var を使って次のように書いた方が良いでしょう。

// Swift
var header = ["Content-Type": "application/json"]
if let token = token {
    header["Authorization"] = "Bearer \($0)"
}

とはいえ、リテラルの中で分岐できる処理がほしくなることもあります。 Function Builder を使えば次のようなことができる extension を作ることも可能です5

// Swift
let header: [String: String] = .init {
    ["Content-Type": "application/json"]
    if let token = token {
        ["Authorization": "Bearer \($0)"]
    }
}

オブジェクトの値部分に処理を加える

// JavaScript ( Lodash を利用)
const obj = { a: '1', b: '2', c: '3', d: '4', /* ... */ }
_(obj).mapValues(Number).pickBy(isEven) // { b: 2, d: 4, ... }

Swift には動的なオブジェクトはないので Dictionary で書きます( isEven は Swift の標準ライブラリにないですが、 JavaScript にもないので、別途宣言されているものとします)。

// Swift
let obj = ["a": "1", "b": "2", "c": "3", "d": "4", /* ... */ ]
obj.compactMapValues(Int.init).filter { isEven($0.value) }

キーになるのは compactMapValues です。 StringInt に変換する処理は、 Int("42") ような場合は成功しますが Int("ABC") のような場合には失敗します。 Int.init は失敗したときに nil を返しますが、 compactMapValuesmapValues した上で結果が nil になるエントリーを取り除いてくれます。

JavaScript から let を「絶滅」させるために足りないもの

こうして色々なケースを比較して眺めてみると、 JavaScript から let (変数)を「絶滅」させる一番のハードルは、 const と制御構文の相性の悪さではないでしょうか。特に、 try/catchasync/awaitifswitch と組み合わせて使おうとすると途端に辛くなってしまいます。

もし JavaScript に改変を加えられるとして、この問題を解決するために僕がすぐに思いつく選択肢は次の二つです。

  1. Swift のように、制御フローを解析して網羅的に初期化されている場合は const の初期化を遅延させられるようにする。
  2. Scala や Kotlin のように、 ifswitch 等を式にする。

1 の例は↓です。

// JavaScript (※引用でない)
const a;
if (Math.random() < 0.5) {
    a = 2;
} else {
    a = 3;
}

2 の例は↓です。

// JavaScript (※引用でない)
const a = if (Math.random() < 0.5) {
    2;
} else {
    3;
}

もちろん、これくらいなら三項演算子で書けますが、 try/catchasync/await などと組み合わせてもこれができることが求められます。

他にも、上記で比較してみた範囲でも、

  • 演算子が関数でない
  • mapreduce を遅延させられない(ので中間計算のために実体を伴う無駄なコレクションを生成しないといけない)
  • 標準で足りない道具が多いわりに extension を気軽に書くのが憚られる

などが挙げられました。

逆に言えば、それらが実現されれば let (変数)の「絶滅」に一歩近づいたと言えるでしょう。

ところで、元記事には const の利点について

constなら宣言された行だけを見ればどんな値が入っているかがわかりますが、letはコード全体を追う必要があり、読み手への負担が大きいです。

と書かれています。僕は、これは必ずしも真ではないと考えています。たとえば、変数と for ループを使って 1 から 100 までの合計を求めるコードは、次のように十分小さなスコープを切れば問題ありません。変数 _sum の値を追うのが辛いということはないでしょう6

// Swift
let sum: Int // 定数
do { // 変数を使う小さなスコープ
    var _sum = 0 // 変数
    for x in 1 ... 100 {
        _sum += x
    }
    sum = _sum
}

残念ながら、 JavaScript では const の初期化を遅らせられないのでこの手を使うことはできません。 ES2017 前提で似たようなことをするなら、次のように書くことになるでしょう。

// JavaScript (※引用でない)
const sum = (() => { // 変数を使う小さなスコープ
    let sum = 0; // 変数
    for (let i = 1; i <= 100; i++) {
        sum += i;
    }
    return sum;
})();

もしくは、 let (変数)を小さなスコープに留めることを諦めるかです。その場合でも、個々の関数やメソッドが十分に小さく保たれていれば、 let (変数)が問題になることは少ないのではないでしょうか。僕個人の体験を振り返ってみても、(ローカルスコープで)定数ではなく変数を使ったことによって問題が引き起こされたようなケースは、もう何年も記憶にありません。

少し違った視点

ここまで、 JavaScript と Swift で変数を「絶滅」させたコードを比較し、いくつかの言語仕様や標準ライブラリの API が提供されていれば、より可読性の高いコードが書けることを見てきました。しかし、変数をより「絶滅」させやすい Swift ですが、 Swift はむしろ変数をよく使う言語です。

もちろん、 Swift でも無駄に var を使うことは推奨されませんし、 let (定数)にできるのに var になっている箇所があるとコンパイラが警告してくれます。しかし、それは var をあまり使わない方が良いということではありません。

JavaScript をはじめ、 Java, Scala, Kotlin, C#, Python, Ruby などはすべて参照型中心の言語です。 Swift がそれらの言語と決定的に異なるのは、 Swift は値型中心の言語だということです。 Array などのコレクションを含め、 Swift の標準ライブラリの型のほとんどは値型です。

値型を扱う場合、 varlet (定数)かはミュータブルかイミュータブルかということに直結します。参照型中心の言語では Value Semantics を得るためにイミュータブルクラスが広く用いられますが、値型は基本的に Value Semantics を持っているため、イミュータビリティの重要性が高くありません。

それはさらに、

  • 参照型中心 → イミュータブルな型を多用 → 式による変更が多い → 式指向が便利 → 定数で済ませやすい
  • 値型中心 → ミュータブルな型を多用 → 文による変更が多い → 文指向が便利 → 変数を活用する場面が多い

とつながります。

この関係で言えば、 JavaScript は参照型中心の言語なので式指向や定数と相性が良いはずです。しかし、言語の構文が式指向でないので上記の関係がねじれてしまって、 const を活用しづらいという見方もできるのではないでしょうか。


  1. とはいえ、他ライブラリとの名前の衝突は面倒なので、標準ライブラリなどの型の API を拡張する場合は名前衝突に配慮が必要です。ただ、ライブラリのコードではなく、アプリのコードを書いている場合はほぼ気にする必要はありません。 Swift でアプリを書くときに、それぞれの環境で都合の良い独自の extension を書くことは当たり前に行われていて、危険もほぼありません。また、仮に衝突してしまっても、別ファイルで一つずつ import して別名を付けることで、最悪ケースでも衝突を回避することが可能です。 

  2. "On the road to Swift 6" の中で concurrency が挙げられています。 

  3. "Swift Concurrency Manifesto" の Part 1 として async/await が挙げられています。 async/await についての Proposal はこちらです。 

  4. 先日 async に関する PR が Swift のリポジトリにマージされました。 

  5. ただし、 Function Builder の if let への対応は Swift 5.3 ( 2020 年秋にリリース予定の次期マイナーバージョン)からの予定です。 

  6. ただし、このコードでは定数 sum の初期化がここで完結することが構文上保証されません。その観点で言えば、式指向の言語のように、スコープの最後の式をスコープの値として直接定数に代入できるとより良いと思います。 

90
64
25

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
90
64