Nimで言語処理100本ノック(第2章: UNIXコマンド)

## はじめに

この記事は[Nim Advent Calendar 2022](https://qiita.com/advent-calendar/2022/nim)の12日目の記事です。

11日目の記事は[UMA821さん](https://qiita.com/UMA821)のNimで言語処理100本ノック(第1章: 準備運動)、
13日目の記事は[UMA821さん](https://qiita.com/UMA821)の[NimのHashSet, OrderedSet(setsモジュール)と
set(system), PackedSet(packedsets)の違いと速度比較](https://qiita.com/UMA821/items/5172bea2479f5746531a)になります。

## 概要

[言語処理100本ノック](https://nlp100.github.io/ja/)の[第2章: UNIXコマンド](https://nlp100.github.io/ja/ch02.html)をNimで実装していく内容となります。

第2章のタイトル通りUNIXコマンドで実現可能な内容となっているため、
UNIXコマンドをNimから使用する形、UNIXコマンドを使用せず処理する形を併記します。

「今」「Nimで」言語処理100本ノックするならPythonとの比較等が欲しいところですが、
11日目の記事タイトルに触発されてクリスマスに~~突如思い付きで~~書き始めた記事なので、
アドベントカレンダー終了に間に合わなくなりお蔵入りするよりは記事としての完成を優先します。

そのため、ところどころ調査・推敲不足な箇所があるかもしれませんので、
不備等あった際はご指摘いただけましたら幸いです(言い訳)。

## 環境

UNIXコマンドを扱う章の実装となるため、Windows上では動作しません。

- OS: WSL2 Debian
- OS Version: bullseye(11.6)
- Nim Version: 1.6.10

## ビルド・実行

別にビルドできて実行できればオプションは何でも良いですが、
この記事ではシンプルに下記のコマンドでビルド・実行します。
「-d:ssl」は[httpclient](https://nim-lang.org/docs/httpclient.html)で[SSL/TLSを利用する場合に必要](https://nim-lang.org/docs/httpclient.html#sslslashtls-support)になります。

```shell
~$ mkdir -p ${HOME}/言語処理100本ノック
~$ vi ${HOME}/言語処理100本ノック # 実装
~$ nim r --hints:off -d:ssl '言語処理100本ノック/第2章：UNIXコマンド.nim'
```

## 言語処理と関係ない大枠の実装方針

[popular-names.txt](https://nlp100.github.io/data/popular-names.txt)はローカルに保存せず、https経由で取得して参照することにします。

### UNIXコマンドを使用

#### [popular-names.txt](https://nlp100.github.io/data/popular-names.txt)の参照

[curl](https://curl.se/)や[Wget](https://www.gnu.org/software/wget/)の使用が恐らく一般的ですが、折角なのでNim Compilerの[--eval:cmd](https://nim-lang.org/docs/nimc.html#:~:text=%2D%2Deval:cmd)オプションを使用します。
[httpclient](https://nim-lang.org/docs/httpclient.html)の[getContent](https://nim-lang.org/docs/httpclient.html#getContent%2CHttpClient%2C)を使用して取得した文字列を[strip](https://nim-lang.org/docs/strutils.html#strip%2Cstring%2Cset%5Bchar%5D)して[echo](https://nim-lang.org/docs/system.html#echo%2Cvarargs%5Btyped%2C%5D)する程度のため、
import等のオプションやURL部分が長いですがギリギリワンライナーの範囲で参照可能です。

```shell
~$ nim r --hints:off -d:ssl --import:httpclient --import:strutils \
  --eval:'echo newHttpClient().getContent("https://nlp100.github.io/data/popular-names.txt").strip'
~$ curl 'https://nlp100.github.io/data/popular-names.txt' # 👆とおおよそ同等
```

#### Nim上でのUNIXコマンドの実行

[os](https://nim-lang.org/docs/os.html)の[execShellCmd](https://nim-lang.org/docs/os.html#execShellCmd%2Cstring)を使用することで、シェル上でのコマンド実行が可能です。
```nim
import os

discard execShellCmd "echo hoge"
```

### UNIXコマンドを使用せず処理

全体的に下記の流れで処理します。
1. [httpclient](https://nim-lang.org/docs/httpclient.html)の[getContent](https://nim-lang.org/docs/httpclient.html#getContent%2CHttpClient%2C)で参照
2. [strutils](https://nim-lang.org/docs/strutils.html)の[strip](https://nim-lang.org/docs/strutils.html#strip%2Cstring%2Cset%5Bchar%5D)で末尾改行を削除 [^1]
3. [strutils](https://nim-lang.org/docs/strutils.html)の[split](https://nim-lang.org/docs/strutils.html#split%2Cstring%2Cchar%2Cint)で改行単位で分割
4. 3.で分割した各行に対して処理

[^1]: 目的上[stripLineEnd](https://nim-lang.org/docs/strutils.html#stripLineEnd%2Cstring)の方が必要十分ですが、[strip](https://nim-lang.org/docs/strutils.html#strip%2Cstring%2Cset%5Bchar%5D)でも十分です
## ノックしていく

### 10. 行数のカウント

#### UNIXコマンドを使用

本記事はNimが主題のため、UNIXコマンドについての詳細は紹介しません。

```nim
import os

const popularNames = "nim r --hints:off -d:ssl --import:httpclient --import:strutils" &
  " --eval:'echo newHttpClient().getContent(\"https://nlp100.github.io/data/popular-names.txt\").strip'"

# 以降、👆の2行は省略します

discard execShellCmd popularNames & " | wc -l"
```

#### UNIXコマンドを使用せず処理

[大枠の実装方針](#言語処理と関係ない大枠の実装方針)で既に可変長配列化しているため、[len](https://nim-lang.org/docs/system.html#len%2Cseq%5BT%5D)で配列長を参照するだけです。

```nim
import std/[httpclient, strutils]

let popularNames = newHttpClient().getContent("https://nlp100.github.io/data/popular-names.txt")
  .strip.split('\n')

# 以降、👆の2行は省略します(importは追加で必要になった場合、追加分のみimportします)

echo popularNames.len
```

### 11. タブをスペースに置換

#### UNIXコマンドを使用
本記事はNimが主題のため(以降略)

```nim
discard execShellCmd popularNames & " | tr '\t' ' '"
```

#### UNIXコマンドを使用せず処理

[sequtils](https://nim-lang.org/docs/sequtils.html)の[mapIt](https://nim-lang.org/docs/sequtils.html#mapIt.t%2Ctyped%2Cuntyped)で各行に対して処理を行い、[strutils]()の[join](https://nim-lang.org/docs/strutils.html#join%2CopenArray%5Bstring%5D%2Cstring)で結合しています。
各行に対する処理として、[strutils](https://nim-lang.org/docs/strutils.html)の[replace](https://nim-lang.org/docs/strutils.html#replace%2Cstring%2Cchar%2Cchar)で文字・文字列の置換を行っています。

```nim
import std/sequtils

echo popularNames
  .mapIt(it.replace('\t', ' '))
  .join("\n")
```

### 12. 1列目をcol1.txtに，2列目をcol2.txtに保存

#### UNIXコマンドを使用

```nim
discard execShellCmd popularNames & " | cut -f 1 >col1.txt"
discard execShellCmd popularNames & " | cut -f 2 >col2.txt"
```

#### UNIXコマンドを使用せず処理

NimのファイルI/Oは様々な方法がありますが、[system/io](https://nim-lang.org/docs/io.html)の[writeFile](https://nim-lang.org/docs/io.html#writeFile%2Cstring%2Cstring)が手軽です。
要件に合わせて使い分けると良いですが、本記事の主題ではないためここでは触れません。

また、異常系処理の省略として配列の範囲チェックが抜けているため、
URL先のファイルが不正な場合[IndexDefect](https://nim-lang.org/docs/system.html#IndexDefect)が送出されます。

```nim
  writeFile "col1.txt", popularNames
    .mapIt(it.split('\t')[0])
    .join("\n")
  writeFile "col2.txt", popularNames
    .mapIt(it.split('\t')[1])
    .join("\n")
```

### 13. col1.txtとcol2.txtをマージ

#### UNIXコマンドを使用

```nim
discard execShellCmd "paste col1.txt col2.txt"
```

#### UNIXコマンドを使用せず処理

NimのファイルI/Oは様々な方法がありますが、[system/io](https://nim-lang.org/docs/io.html)の[readFile](https://nim-lang.org/docs/io.html#readFile%2Cstring)が手軽です。
また、[a ..< b](https://nim-lang.org/docs/system.html#..%3C.t%2Cuntyped%2Cuntyped)で数値範囲指定のループが可能です。

```nim
let col1 = "col1.txt".readFile.split('\n')
let col2 = "col2.txt".readFile.split('\n')
for i in 0 ..< col1.len:
  echo col1[i], '\t', col2[i]
```

### 14. 先頭からN行を出力

[system/io](https://nim-lang.org/docs/io.html)の[stdin](https://nim-lang.org/docs/io.html#stdin)で標準入力の参照、[system/io](https://nim-lang.org/docs/io.html)の[readLine](https://nim-lang.org/docs/io.html#readLine%2CFile)で入力1行の読込、
[strutils](https://nim-lang.org/docs/strutils.html)の[parseUInt](https://nim-lang.org/docs/strutils.html#parseUInt%2Cstring)で文字列を[uint](https://nim-lang.org/docs/system.html#uint)値に変換可能です。

異常系処理の省略として標準入力の文字列チェックが抜けているため、
[uint](https://nim-lang.org/docs/system.html#uint)で表現不能な数値や数値以外の文字列を標準入力した場合、[ValueError](https://nim-lang.org/docs/system.html#ValueError)が送出されます。


#### UNIXコマンドを使用

```nim
discard execShellCmd popularNames & " | head -n " & $(stdin.readLine.parseUInt)
```

ちなみに、[strformat](https://nim-lang.org/docs/strformat.html)の[&](https://nim-lang.org/docs/strformat.html#%26.m)でPythonの[フォーマット済み文字列リテラル](https://docs.python.org/ja/3/reference/lexical_analysis.html#formatted-string-literals)相当の文字列結合が可能です。

```nim
import std/strformat

discard execShellCmd &"{popularNames} | head -n {stdin.readLine.parseUInt}"
```

#### UNIXコマンドを使用せず処理

[x[a .. b]](https://nim-lang.org/docs/system.html#:~:text=Slice%20of%20a%20sequence%20(both%20ends%20included))、[x[a ..< b]](https://nim-lang.org/docs/system.html#:~:text=Slice%20of%20a%20sequence%20(excluded%20upper%20bound))で部分配列の抽出が可能です。

```nim
echo popularNames[0 ..< stdin.readLine.parseUInt].join("\n")
```

### 15. 末尾のN行を出力

#### UNIXコマンドを使用

```nim
discard execShellCmd &"{popularNames} | tail -n {stdin.readLine.parseUInt}"
```

#### UNIXコマンドを使用せず処理

[len](https://nim-lang.org/docs/system.html#len%2Cseq%5BT%5D)が[int](https://nim-lang.org/docs/system.html#int)を返却するので、[uint](https://nim-lang.org/docs/system.html#uint)値と減算するためキャストしています。
何かの間違いで負の長さが返却される可能性を考慮する場合、適切な範囲チェックが必要です。

```nim
echo popularNames[uint(popularNames.len) - stdin.readLine.parseUInt ..< popularNames.len].join("\n")
```

### 16. ファイルをN分割する

#### UNIXコマンドを使用
全体量が分からなければN分割はできないことから
[split](https://linuxjm.osdn.jp/html/GNU_coreutils/man1/split.1.html)の「-n」オプションと標準入力は両立不能なため、
[popular-names.txt](https://nlp100.github.io/data/popular-names.txt)をローカルに保存しない方針では実現できません。

```nim
discard execShellCmd popularNames & " >popular-names.txt"
discard execShellCmd &"split -n l/{stdin.readLine.parseUInt} popular-names.txt"
discard execShellCmd "rm popular-names.txt"
```

#### UNIXコマンドを使用せず処理

Nimには[条件演算子](https://ja.wikipedia.org/wiki/条件演算子)が存在せず文ではなく式のifを使用している点や、
[uint](https://nim-lang.org/docs/system.html#uint)の除算・剰余に[div](https://nim-lang.org/docs/system.html#div%2Cuint%2Cuint)・[mod](https://nim-lang.org/docs/system.html#mod%2Cuint%2Cuint)している点で、若干長くなっています。[^2]

[^2]: [uint](https://nim-lang.org/docs/system.html#uint)値の端数切り上げ除算ができれば条件演算子は不要ですが、<br>軽く調べた限りでは見つかりませんでした。

```nim
import std/strformat

let
  l = uint(popularNames.len)
  n = stdin.readLine.parseUInt
  linesPerFile = l.div(n) + (
    if l.mod(n) == 0:
      0'u
    else:
      1'u)
for i in 0 ..< n:
  let currentIndex = i * linesPerFile
  writeFile &"popular-names{i}.txt",
    popularNames[currentIndex ..< min(currentIndex + linesPerFile, l)].join("\n") & '\n'
```


### 17. １列目の文字列の異なり

#### UNIXコマンドを使用
```nim
discard execShellCmd popularNames & " | cut -f 1 | sort | uniq"
```

#### UNIXコマンドを使用せず処理

[sets](https://nim-lang.org/docs/sets.html)の[toHashSet](https://nim-lang.org/docs/sets.html#toHashSet%2CopenArray%5BA%5D)で集合に変換することで重複削除、[algorithm](https://nim-lang.org/docs/algorithm.html)の[sorted](https://nim-lang.org/docs/algorithm.html#sorted%2CopenArray%5BT%5D)でソートします。

```nim
import algorithm

echo popularNames
  .mapIt(it.split('\t')[0])
  .toHashSet
  .toSeq
  .sorted
  .join("\n")
```

### 18. 各行を3コラム目の数値の降順にソート

#### UNIXコマンドを使用

```nim
discard execShellCmd popularNames & " | sort -rnk3"
```

#### UNIXコマンドを使用せず処理

[algorithm](https://nim-lang.org/docs/algorithm.html)の[sortedByIt](https://nim-lang.org/docs/algorithm.html#sortedByIt.t%2Cuntyped%2Cuntyped)でソート列の指定、[algorithm](https://nim-lang.org/docs/algorithm.html)の[reversed](https://nim-lang.org/docs/algorithm.html#reversed%2CopenArray%5BT%5D)で配列要素の逆順並べ替えが可能です。

[sorted](https://nim-lang.org/docs/algorithm.html#sorted%2CopenArray%5BT%5D%2Cproc%28T%2CT%29)で比較関数を渡すことでも同等の処理が可能で、
こちらの場合ソート処理の一部として降順指定が可能ですが、比較関数指定が若干面倒になります。

```nim
echo popularNames
  .sortedByIt(it.split('\t')[2].parseUInt)
  .reversed
  .join("\n")
```

### 19. 各行の1コラム目の文字列の出現頻度を求め，出現頻度の高い順に並べる

#### UNIXコマンドを使用する正規の方法

```nim
discard execShellCmd popularNames & " | cut -f 1 | sort | uniq -c | tr -s ' ' | sort -rn | cut -d ' ' -f 3"
```

#### UNIXコマンドを使用せず処理

[tables](https://nim-lang.org/docs/tables.html)の[Table](https://nim-lang.org/docs/tables.html#Table)で出現頻度を保持しています。[^3]

[^3]: [sequtils](https://nim-lang.org/docs/sequtils.html)の[foldl](https://nim-lang.org/docs/sequtils.html#foldl.t%2C%2C%2C)で上手いこと集計できればforとメソッドチェーンが混在せず<br>1文で実現可能な気はしますが、気のせいかもしれません。

```nim
var counts: Table[string, int]
for name in popularNames.mapIt(it.split('\t')[0]):
  if counts.contains(name):
    counts[name] += 1
  else:
    counts[name] = 1
echo counts
  .pairs
  .toSeq
  .sortedByIt(it[0]) # 出現頻度が同じ場合は名前でもソートする、要件上必須ではない処理
  .sortedByIt(it[1])
  .reversed
  .join("\n")
``