Rustの実用性が理解できる図を作成してみた 〜C/C++/Java/JS/Python/Go/TS/Elixirとの比較〜

この記事はRust Advent Calendar 2022 - Qiitaの11日目の記事です。
(Zennに投稿した記事と同じものです。)

Rustはプログラマに愛されている言語だと言われています。でも、その愛されている理由をRustを知らない人に説明しようとしたとき苦労した経験はないでしょうか?たくさんの愛を語れば語るほど 「難しそう」 という一言に心を砕かれるのです。

私はRustが多くのプログラマに愛される理由はRustが多くの場面で実用的だからだと思っています。しかしプログラミング言語における「実用性」を定義するのも説明するのも非常に困難を極めるため、Rust布教の大きな壁になっていると感じています。

そこで本記事ではRustの実用性の図解にチャレンジしてみようと思います。

はじめに

本記事は、何らかのプログラミング言語の経験を持っているがRustを使ったことがない、またはRustの良さが分からない方を対象としています。またRust愛好者でRust未経験者にRustの良さを伝えるのに苦労した経験がある方にも読んで頂きたい内容となっております。Rustの実用性を図解を通して視覚的にお伝えできればと思います。

本記事は複数の言語の比較を行いますが、特定の言語を貶める意図はありません。本記事で提供するのは特定の切り口(実用性)に沿った比較に過ぎず、それが言語の意義や魅力の全てを決めるわけではないことをご了承ください。

前提

まず 「実用性」 の定義ですが、本記事では 「プログラミング言語の様々なユースケースで実務レベルで対応できる広さ」 とします。要は一つのプログラミング言語で万能ナイフのように場面を選ばず使える言語の方が 「実用性が高い」 とします。

一方で特定の場面で利便性が高い言語というのも存在します。それらは 「DSL(ドメイン固有言語)」 と呼ばれていていますが、本記事はその有用性を否定するものではありません。本記事の対象はDSLとは正反対の 「汎用言語」 であり、その性能を語る上で適用範囲の広さを軸にした 「実用性」 測るのは意義深いことだと考えました。

言語比較

Rustの実用性を伝えるには、汎用プログラミング言語の実用性を見るときの観点が必要になってきます。ここでは私の経験と論理的思考(独断と偏見とも言う)に従って実用性を判断するための20の観点をピックアップしました。

比較する言語は現在多くのプログラマに利用されていて実用性が明白な言語を選択しました^1

以下はその観点に従って言語を比較したマトリックスになり、他の言語との比較によってRustの比較優位性を示したいと思います。
※ ASMはアセンブリ言語、JSはJavaScript、TSはTypeScriptを指す。

ASM C CPP Java JS Python Go TS Elixir Rust
エディタ支援 o o o
自動テスト o o o
リンタ o o
ビルドシステム o o o
パッケージマネージャ o o o o
フォーマッタ o o o
手続き型 o o o o o o o o
オブジェクト指向 o o o o o o o
関数型 o o
前処理性能 o o o
実行時性能 o o o o
メモリ効率 o o o o
メモリ安全性 o o o o o o o
型安全性 o o o o
スレッド安全性 o o
インタプリタ o o o
トランスパイラ o
コンパイラ o o o o o o
VM o o
アセンブラ o

各行はプログラミングの実用性を判断するために必要な観点です。言語の特徴は正確な分類が困難なため多少の独断と偏見が含まれていることをご了承ください。

