🔺

一日一処: TypeScriptでピラミッド建造プログラムをできるだけ長く書く

に公開

昔話

以前にピラミッドを建造するプログラムについての記事を投稿している。個人的には、ぎゅっと短くなったりシンプルなプログラムを好む。
https://zenn.dev/jinkutoriu/articles/ec81d4eced5dea

モチベーション

前回はJavaScriptで可能な限り短縮を意識した記述を行った。今回はTypeScriptを使用して、できるだけ詳細で長いプログラムを書いてみる。
「プログラムを短くしよう」という考え方ももちろん大切だ。しかし、TypeScriptの機能をふんだんに用いて、具体的で正確で堅牢なものが作れるかもしれない。そして、なんといっても巨大ロボのような機能として本来不要な飾りや、簡単な動作のために大げさなギミックがあることは、大変ロマンを感じる。プログラムは短く書くことも、長く書くこともやはり難しいと感じる。そういったところが、さらに好奇心を高ぶらせる。

また、この記事はTypeScriptを学ぼうと考えている方に向けて、JavaScriptの記述を基準に比較しながら、段階的にTypeScriptに変更していく。よってこの記事がさまざまな機能を知る機会にもなってくれればうれしく思う。

原点

まずは前回の記事に記載したJavaScriptを原点に展開していく。

const lines = 5

for (let i = 0; i < lines; i++) {
  let spaces = ''
  let blocks = '*'
  for (let k = 0; k < lines - i; k++) {
    spaces += ' '
  }
  for (let j = 0; j < i * 2; j++) {
    blocks += '*'
  }
  console.log(spaces + blocks)
}

TypeScriptの思想はJavaScriptに対する型の拡張であり、TypeScript独自の機能を増やしてJavaScriptとの機能的な差別化を目指しているわけではない

そのため、この投稿でのTypeScript変換は、基本的に型を用いた変更であり、それに伴う関数等の定義となる。それでは、型の定義からTypeScriptへと変えていく。

const lines: number = 5

for (let i: number = 0; i < lines; i++) {
  let spaces: string = ''
  let blocks: string = '*'
  for (let k: number = 0; k < lines - i; k++) {
    spaces += ' '
  }
  for (let j: number = 0; j < i * 2; j++) {
    blocks += '*'
  }
  console.log(spaces + blocks)
}

このようにすべての変数に型を設定した。変数に導入されている値が何かを確認し、文字列であればstringを、数値であればnumberをそれぞれ変数に設定した。これにより、それぞれの変数は文字や数値など設定された型以外は代入できなくなる。これが、JavaScriptでのデータの取り扱いをより堅牢にしたTypeScriptの良さだろう。

本来であれば、これでTypeScriptとしての修正は完了したといえるが、それだと全く面白くない。これ以降は本来なくてもいいが、ここからは、ロマンのために、無駄にTypeScriptの機能を使って長くするだけに特化した内容を記述していく。

ピラミッドの段数を型として同定する

値を代入する変数を型によって束縛したことで、すでにTypeScriptとしての変更は終わりではないだろうか?と考える方もいるだろう。

より適切な値を扱い、より厳密な束縛ができる余地がまだある。まず手始めがピラミッドの段数だ。このプログラムにおいて、ピラミッドの段数が命であり、二段以上であることにピラミッドとしての真価を発揮する。よって、二段未満の一段のピラミッドは、もはやただの立方体の石でありピラミッドでさえない。

ピラミッド建造時に設定する段数は一行目の変数linesに格納しているが、TypeScript上、この段数を型として利用するために数値リテラルを型として確定させてみる。(numberという広義の値としての型ではなく、具体的な42などの数値を型として定義するという意味)

例えば、以下のようなプログラムを書く。

function func<N extends number>(lines: N) {
  return lines
}

const result = func(5)
type ResultType = typeof result

