nunulkのプログラミング徒然日記

毎日暇なのでプログラミングの話題について書きたいことがあれば書いていきます。

私が衝撃を受けたプログラミング言語5選

はじめに

この記事について

以下の記事に触発されて自分でも書いてみました。

yoric.github.io

プログラミング言語が好きなので、気になったものを見つけると触るようにしているんですが、ほとんどは新たな発見や驚くような便利な機能があって、毎回感心させられます。仕事ではなかなかあれもこれもというわけにいかないのが残念ではありますが、個人で使うアプリケーションや使い捨てのプログラムを書くときには、なるべくいろんな言語で書くようにしています。

仕事と趣味を合わせて20以上の言語に触れてきましたが、それらのなかでもとくに衝撃的だった言語をいくつか選びました。

これまで触ってきた言語

だいたい時系列順ですが、似たものは近くに寄せてたりするので、順不同です。

趣味

BASIC (MS BASIC), Smalltalk, Nim, Scala, Clojure, Haskell, Kotlin, R, F#, Rust, Gleam

仕事

C, C++, Java, PHP, Perl, JavaScript, TypeScript, SQL, Visual Basic, VBScript, C#, Object Pascal (Delphi), Ruby, Python, Elixir

衝撃を受けたプログラミング言語5つ

  1. C++
  2. PHP
  3. Nim
  4. Clojure
  5. Gleam

1. C++

初めて書いたのは BASIC でしたが(中学生)、仕事で最初に覚えたのは C でした。最初の1,2年 C でミドルウェアやデバイスドライバを書いていて、その後、C++にシフトしていきました。昔の記憶がほぼないので(記憶力が壊滅的に悪いため)、なんとなくの感覚でしか思い出せないんですが、いちおういくつか衝撃だったポイントを書いていきます。

衝撃ポイント

  1. クラス
  2. オーバーロード
  3. テンプレート

C++ との出会いはオブジェクト指向言語との出会いでもあるので、多分にオブジェクト指向の特徴も入っているんですが、とくに衝撃だったのは、Microsoft の MFC(Microsoft Foundation Class) でした。Win32 API という C ベースの API と比較すると MFC は格段に使いやすく感じたのを覚えています。ライブラリの設計の良し悪しもあったんだろうと思いますが、C++ って便利だなぁと思ったのは確かです。

その後、Linus Torvalds さんが C++ は◯ソみたいなことを言ってるのを読んで、わかる…と思ったのも事実ですが、柔軟性と堅牢性のトレードオフなのかなとも感じます。

あと、これはおそらく分量を書いていく中で変わった点だと思われますが、BASIC も C もコンピューターに命令を送るための文という意識だったのが、C++ に出会って表現を意識するようになりました。クラスやテンプレートという概念が入ってきて格段に設計が難しくなったというか、表現の幅が広がった感じがしました(そのぶん楽しかったですが)。

2. PHP

Perl と PHP どちらが先だったか忘れましたが、Perl に衝撃を受けた記憶がないのでたぶん PHP が先なんだと思います。バージョン 4 が出たばかりで、3 を最初に読み書きした記憶がうっすらあります。ウェブ黎明期に Perl で CGI を書いたり、PHP で簡単なウェブページを作ったりして遊んでいたら、仕事でも使うようになりました。自分にとってもっとも長く多く書いている言語です。

C++ がオブジェクト指向言語との初めての出会いで、PHP はインタプリタ言語との初めての出会いでした。

衝撃ポイント

  1. 書けばすぐ動く
  2. 動的型付け
  3. テンプレート構文(HTMLに埋め込むやつ)

C++ のあとは Java を使うようになりましたが、いずれもコンパイル型で、コンパイルしている間は待つのが当たり前でしたが、PHP はインタプリタ型なので、書いたらすぐ動くというのが衝撃でした。

C で CGI を書いたときや Java の JSP + Servlet を使っていたときに比べて、格段に楽になったと感じた記憶があります。一方で、HTML にロジックが入り込む、あるいは逆にロジックに HTML が入り込むことで、ひどく複雑になっているプロジェクトも経験したので、あまり他人が書いた PHP コードのメンテナンスしたくありませんでした(昔は自動テストを書く文化もほぼなかったですし)。

3. Nim

一時期「至高のプログラミング言語」として一部界隈で一瞬の輝きを見せた Nim。Python 風の構文を持つ静的型付け言語で、C や JS にトランスコンパイル可能な、マルチパラダイムな汎用言語です。

インデントブロックは好みが分かれるところだとは思いますが、私は好きです。

衝撃ポイント

  1. とにかく速い
  2. メソッド呼び出し構文
  3. template æ§‹æ–‡