各行の意味
  • エディタ支援
    • 言語の公式がエディタ支援(LSP等)を提供しているかを示しています。
  • 自動テスト
    • 言語の公式が自動テスト(ユニットテスト等)を備えているかを示しています。
  • リンタ
    • 言語の公式がリンタを提供しているかを示しています。
  • ビルドシステム
    • 言語の公式がビルドシステムを提供しているかを示しています。
  • パッケージマネージャ
    • 言語の公式がパッケージマネージャを提供しているかを示しています。
  • フォーマッタ
    • 言語の公式がフォーマッタを提供しているかを示しています。
  • 手続き型
    • 言語のプログラミングパラダイムが手続き型プログラミングを強くサポートしているかどうかを示しています。
  • オブジェクト指向
    • 言語のプログラミングパラダイムがオブジェクト指向プログラミングを強くサポートしているかどうかを示しています。
  • 関数型
    • 言語のプログラミングパラダイムが関数型プログラミングを強くサポートしているかどうかを示しています。
  • 前処理性能
    • 実行の前処理の時間が短い程、前処理性能は良いものとここでは定義します。
    • ここで言う「前処理の時間」とはユーザから見てプログラム実行前に必要な時間になります。
    • 例えば、コンパイル言語ではコンパイル時間やリンク時間に該当します。
    • スクリプト言語はすぐに実行されるので前処理時間が存在せず、前処理性能は良いものとします。
  • 実行時性能
    • プログラムの実行時間が短いほど実行性能が良いものと定義します。
    • コンパイル型の言語では前処理で最適化処理をかけて実行性能を上げたりします。
  • メモリ効率
    • プログラムの実行時間帯でどれだけメモリを効率的に使えたかかを示します。
    • メモリのフットプリントが大きいとメモリ効率が悪く、フットプリントが小さいとメモリ効率が良いものとします。
  • メモリ安全性
    • バッファオーバーフローやダングリングポインタ等のメモリアクセスに関するバグやセキュリティホールから守られている場合、メモリ安全性を満たしている状態になります。
  • 型安全性
    • 型によってプログラムの不正や脆弱性を検知できる場合、型安全性が高いものとここでは定義します。
    • ここでは比較のために動的型よりも静的型の方が早く型エラーを検知できる分、型安全性が高いものとします。
    • そのため動的型の型安全性はここでは△としています[^2]。
  • スレッド安全性
    • 並行処理の共有リソースの競合に対する安全性がある場合にスレッド安全性があるものとします。
    • リソース競合を回避する仕組みだけが用意されていて、実際に簡単に競合が起こせるものは△とします。
    • スレッドやリソース競合に関する概念がそもそも言語本体にない場合は、スレッド安全性はないものとします。
  • インタプリタ
    • 言語の最も主要な処理系がインタプリタ型の処理系であることを示しています。
    • スクリプト言語(LL言語)に多い処理系です。
  • トランスパイラ
    • 言語の最も主要な処理系がトランスパイラ型の処理系であることを示しています。
    • TypeScriptやDartなどAltJSの処理系が含まれます。
  • コンパイラ
    • 言語の最も主要な処理系がコンパイラ型の処理系であることを示しています。
  • VM
    • 言語の最も主要な処理系がVM(仮想マシン、バイトコードインタプリタ)を採用していることを示しています。
    • この場合VMが一般のユーザから見える状態のものを指します。
    • 言語処理系の内部実装でVMと近い処理をしていもユーザから見えなければVMに該当しないものとします。
  • アセンブラ
    • 言語の最も主要な処理系がアセンブラ型の処理系であることを示しています。

 
プログラミング言語の実用性を判断する上で考慮した点は言語の特性もそうですが、 「言語が公式に提供するツール」 にも着目しています。それはプログラミングにおいてツールのサポートがなくては小規模ならまだしも大規模なプログラムを書くのは困難なのでだからです。ここで「公式」に絞っているのは、判断のしやすさもありますがやはり公式の実装は安定していて安心でき、迷わず利用可能なことから「実用性」の根拠に足るものだと判断したためです。

本当は実用性を判断するには 「ドキュメントの充実度」「エコシステムの充実度・活発度」「スポンサーの強さ、多さ」 なども判断基準にすべきだと思いますが、これらは判断が難しく様々な要因で揺れ動くので、今回は言語特性とその特性から本質的に影響を受けている中長期的に安定した性質にフォーカスすることにしました。

言語の実用性判定マップ

前節の表で 「完全に理解した」 という方は、恐らくプログラミング言語に造詣が深い方だと思われます。実際に分かる人にはあの表だけでプログラミング言語の得意なシーンや苦手な分野が見えて来るはずです。そして○が多い方が様々な分野で利用可能な言語だと言うことが何となく分かるかもしれません。