本来は、どのような数値でもnumberとして表現されるが、上記のようにジェネリクスを用いたプログラムになると、ResultType5という型として確定する。エディターでマウスオーバーすると型定義の内容がわかる。

ただ、この関数は与えられた引数をそのまま返却するのみで具体的な処理がない。せっかくなので、前述した2段以上でなければピラミッドとして認めないという方向性で検証の関数にしてみよう。

function validateLines<N extends number>(lines: N) {
  if (lines < 2) {
    new Error(`${lines} is too small`)
  } else if(lines > 1020) {
    new Error(`${lines} is too big`)
  }
  return lines
}

同時に、高さに関する制約も追加した。高すぎた場合、現実的な建築は不可能なため、すでに過去崩壊した実績のある高層建築物である「バベルの塔」の高さ510mをピラミッドの石一つの高さを50cmで割る。結果として1020段が現実的であるため、これを超える場合はエラーが発生するようにした。これで、値の検証と型の確定ができた。

ピラミッドの全体を司る仕組み

ピラミッドは一段ごとに建造を行う。一段ごと建造する過程は繰り返しであり、それを実行しているのが、一番外側のfor文だ。変数linesの数値まで繰り返しを行うことになるため変数iは0から一つずつ数値を増加させながら一段ずつ建造し、最終的には変数linesと同じ値となる。

for (let i: number = 0; i < lines; i++) {
  // (中略)
}

ここからlet i: number = 0を厳密に書き換えていく。先のとおり、変数iの数値は増加していくが、最小(0)と最大(lines)が明確である。そのため、number型ではなく、既定の数値に合わせた型が設定できると、正確なものだといえる。
TypeScriptでは、数値としてnumber型が存在するが、各数字を限定する型(例えば0から99までの数字のみの型)は存在せず、人力で型を作ることになる。

type Num = 0 | 1 | 2 | 3

このようにUnion型を用いてパイプで具体的な値をつなげていく。4桁の数字に制限したい場合は、多くの数字をパイプで記述しなければならないため現実的な方法とは言えないだろう(ただしテンプレートリテラルを用いた型定義を行えば手間は減らせるかもしれない)。

具体的に一つずつの数字を記述していくにはさすがに骨が折れる(今回は0から1020まで)ため、型定義による再帰的な処理を行い連続の値を設定できる型を準備する。

type Enum<N extends number, Acc extends number[] = []> = Acc['length'] extends N
  ? Acc[number]
  : Enum<N, [...Acc, Acc['length']]>

これはよく出てくる型定義のパターンである。初見だと頭に入りにくい怪文だが、シンプルにそれぞれ分解すると、最初に登場するのは再帰的な処理だ。

// 簡潔にするため一部を書き換え
type Enum<N, A> = A['length'] extends N ? A[number] : Enum<N, [...A, A['length']]>

Aは配列であり、Nは特定の数値となる。A['length'] extends Nは、配列Aの長さ(length)が数値Nと同値であるかを判断している。その後は通常の三項演算子であり、配列の長さと指定数が一致する場合、A[number]の結果となる。初見だと意味が分からないかもしれないが、A[number]は配列Aの要素の型を出力する。

三項演算子の三項目は、Enum<N, [...A, A['length']]となっており、型定義(type Enum<N, A>)そのものに再帰的な処理を行っている。A['length'] extends Nから長さと数値が一致しなかったときに実行される処理であり、数値N[...A, A['length']]が渡される。[...A, A['length']]は、通常のJavaScriptでも理解できる範囲だろう。スプレッド演算子で配列Aの中身を展開し、配列の末尾には配列の長さを挿入する。

つまり、この再帰的な型定義は、指定の数値Nまで配列の要素を0から順に追加する。最終的に配列の長さと指定の数値が一致するとき、その配列の中に含まれる要素の型を返却する。
ただ、ここで疑問が生まれる。前述の例だと、配列の中身を増やして中身を型として出力してもstringnumberというリテラル型となるため具体的な数値として区別できないのではないか。
このコードでは、内部で繰り返し使用される配列が定数のような振る舞いを持っている。そのため、返却される実態は、以下のように具体的な値として確定してくれる。以下の例は変数に代入した配列を定数として固定し、そこから要素の型を導いたものになる。