とくに衝撃だったのは、メソッド呼び出し構文でした。Nim では通常 プロシージャ名(引数1, 引数2) の構文でプロシージャを実行しますが、 引数1.プロシージャ名(引数2) のように、オブジェクト指向言語のメソッドのように呼び出すことができます。この「どっちでもいい」っていう仕様というか思想は他の言語では見たことがなかったので、けっこう衝撃でした。

from std/strformat import fmt

type Person = object
  name: string

proc greetTo(person: Person, greeting: string = "hello") =
  echo fmt"{greeting}, {person.name}!"

let person = Person(name: "John")

# 下の書き方がメソッド呼び出し構文
# greetTo(person) => hello, John!
person.greetTo("goodby") # => goodby, John!

# プリミティブ型に対してもできる
# succ(10) => 11
10.succ()

冷静に考えると、複数の書き方があるとそれらが混ざってしまって、チーム開発だと混乱するかなとも思うんですが、メソッドチェーンがしたいときだけに限定するなど、ルールがあれば活用できるかなとも思います。

maxIndex(items.map(item => item.x))
# こっちのほうが読みやすい
items.map(item => item.x).maxIndex()

オブジェクト指向的に使うケースでもメソッド呼び出し構文を使うようにしてもいいかもしれません。method キーワードを使うと、オブジェクト指向言語のような多相性を持たせられます。

type Person = ref object of RootObj
  name: string
  age: int

type Student = ref object of Person
  id: string

method toString(person: Person): string {.base.} =
  person.name

method toString(student: Student): string =
  fmt"{student.name}({student.id})"

let person = Person(name: "Taro", age: 20)
let student = Student(name: "Jiro", age: 17, id: "00000002")
let people = [person, student]

for p in people:
  echo p.show()
# Taro
# Jiro(00000002)

template 構文もポイント高いです。マクロの一種なんですが(macro 構文も別に存在しています)、AST を扱わなくて済むように設計されています。これまであまりマクロを使える言語を使ってこなかったので、C のマクロとの比較になってしまいますが、より普通の関数っぽい書き方ができます(最近だと Rust のマクロもわりと特殊な書き方しないといけない感じです)。

template withFile(f, fn, mode, actions: untyped): untyped =
  var f: File
  if open(f, fn, mode):
    try:
      actions
    finally:
      close(f)
  else:
    quit("cannot open: " & fn)

withFile(txt, "ttempl3.txt", fmWrite):
  txt.writeLine("line 1")
  txt.writeLine("line 2")