しかし現実的にあの表からプログラミング言語の実用性を判断するのは確かに難易度が高いのでもうひと工夫します。具体的には観点を5つのカテゴリに分類し、さらに一本の軸に沿ってそのカテゴリを整列させます。これを 「言語の実用性判定マップ」 と呼ぶことにします。以下がその図になります。

言語の実用性判定マップ

1つの軸

言語の実用性を判断する要は ユーザビリティ/効率・信頼性 の軸です。これら2つの要素は必ずしも背反するものではありませんが、多くの場合 トレードオフの関係 になります。

ここで言うユーザビリティは単純にユーザ(人間)に分かりやすいという意味だけでなく、プログラムの規模が大きくなってもユーザビリティが落ちにくいという意味も含まれています。一般にプログラムの規模が小さい場合にはどの言語も容易にユーザが把握可能ですが、規模が大きなるほどユーザが把握しづらくなり、言語の抽象化の能力やツールの力が発揮されることになります。

効率・信頼性はプログラムが ハードウェア(CPU/メモリ等)にとってどれだけ扱いやすいか を示しています。CPUは非常に単純な実行モデルなので、CPUに優しく効率の良いプログラムを書こうとすると究極的には「0」と「1」の2値だけでプログラミングするハメになります。またCPUは曖昧さを許してくれないので、人間が犯しやすそうなミスに(メモリアクセス違反等)を検知できるような信頼性が必要になります。

5つのカテゴリ

「ユーザビリティ/効率・信頼性」の軸に沿って並べられるのは、 「ツール」、「パラダイム」、「性能」、「安全性」、「言語基盤」 の5つのカテゴリです。

「ツール」 人間が実際に操作するものでそもそもユーザを補助する目的で作られるもので、高いユーザビリティが必要とされるため図では上に配置されます。

「パラダイム」ユーザビリティ/効率・信頼性の軸の中間に位置して、人間(ユーザ)と機械(CPU)をつなぐ役割を果たします。サポートするパラダイムは多ければ良いというものではありませんが、ここに上げた「手続き型」、「オブジェクト指向」、「関数型」の3つのパラダイムは広く認知されており多くの有用性が認められているので、全てのパラダイムを上手に取り入れた言語の実用範囲は広がることになります。

「性能」「安全性」 はハードウェア(CPUやメモリ等)を活かすために必要な特性なので一番下(効率・信頼性重視)として配置されています。

「言語基盤」 は 「ユーザビリティ/効率・信頼性」の全般に関わるので縦長の配置になっています。上の方の特徴(インタプリタ・トランスパイラ)はユーザビリティを重視した設計が求められており、下の方のVM・アセンブラは効率・信頼性を重視した設計が行われることが多いので内部で軸を意識した配置にはしています。

さて、ここまででようやく各言語の比較ができるようになりました。

言語比較

早速、言語の実用性判定マップを使って言語比較を行ってみたいと思います。簡単な言語の説明も入れていますが、主題ではないのでかなり大味で簡略したものになっていることをご了承ください。

C言語とC++

まずは定番のC/C++の比較から行っていきます。

CとC++は同じコンパイル型の言語ですが、C++はオブジェクト指向言語であり、Cと比較して大規模なコードベースや抽象化に向いています。C++の特徴としてはオブジェクト指向を追加するにあたって、実行時性能とメモリ効率を犠牲にしないという選択肢を取っています。そのためにコンパイラが非常に頑張っていますが、エラーメッセージが難解になるパターンも多いです。

JavaとJavaScript

これもまたよくネタにされやすいJavaとJavaScriptの比較をやっていきます。

JavaとJavaScriptはパラダイムこそ同じですが、性能と安全性の守備範囲が大きく異なります。Javaはコンパイル型言語で静的型付けなのでメモリ安全性と型安全性はしっかり守られます。また言語自体に並行性に関する機能も取り入れられています。しかしリソース競合に関してはユーザの注意努力に頼るところが大きいので、スレッド安全性は半分になっています。実行時性能はJava VMの最適化がすごいおかげでそこそこ良好ですがC/C++と比較すると明らかに劣ります。