このように、定数から内部の値を具体的な型として取り出す仕組みを変数を用いず型定義のみで行ったのが前述の例となる。

以下にJavaScriptによる同等のプログラムを記述しているため、上記の解説で理解が難しかった方は、確認してみてほしい。

function generator(num, array = []) {
  return array.length === num
    ? array
    : generator(num, [...array, array.length])
}

console.log(generator(10))

では、ここまで記述した型定義を用いて、最上位の繰り返しに用いる変数用の型を作成する。

const lines = validateLines(10)
type Lines = typeof lines
type LineRange = Enum<Lines, []>

エディター上では最後のLineRangeの型にしっかりと必要となる繰り返しの数値が型として設定された。

最後に、新たに宣言した型をfor文の変数に設定すれば、ここでの変更は終わりだ。

for (let i: LineRange = 0; i < lines; i++) {
  // 省略
}

ピラミッドを形作る

ピラミッドは二等辺三角形の形状となる。その形を実現するために左右に空間を作っている(正確には左側のみ)。先のコードでは、以下のfor文が該当し、spaces変数に必要な個数分の空白を繰り返し追加しているのが分かる。

  for (let k: number = 0; k < lines - i; k++) {
    spaces += ' '
  }

前述の全体の繰り返し文と同じような見方をするならば、ここでの繰り返しで数値が増加する変数kは0から変数linesと変数iで減算した数値まで増加していくことが明確だ。よって、Enum<Lines - i, []>のように書けば型定義が解決するだろうと思われるかもしれないが、型の宣言の中で減算することはできない。さらにこの変数に格納される数値の幅は、計算結果では一番外側のfor文と同一になるため、先ほど定義した型を設定することができる。

  for (let k: LineRange = 0; k < lines - i; k++) {
    spaces += ' '
  }

しかし、次のピラミッドそのものの形を作り出している繰り返し文の条件はやや複雑だ。

  for (let j: number = 0; j < i * 2; j++) {
    blocks += '*'
  }

繰り返しに使用される変数jは0からj < i * 2の条件まで繰り返される。変数iが繰り返し処理が行われるときの最大の値はlines - 1となるため、j < i * 2で比較される最大数値は、(lines - 1) * 2になる。上記で、型定義では減算ができないと書いたが、同じく同様に乗算もできるわけもなく、このような結果が不定となる計算式での型定義が難しい。そのため、最大で二倍の数値となるこの繰り返し文を二つに分割する。

  for (let j: number = 0; j < i; j++) {
    blocks += '*'
  }
  for (let j: number = 0; j < i; j++) {
    blocks += '*'
  }

非常にシンプルになった。複雑なロジックや計算式を作って混乱しながらプログラミングを行うより、分割ができるのであれば、最小限にまで分割してプログラミングを行うほうが理解もしやすく、効率的だ。
ただこのままだと、繰り返しに使用されている変数の型が不適切なため、number型を置き換える。

  for (let j: LineRange = 0; j < i; j++) {
    blocks += '*'
  }
  for (let j: LineRange = 0; j < i; j++) {
    blocks += '*'
  }

繰り返しの関係は先ほどまでと大きな違いがないため、同様にLineRange型を用いる。
これで、記述していた繰り返し処理のすべてに適切な型が設定できただろう。

ピラミッドの原材料となるもの

このピラミッドを建造するプログラムでは、ピラミッドの形を作るために、スペースとアスタリスクを用いています。ここまでは数値に関する型定義を行っていましたが、ここからはスペースやアスタリスクなどの文字列に関しての型定義を行っていく。