上のコードは公式ドキュメント(https://nim-lang.org/docs/manual.html#templates)からの引用ですが、コードブロックも引数として受け取れるのでテンプレートの中も呼び出し側もとてもわかりやすいです。

この仕組みを考えた人は天才だな、と思いました。

とはいえ、template の利点はあるもののたいていのケースでは関数(Nim ではプロシージャ)で事足りますし、マクロは使いすぎると保守性が下がるので、実際にはほとんど使っていませんが、他の言語を使っているときも、マクロほしい、と思うことがたまにあるので、Nim のテンプレートはいまのところ理想形の一つではあります。

4. Clojure

Clojure は JVM 上で動作する Lisp の一種です。Lisp に関しては、一時期 Emacs を使っていたこともあり、多少の読み書きはできたものの、Lisp 信者になったのは Clojure のおかげです。

私は「プログラミング言語は道具」と思っているのであまり特別な思い入れは持たないですが、Clojure だけは別というか、語ると思いの丈が溢れてくるような気がします。

衝撃ポイントもたくさんあるんですが、とくに他の言語ではあまり味わえない3点に絞って列挙します。

衝撃ポイント

  1. S式の美しさ
  2. スレディングマクロ
  3. ケバブケース

Lisp(S式)の美しさについては、多くの人が言及しているので、なにをいまさらという感じもします。なんなんでしょうね、この吸引力。もちろん、どこが美しいのかまったくわからんという方もいるとは思うので、一部の人に強烈な好印象を抱かせる謎の魅力みたいなのがあるんだと思います。

(map inc (filter even? (take 10 (range))))
;; => (1 3 5 7 9)

Emacs Lisp で初めて Lisp に出会ったわけですが、そのときの第一印象は、うげぇなんだこれ、でした。次第に慣れたものの、やはり Emacs をいじるために仕方なく書く、という意識だったためか、美しいとはまったく思いませんでした。

それが、のちに Clojure を知り、趣味でたまにプログラムを書くにつれ、まんまとS式の魅力にハマってしまった、という次第です。

一方で、スレディングマクロについては、その便利さは圧倒的でした。

数値のリストをそれぞれ0️で前詰めした5桁の文字列に変換し、カンマ区切りで一つの文字列に繋げたのち、カンマをスラッシュに置き換えて出力する、という処理で見てみます(ちょっと苦しいですが、例なのでご容赦を)。

before

(println (replace (join "," (map #(format "%05d" %) [1 2 3])) #"," "/"))

after

(-> [1 2 3]
    (->> (map #(format "%05d" %))
        (join ","))
    (replace #"," "/")
    (println))

Lisp は前置記法なので、処理が連続すると可読性が低下しますが、スレディングマクロを利用することで処理の順序が明確になり、可読性も上がります。

スレッドファースト( -> )が、引数が1番目にくるバージョンで、スレッドラスト( ->> )が最後にくるバージョンです。

Clojure のスレッディングマクロに出会ったせいで、もうパイプライン演算子のない言語には満足できない身体になってしまいましたし、あれからいくつかの素晴らしい言語に出会いましたが、これを超える衝撃はまだないんじゃないでしょうか。

(これらに加えてまだ ->as, some->, cond-> とか色々面白いバリエーションがあるんですよ…たまりませんね…)

最後はケバブケースなんですが、これは細かいけど地味に効くやつです。たいていの言語はスネークケースですが、 - と _ だとShiftキーを押すかどうかで労力が違います。Lisp ならではなので、他では味わえない魅力です。

5. Gleam

Gleam は今年(2024年)にバージョン 1.0 になったばかりの比較的新しい言語です。どういう経緯で出会ったかは忘れましたが、Nim と同様に Erlang と JavaScript にトランスパイルできる静的型付け関数型言語、というところに惹かれました。

BEAM(Erlang VM)上で動作しますが、Erlang で書かれたモジュールを FFI することも可能です(ターゲットが JavaScript の場合は JavaScript のモジュールを FFI できます)。

なお、以前の記事でも言及していますが、新しい言語であるためシンタックスハイライトに対応していないので Rust のを借用します。

衝撃ポイント

  1. 型システム
  2. if がない
  3. パイプのプレースホルダー

型定義はすべて type キーワードを使います。列挙型(enum)がなく、直和型を以下のように表現します。

type Message {
  Start
  End
}
fn send_message(message: Message) {
  case message {
    Start -> io.println("Starting...")
    End -> io.println("End")
  }
}
send_message(Start)
send_message(End)

一方で、直積型は以下のよう書きます。

type Message {
  Message(from: String, to: String, body: String)
}
fn send_message(message: Message) {
  io.println("from: " <> message.from)
  io.println("to: " <> message.to)
  io.println("body: " <> message.body)
}
send_message(Message(from: "alice", to: "bob", body: "Hello"))

enum キーワードを持つ言語もありますが、個人的には、このシステム(直和型も直積型も同じ構文で書く)は目から鱗というか、盲点でした。

続いては、if がない点です。厳密には if キーワード自体はあるんですが、あくまでも case 式の中だけで使えるもので、条件分岐はすべて case 式で行います。

関数型言語なので、すべてが式であり、for/while 文はなく、ループも再帰呼び出しで実装しますが、if/else がないのは驚きました。

case 式の各アームは同じ型である必要があるため、複雑な条件式になるとちょっと実装に詰まったりしますが、むしろ条件式をシンプルに保とうという圧力がかかるので、個人的には気に入っています。

fn double_if_event(n: Int) -> Int {
  case n % 2 == 0 {
    True -> n * 2
    False -> n
  }
}
[1, 2, 3]
|> list.map(double_if_event)
|> io.debug // => [1, 4, 3]

最後に、パイプのプレースホルダーですが、引数の順番によってはパイプを繋げるのが難しいケースがあるので、位置を指定する構文です。

Clojure のスレッドファースト/スレッドラストよりも柔軟な方法です。パイプラインに渡ってくる引数が必ず先頭にあるとは限らないので、 _ を用いて位置を指定します。

コードを見たほうがはやいです。

// pipe なし
string.append("hello", string.append(", ", "world!"))
// pipe あり
", "
|> string.append("world!")
|> string.append("hello", _)
// hello, world!

パイプ演算子のある言語では、多くの処理をパイプラインで書くことになるので、こうした細かい使い勝手の良さは積み重なって、結果的に大きなものになります。

おわりに

いやー、プログラミング言語って本当にいいものですね。

最後に唐揚げつまんでみたプログラムを Nim, Clojure, Gleam で書いてみたので、興味のある方は比較がてら見てみてください。

qiita.com

Nim

from std/strformat import fmt
from std/sequtils import map, maxIndex
import std/sugar

type Bento = object
  name: string
  count: int

proc toString(bento: Bento): string =
  return fmt"(name: {bento.name}, count: {bento.count})"

type Bentos = openArray[Bento]

proc report(i: int, bentos: Bentos) =
  echo fmt"{i + 1}回目"
  for i in 0..bentos.high:
    echo toString(bentos[i])

func findMaxIndex(bentos: Bentos): int =
  bentos.map(b => b.count).maxIndex()

proc snitchOne(bentos: var Bentos) =
  let i = findMaxIndex(bentos)
  if bentos[i].count > 0:
    dec(bentos[i].count)

proc run() =
  var bentos = [
    Bento(name: "唐揚げ", count: 10),
    Bento(name: "唐揚げ", count: 8),
    Bento(name: "唐揚げ", count: 6),
  ]
  const tryCount = 5
  for i in 0..<tryCount:
    snitchOne(bentos)
    report(i, bentos)

when isMainModule:
  run()

Clojure

(ns karaage.core
  (:gen-class))

(require '[clojure.string :refer [join]])

(defrecord Bento [name count])

(defn- format-bento [bento]
  (format "(name: %s, count: %d)" (get bento :name) (get bento :count)))

(defn- report [bentos n]
  (println (str (+ n 1) "回目" "\n" (join "\n" (map format-bento bentos)))))

(defn- find-max-count [bentos]
  (->> bentos
      (map :count)
      (apply max)))

(defn- find-max-index [bentos map-fn]
  (let [maxVal (find-max-count bentos)]
    (->> (keep-indexed vector bentos)
         (filter #(= (map-fn (second %)) maxVal))
         (ffirst))))

(defn- dec-count-unless-zero [bento]
  (if (> (get bento :count) 0)
    (update bento :count dec)
    bento))

(defn- snitch-one [bentos]
  (update bentos (find-max-index bentos #(get % :count)) dec-count-unless-zero))

(defn- do-run-while [count bentos]
  (loop [bentos (snitch-one bentos) n 0]
    (when (< n count)
      (report bentos n)
      (recur 
       (snitch-one bentos)
       (inc n)))))

(defn run
  []
  (let [try-count 5
        bentos [
                (->Bento "唐揚げ" 10)
                (->Bento "唐揚げ" 8)
                (->Bento "唐揚げ" 6)]]
                
    (do-run-while try-count bentos)))     

(defn -main [] (run))

Gleam

import gleam/function
import gleam/int
import gleam/io
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/result

type Bento {
  Bento(name: String, count: Int)
}

fn to_string(bento: Bento) -> String {
  "(name: " <> bento.name <> ", count: " <> int.to_string(bento.count) <> ")"
}

fn max_loop(ns: List(Int), n: Int) -> Option(Int) {
  case ns {
    [] -> None
    [a] -> Some(int.max(a, n))
    [a, ..rest] -> max_loop(rest, int.max(a, n))
  }
}

fn max(ns: List(Int)) -> Option(Int) {
  max_loop(ns, 0)
}

fn find_max_index(bentos: List(Bento), max: Int) -> Int {
  bentos
  |> list.index_map(fn(v, i) { #(v.count, i) })
  |> list.key_find(max)
  |> result.unwrap(0)
}

fn dec_count(bentos: List(Bento), index: Int) -> List(Bento) {
  let bentos_with_index = list.index_map(bentos, fn(v, i) { #(i, v) })
  let assert Ok(bento) =
    list.find(bentos_with_index, fn(item) { item.0 == index })
    |> result.map(fn(v) { v.1 })
  list.key_set(
    bentos_with_index,
    index,
    Bento(..bento, count: int.max(bento.count - 1, 0)),
  )
  |> list.map(fn(v) { v.1 })
}

fn snitch_one(bentos: List(Bento)) -> List(Bento) {
  let max_count = list.map(bentos, fn(bento) { bento.count }) |> max
  case max_count {
    None -> bentos
    Some(count) -> count |> find_max_index(bentos, _) |> dec_count(bentos, _)
  }
}

fn show(bentos: List(Bento), n: Int) {
  io.println(int.to_string(n + 1) <> "回目")
  bentos
  |> list.each(fn(bento) { bento |> to_string |> io.println })
}

fn run_while_loop(bentos: List(Bento), from: Int, to: Int) {
  case from >= to {
    True -> Nil
    False ->
      bentos
      |> snitch_one
      |> function.tap(fn(bentos) { show(bentos, from) })
      |> run_while_loop(from + 1, to)
  }
}

fn run_while(bentos: List(Bento), try_count: Int) {
  run_while_loop(bentos, 0, try_count)
}

fn run() {
  let bentos = [
    Bento("唐揚げ", 10),
    Bento("唐揚げ", 8),
    Bento("唐揚げ", 6),
  ]
  let try_count = 5
  run_while(bentos, try_count)
}

pub fn main() {
  run()
}

Clojure がいちばん短く書けると思ってたけど、Nim が最も短い行数でした(Nim: 39, Clojure: 51, Gleam: 89)、意外。