JavaScriptはインタプリタ型の言語でコンパイル等の前処理がいらず、すぐに実行できることから、前処理性能は良好です。ただその皺寄せは実行時性能にいっています。安全性に関してはメモリ安全性は確保していますが動的型付けなので型安全性は半分にしています。JavaScriptは伝統的に言語仕様に並行性を含まないので、スレッド安全性はなしにしています^3

PythonとGo

最近はライバル認定されやすいPythonとGo言語も比較してみます。

Pythonは典型的なインタプリタ言語でLL(LightWight)言語の特性を持ち、日々のタスクを回すのに最適なスクリプト言語です。スクリプト言語の特徴は前処理性能でコードを書いて即実行できます。コンパイル処理の間にコーヒーを入れる必要はありません(笑)。物議を醸しそうなのはパッケージマネージャでしょうか。Pythonのパッケージングには紆余曲折あり今後も変わりゆく予感はありますが、 「pip」 が標準添付されているので「パッケージマネージャ」を○にしました。

Go言語も典型的なコンパイル言語と言いたいところですが、天下のGoogle様が設計しただけあって性能が見慣れない光景になっています。よく言えばバランスが取れており悪く言えば中途半端になっています。この性能の特徴からシステムプログラミングにおいてはミドルウェアで良く用いられますが、OSや組み込みプログラミングなどの性能要件がシビアな世界ではC/C++の代替にはなれていません。ツールは後発の言語だけあって充実しており、大規模なコードベースでも、複数人の開発でもユーザビリティを損なうことなく開発可能です。

TypeScriptとElixir

最後によく話題に登る熱い言語を持ってきました。

TypeScriptはフロントエンド界隈では飛ぶ鳥落とす勢いで、人気だけ見るとJavaScriptを大きく引き離しており、OSSでもTypeScriptの採用が目立ってきました。TypeScriptはトランスパイル^4という処理でJavaScriptに変換されて、JavaScriptのインタプリタで実行される言語です。JavaScriptは動的型付けのため人間のミスによる実行時エラーに悩まされましたが、TypeScriptは静的型のため実行前に型チェックして未然に多くのミスを防ぎます^5。そのためJavaScriptと比較して実行前性能を犠牲にしていますが、それを上回る利点を型安全性から得ているという評価です。

ちなみに静的型付けは 「型安全性」 にカテゴライズされて、どちらかというと「機械」のための性質に見えますが、結果的にツールや言語基盤のサポートを受けやすくユーザビリティに間接的に影響を与えるという複雑な構造になっています。このようなところが言語比較でなかなか理解しづらい難しいところです。

 Elixirはこれまでの言語と比較すると比較的マイナーですが、Stack Overflowの2O22年の調査でプログラマに愛される言語として Rustに次ぐ2位の座を奪い取ったアチアチの言語 です。Elixirは関数型言語として唯一のエントリーですが、注目すべき特徴はスレッド安全性です。これはElixirの基盤となっているErlang言語とBEAMと呼ばれるErlang VMの特性に由来します^6。Elixirも後発の言語らしくツールが非常に充実しており、その辺りも人気に繋がっていると考えられます。

Rustの実用性を確認する

そして満を持してのRustの登場です。

ここまで辿り着いた方は図を見ただけで一目瞭然でしょう。性能と安全性をここまで両立させてしまったことに驚愕せざるを得ません。 これは 「マルチパラダイム」 ^7という豊富な抽象化の武器を活かして、性能を殺さずに安全性を提供するという言語の創意工夫コンパイラの多大な尽力 のおかげです。