それぞれの文字は以下の変数で初期化され、それぞれの変数に繰り返しの過程で追加される。追加される文字列は、一段ずつの内容であり、一段分の建造が完了すると、また以下の初期化処理が発生する。

  let spaces: string = ''
  let blocks: string = '*'

スペースの変数spacesについては、スペースの型定義を設定すべきだが、変数を初期化している文字は空文字(文字なし)となる。よって若干の工夫が必要となる。

type Space = ' ' | ''
let spaces: Space = ''

工夫と言っても、Union型を用いて、スペースと空文字を設定することができれば問題わけだ。だが、これには致命的な課題がある。例えば、以下のコードだ。

type StrType = 'a'|'b'
let a: StrType = 'a'
a = a + 'b'

本来変数aには'a'または'b'の文字列しか代入できないが、最後の行でa + 'b'を代入している。これは、変数a'ab'という文字を代入することになるが、これは、型定義から逸脱している。ただし、TypeScript側は、これに対しての推論がうまくできず、警告が発生しない。よって、ここでは、単純な文字列ではなく、配列としてスペースを取り扱うようにする。

// 省略
+ type Space = ' '
+ type Spaces = Space[]

  for (let i: LineRange = 0; i < lines; i++) {
-   let spaces: string = ''
+   let spaces: Spaces = []
    let blocks: string = '*'
    for (let k: LineRange = 0; k < lines - i; k++) {
      spaces.push(' ')
    }
- console.log(spaces + blocks)
+ console.log(spaces.join('') + blocks)
// 省略

最後の行では出力の処理を若干変更している。配列をそのままconsole.logで出力すると、自動的にカンマ区切りの文字列に変更されてしまう。今回はスペースのみの配列となるため, , ,のように出力される。そのため、空文字で結合(join)を行った。

続いてアスタリスクの変数(blocks)も同様に変更しておこう。

// 省略
type Block = '*'
type Blocks = Block[]

for (let i: LineRange = 0; i < lines; i++) {
  let spaces: Spaces = []
  let blocks: Blocks = ['*']
  for (let k: LineRange = 0; k < lines - i; k++) {
    spaces.push(' ')
  }
  for (let j: LineRange = 0; j < i; j++) {
    blocks.push('*')
  }
  for (let j: LineRange = 0; j < i; j++) {
    blocks.push('*')
  }
  console.log(spaces.join('') + blocks.join(''))
}

これでピラミッドの原材料としてのスペースやアスタリスクがプログラム上適切であると制限することができた。
だが、ここまでくると、最後のconsole.log(spaces.join('') + blocks.join(''))もだんだん気になってくる。何が気になるかというと、原材料はあっているかもしれないが、最終的に建造されるアウトプットに何か違うものが混入するかもしれないという危険性だ。なので、以下のように出力用の処理を分離した。

function outputLine(spaces: Spaces, blocks: Blocks) {
  console.log(spaces.join('') + blocks.join(''))
}
// 省略
outputLine(spaces, blocks)

これでも少しだけ不安になるのがconsole.logに渡している情報をその場で処理している点だ。できれば、変数一つのみを渡したい。そして、その変数には適切な型を設定して、console.logには型で制限された適切な値を渡したい。型定義の再帰や配列の機能を使うと、joinで結合した値を型として表現することができる。

function outputLine<S extends Spaces, B extends Blocks>(spaces: S, blocks: B) {
  type JoinedArray<T extends string, A extends T[]> = A extends readonly [infer _, ...infer U]
    ? [] extends U ? T : `${T}${JoinedArray<T, U extends T[] ? U : []>}`
    : T

  const joinedSpaces: JoinedArray<Space, S> = spaces.join('') as JoinedArray<Space, S>
  const joinedBlocks: JoinedArray<Block, B> = blocks.join('') as JoinedArray<Block, B>
  console.log(joinedSpaces + joinedBlocks)
}

型定義の再帰は冒頭と概ね同じだが、今回はA extends readonly [infer _, ...infer U]が新顔だ。これは、型Aの内容を型推論inferによって取り出している。infer _は配列の先頭要素を意味し、_は型変数だが使用しないことを意味する(アルファベットにすると、TypeScriptから未使用の型変数があるという点で指摘を受けるため_によって迂回)。
...infer Uは配列の先頭を除いた残りの要素を型変数Uに組み入れている。いわゆる残余引数を使用しているものになる。このように配列の中身を一つずつ取り出し、再帰関数を使いながら、結合していくことで、スペースやアスタリスクなどそれぞれ個別の文字が繰り返し一つの文字列として用いられる型を作ることができる。

最後に出力用の変数とそれに伴う型を準備すると、この関数でのやるべきこは終わりになる。

+   type Line = `${JoinedArray<Space, S>}${JoinedArray<Block, B>}`

    const joinedSpaces: JoinedArray<Space, Spaces> = spaces.join('') as JoinedArray<Space, Spaces>
    const joinedBlocks: JoinedArray<Block, Blocks> = blocks.join('') as JoinedArray<Block, Blocks>
-   console.log(joinedSpaces + joinedBlocks)
+   const line: Line = `${joinedSpaces}${joinedBlocks}`
+   console.log(line)

全体像

これで、当初目的に掲げていたJavascriptをTypeScriptに置き換え、ソースコードを長くするという目的は達成できたように思える。

type Enum<N extends number, Acc extends number[] = []> = N extends 0
  ? 0
  : Acc['length'] extends N ? Acc[number] : Enum<N, [...Acc, Acc['length']]>
type Space = ' '
type Spaces = Space[]
type Block = '*'
type Blocks = Block[]

function validateLines<N extends number>(lines: N) {
  if (lines < 2) {
    throw new Error(`${lines} is too small`)
  } else if(lines > 1020) {
    throw new Error(`${lines} is too big`)
  }
  return lines
}

function outputLine<S extends Spaces, B extends Blocks>(spaces: S, blocks: B) {
  type JoinedArray<T extends string, A extends T[]> = A extends readonly [infer _, ...infer U]
    ? [] extends U ? T : `${T}${JoinedArray<T, U extends T[] ? U : []>}`
    : T

  type Line = `${JoinedArray<Space, S>}${JoinedArray<Block, B>}`

  const joinedSpaces: JoinedArray<Space, S> = spaces.join('') as JoinedArray<Space, S>
  const joinedBlocks: JoinedArray<Block, B> = blocks.join('') as JoinedArray<Block, B>
  const line: Line = `${joinedSpaces}${joinedBlocks}`
  console.log(line)
}

const lines = validateLines(10)
type Lines = typeof lines
type LineRange = Enum<Lines, []>

for (let i: LineRange = 0; i < lines; i++) {
  let spaces: Spaces = []
  let blocks: Blocks = ['*']
  for (let k: LineRange = 0; k < lines - i; k++) {
    spaces.push(' ')
  }
  for (let j: LineRange = 0; j < i; j++) {
    blocks.push('*')
  }
  for (let j: LineRange = 0; j < i; j++) {
    blocks.push('*')
  }
  outputLine(spaces, blocks)
}

終わりに

疲れた。ただ、興味が赴くままキーボードをたたいていたが、気づけば数時間経過していた。正解や不正解がひとによってそれぞれおありだろうが、個人的にはある程度満足がいくものとなったためこれで終わりにする。
最後に心残りなのは、関数の型宣言を記述していない点だ。せっかくであれば、関数の型も書いてしまってもよいと感じたが、私は実際の業務で関数の型を書くことはほとんどないため、割愛した。

やはりTypeScriptは他言語に比べて独特な型定義を持っているように感じているが、その自由度の高さから関数や変数の取り扱いにミスが生まれにくくなるのも確かだ。今後も様々な型表現に触れていきたいし、作っていきたい。そう思った。

Discussion