そして標準で装備されているツールの豊富さも実用性が優れている根拠の一つです。ある程度の規模の開発ではこの図に出てくるツール一式を揃えてようやく 「開発環境」 として成立しますが、これらが標準で備わっていない言語ではツール探しやその組み合わせ/連携で非常に苦労することが多い印象です。そしてようやく覚えたツール群も流行り廃りが激しくて数年後にはオワコンになっていたりと心配は尽きません。その点Rustでは言語を覚えたてでも実務レベルですぐに使える開発環境を悩むことなく手に入れることができるので、 「ブラボー」 としか言いようがありません。

図にも載せてある 効率的で信頼できるソフトウェアを誰もがつくれる言語というのはRust本家が掲げている公式のテーゼですが、この言葉からもRustが高い実用性を自負している様子が窺えます。

言語の得意分野をレーダーチャートにしてみる

本来なら前節で終わりにする予定でしたが、コードを載せずにTech記事を名乗るとイマイチな感じがしたので強引にRustコードを載せるべくネタを考えました。
プログラミングが活躍する適用分野を5つ選択してかなり強引ですが以下のような特色があると仮定します。

適用分野 前処理
性能
実行時
性能
メモリ
効率
メモリ
安全性

安全性
スレッド
安全性
抽象度 コード
規模
Webアプリケーション 4 1 1 3 2 1 4 2
システム開発 1 3 4 1 1 2 1 3
日常タスク自動化 4 0 0 3 1 0 4 1
ゲーム開発 1 2 2 2 2 2 4 3
分析・機械学習 2 2 1 3 2 2 4 1

コード規模はツール、抽象度はパラダイムの特性を反映させることにします。逆に言語基盤の特性はその他の特性に反映されていると仮定します。上記の表と言語比較で紹介した表を利用して各言語がどの分野に適しているのかを計算してみます。その結果をレーダーチャートにしたのが以下になります。細かく見るとツッコミどころは多々ありますが、感触は悪くないと思いました。

ソースコードは以下になります。言語毎に適用分野のスコアを10段階で計算しています。強引な計算も色々入っているのでつっこみは不要です。

Rustコード
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
#[derive(Debug)]
struct LangProp {
editor_support: u8,
auto_test: u8,
linter: u8,
build_system: u8,
package_manager: u8,
formatter: u8,
procedural: u8,
object_oriented: u8,
functional: u8,
preparation_performance: u8,
execution_performance: u8,
memory_efficiency: u8,
memory_safety: u8,
type_safety: u8,
thread_safety: u8
}

impl LangProp {
fn new(editor_support: u8, auto_test: u8, linter: u8, build_system: u8, package_manager: u8, formatter: u8, procedural: u8, object_oriented: u8, functional: u8, preparation_performance: u8, execution_performance: u8, memory_efficiency: u8, memory_safety: u8, type_safety: u8, thread_safety: u8 ) -> LangProp {
LangProp { editor_support, auto_test, linter, build_system, package_manager, formatter, procedural, object_oriented, functional, preparation_performance, execution_performance, memory_efficiency, memory_safety, type_safety, thread_safety}
}
}

#[derive(Debug)]
struct FieldProp {
preparation_performance: u8,
execution_performance: u8,
memory_efficiency: u8,
memory_safety: u8,
type_safety: u8,
thread_safety: u8,
abstraction: u8,
code_size: u8
}

impl FieldProp {
fn new(preparation_performance: u8, execution_performance: u8, memory_efficiency: u8, memory_safety: u8, type_safety: u8, thread_safety: u8, abstraction: u8,code_size: u8) -> FieldProp {
FieldProp { preparation_performance, execution_performance, memory_efficiency, memory_safety, type_safety, thread_safety, abstraction, code_size }
}
}

fn calc_practicality_score(field: &FieldProp, lang: &LangProp) -> u8 {
let code_size_score = ((lang.editor_support + lang.auto_test + lang.linter + lang.build_system + lang.package_manager + lang.formatter) as f32 / 6.) as u8;
let abstraction_score = (((lang.procedural + lang.object_oriented + lang.functional) as f32) / 2.) as u8;
let perfect_score = (field.preparation_performance + field.execution_performance + field.memory_efficiency + field.memory_safety + field.type_safety + field.thread_safety + field.abstraction + field.code_size) + 10;
let real_score = field.preparation_performance * lang.preparation_performance + field.execution_performance * lang.execution_performance + field.memory_efficiency * lang.memory_efficiency + field.memory_safety * lang.memory_safety + field.type_safety * lang.type_safety + field.thread_safety * lang.thread_safety + field.abstraction * abstraction_score + field.code_size * code_size_score;
let practicality_score = ((real_score as f32 / perfect_score as f32) * 10.) as u8;
if practicality_score > 10 {10} else {practicality_score}
}

use std::collections::HashMap;
fn main() {
let mut langs = HashMap::new();

langs.insert("asm", LangProp::new(0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 0, 0, 0));
langs.insert("c", LangProp::new(0, 0, 0, 0, 0, 0, 2, 0, 0, 1, 2, 2, 0, 0, 0));
langs.insert("cpp", LangProp::new(0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 2, 2, 0, 0, 0));
langs.insert("java", LangProp::new(0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 1, 0, 2, 2, 1));
langs.insert("js", LangProp::new(0, 0, 0, 0, 2, 0, 2, 2, 0, 2, 0, 0, 2, 1, 0));
langs.insert("python", LangProp::new(0, 0, 0, 0, 2, 0, 2, 2, 0, 2, 0, 0, 2, 1, 1));
langs.insert("go", LangProp::new(2, 2, 2, 2, 2, 2, 2, 2, 0, 1, 1, 1, 2, 2, 1));
langs.insert("ts", LangProp::new(0, 0, 0, 0, 2, 0, 2, 2, 0, 1, 0, 0, 2, 2, 0));
langs.insert("elixir", LangProp::new(0, 2, 0, 2, 2, 2, 0, 0, 2, 1, 1, 0, 2, 1, 2));
langs.insert("rust", LangProp::new(2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 2, 2, 2, 2, 2));

let mut fields = HashMap::new();

fields.insert("web", FieldProp::new(4, 1, 1, 3, 2, 1, 4, 2));
fields.insert("system", FieldProp::new(1, 3, 4, 1, 1, 2, 1, 3));
fields.insert("task", FieldProp::new(4, 0, 0, 3, 1, 0,4, 1));
fields.insert("game", FieldProp::new(1, 2, 2, 2, 2, 2, 4, 3));
fields.insert("analysis", FieldProp::new(2, 2, 1, 3, 2, 2, 4, 1));

for lang_name in ["asm", "c", "cpp", "java", "js", "python", "go", "ts", "elixir", "rust"] {
for field_name in ["web", "system", "task", "game", "analysis"] {
println!("lang:{: >7}, field:{: >9}, practicality score:{: >3}", lang_name, field_name, calc_practicality_score(fields.get(field_name).unwrap(), langs.get(lang_name).unwrap()));
}
println!();
}
}

まとめ

長文を読んで頂きありがとうございました。

Rustは所有権や借用といった特徴が目立ちすぎている感じがしますが、本記事ではそういう際立った特徴には触れずに言語間で比較可能な特性をもとにRustの実用性を考察してみました。

「実用性」 の定義は、本記事では一つのプログラミング言語で万能ナイフのように場面を選ばず使える言語の方が 「実用性が高い」 としています。その前提のもとで実用性を判定する20のキーファクターを1つの軸と5つのカテゴリに分類した 言語の実用性判定マップ を考案して、汎用言語であるC/C++/Java/JS/Python/Go/TS/Elixirを比較してみました^8

そして実際に言語の実用性判定マップを用いることで、Rustの実用性を浮き彫りにすることができたのではないかと思っています。言語の実用性判定マップは言語の特徴を大雑把に捉えるのに適していると思われるのでぜひ活用してみてください。

本記事がRustの実用性に関してより良い理解に繋がれば幸いです。

おまけ

Rustの機能に着目した言語の比較も行っています。興味がある方は御覧ください。

https://qiita.com/hinastory/items/e97d5459b9cda45758db

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×