河豚板のi386版でfiupdateを使いたい

先日Super-microDXにインストールした河豚板をfiupdateでアップデートしようとしたところ、すこしつまづいてしまったので、メモを残しておく。

前提というか背景を書いておくと、Super-microDXにはコンパクトフラッシュのドライブ(リーダ?)が内蔵されている。OSはコンパクトフラッシュにインストールして運用することが多い*1。なお河豚板というかOpenBSDコンパクトフラッシュIDE(パラレルATA)として認識していて、転送モードとしてPIOモード4とUltra DMAモード2が選択されている。

「Super-microDXにインストールした河豚板」と書いているが、これはSuper-microDX上でUSBメモリから河豚板を起動して、usbfadmnewdriveコマンドを使用してコンパクトフラッシュ上に河豚板のLiveUSB版*2を書き込んだ後、コンパクトフラッシュからブートして使用しているのだ。

さて本題。河豚板はOpenBSDのパッチ適用などに合わせてアップデート版がリリースされる。LiveDVD版の場合はメディアを作り直すしかないのだが、LiveUSB版の場合はfiupdateというコマンドを使用して中身を直接更新することができる。

で、このfiupdate、少なくとも河豚板 7.1のi386版では、少しやり方を工夫しないと失敗する。私の環境ではgzip(1)に「No space left on device」と言われてしまった。

河豚板ではRAMディスク上にホームディレクトリ等が展開される。河豚板 7.1ではRAMディスクを実現するためにmfs(Memory File System)を使用しているのだが、i386でのmfsの最大サイズは約1GBである。

つまりホームディレクトリでfiupdateする場合、どう頑張っても1GBのディスクスペース上で作業することになってしまう。で、fiupdateするには1GBでは足りない。

mfsの最大サイズはアーキテクチャごとに異なるようで、amd64では32GB、arm64では16GBと河豚板ガイドに記載されている。だからamd64とarm64では、mfsの割り当てを工夫すればfiupdateがディスクフルで失敗する問題は回避できる。でもi386ではmfsの割り当ての調整ではどうにもならない。

なので、河豚板 i386版でfiupdateする場合は、別途書き込み可能な作業用パーティションを用意して、その中でfiupdateの手順を実行する必要がある。

手元の環境の場合、16GBのコンパクトフラッシュの大半をデータ保存用領域(noasksファイルとかが置いてあるパーティション)に割り当てている。そこで今回はデータ保存用領域を作業用パーティション代わりにしてfiupdateを実行した。

# rootで作業していると仮定:
mount /dev/wd0d /mnt
cd /mnt
ftp https://jp2.dl.fuguita.org/{MD5,FuguIta-7.1-i386-202205061.iso.gz}
fiupdate 202205061

更新はうまくいったようだ。

ただしSuper-microDXの個体の問題なのか、アップデートの最後にデータ保存用領域がうまくアンマウントされなかったようだ。そのため後で「fsck /dev/wd0d」とチェックを走らせることになった。この件はハードウェア側の問題だと思う。

*1:一応、USB2.0のポートが2つ付いているので、USBブートも可能である。しかしポート間の物理的な間隔が狭いため、ブート用のUSBデバイスの選択がちょっと面倒である。

*2:コンパクトフラッシュはUSBデバイスではないのだが、河豚板が公式で「LiveDVD版」と「LiveUSB版」という書き方をしているので、それに合わせている。

お願いお助けOpenBSD

10年前に新品で購入したPCにインストールできるメジャーなOSが減ってきた。思案した結果、ひとまずOpenBSDベースの河豚板を使ってみることにした。

件のPCは、ピノーが販売していたSizka Super-microDXという、少し癖のある代物だ。*1

何しろCPUがVortex86DXというx86互換の32bit SoCである。動作周波数は800MHz。メモリはオンボードのDDR2メモリが512MBだ*2

猛者ならFreeDOSを使ったり*3他のPCでクロスコンパイルしたOSを導入したりするかもしれないが、私はめんどくさがりなので、出来合いのビルド済み汎用OSをインストールして使用したい。したいのだが、セキュリティを気にして現行のOSから選択しようとすると、候補が少ないのだ。

インストール可能なOSが減ってきた理由は、Vortex86DXがサポートする命令セットの古さにある。Vortex86DXはi586*4の命令セットに概ね対応している*5。このSoCでOSを動かすためには、OSのバイナリはi586以前の命令で構成されている必要がある。i686*6以降の命令が含まれていると、実行時に該当する命令にたどり着いた時点でCPU例外が発生してストールする。

最近のLinuxx86-64のみのディストリビューションも多いのだが、x86(32bit)版をリリースしているものであっても、その中身は「i686以降をサポートする」だったりする。多種多様なアーキテクチャをサポートしているDebianでも、Debian 9 (stretch) にてi586はサポート対象外となっている*7

FreeBSD i386も、13.0-RELEASE以降ではi686が最低ラインだ。一方で12.x系はi586でも動作する……はずなのだが、なぜか12.2-RELEASE以降のGENERIC kernelのバイナリにはi686命令であるCMOVが含まれているため、i586では動作しない。この問題はFreeBSDのBugzillaに挙がっているようだが、先行きは不透明だ。

どうしたものかと思案していたところで、Vortex86DXを搭載したPCにてNetBSD 9.2とOpenBSD 7.0が動作したとの情報を発見した。

NetBSDOpenBSDなら、最近のビルドでも問題なく使えるようだ。

ということで勉強もかねて触ってみようと思ったのだが、誤解かもしれないが素のNetBSDOpenBSDはどちらもインストールが(他のOSと比較して)面倒くさい印象があるのでパス。NetBSDておくれLive Imageは良さそうだが、Vortex86DXで動かすには重そうなので*8、今回はパス。河豚板は起動時にコンソールを指定できるだけでなく、設定で永続化できるので、丁度よさそうだった。

今回は、あまり手間をかけずに環境を構築できる河豚板を使用して、OpenBSDCLI環境を構築することにした。まだ河豚板 7.1 202205011を入れただけの状態だが、今のところ問題なく動作している。

後で気づいたのだが、個体の問題なのか私のSuper-microDXは熱が原因らしきバスエラーが発生することがあるので、システムの利用と設定等の保存(ファイルシステムへの書き込み)を分離できる河豚板は割とマッチしているかもしれない……設定保存中にバスエラーが起きませんように。

*1:メディアなどで「煙草箱サイズ」と書かれていたが、個人的には、サイズ感はAppleの60W MagSafe電源アダプタに近いと思う。アレを1.3倍ぐらい厚くした感じだ。

*2:メモリは333MHz駆動らしいのだが、DDR2のメモリチップ規格に333MHzなんて存在しないので、意味を図りかねている。Vortex86DXのDDR2コントローラはDDR2-667までサポートしているようだが、DDR2-667(つまりメモリチップが667MHz駆動)の時のバスクロックは333MHzなので、このことを指し示しているのだろうか? それともクロックを下げてDDR2-400(400MHz駆動)よりも低い333MHzでメモリチップを動作させているのだろうか?

*3:CPU内蔵のフラッシュメモリがフロッピディスクドライブとして認識できるようになっていて、しかも出荷時にFreeDOSがインストールされている。

*4:初代PentiumMMX Pentiumの世代。

*5:噂によれば、実は一部のあまり使われない命令には対応していないらしいが……。

*6:Pentium ProPentium II~4の世代。

*7:正確に言えば「NOPL以外の全てのi686命令に対応しているi586 CPU」はサポートしている。でもそれって「ほぼi686」だよね。

*8:GUI前提っぽいので。

プログラマはコミュ障じゃないよ

時にコミュ障とプログラマがセットで語られることがあるのだが、そもそもプログラマに占めるコミュ障の割合は他の職業と比較して有意に大きいのだろうか、という疑問がある。

世間一般でいう「コミュニケーション能力」が具体的にどのような内容であるのか、寡聞にして知らないのだが、職業プログラマにとって効率的なコミュニケーションとは次のようなものだ。

  1. 正確な情報を、
  2. 包み隠さず全て、
  3. できるだけ正確な用語を使用して、
  4. 簡潔かつ網羅的に、
  5. キャッチアップしやすい媒体にて、
  6. なるべく容易に再利用できる形式で、
  7. 概ね全ての関係者がアクセスできる方式にて発信する。

コンピュータ・システムの開発ってのは、賽の河原で石を積み上げてサグラダ・ファミリアを建造するようなものである。

プログラマは、その難しさを誰よりも知っている訳で、だからこそ情報の正確さと、チーム内の情報格差の是正を重視する。情報が不正確だと迷走するし、情報格差は意図せぬ同床異夢を誘発する。どちらも、バベルの塔の逸話のごとくプロジェクト関係者の言葉を乱して混乱と疲労を生むだろう。いずれもシステム完成の障害でしかない。

現実には、政治ムーブの一環として、言葉を濁したり、相手によって情報の公開/非公開を取捨選択したり、些細な言葉尻を捕らえて責任をなすりつけあったりする、なんてことが多いのだが、実のところそのような行為は「システムを完成させる」という技術的視点では第一級の障害である。火薬庫の屋根裏で火遊びするに等しい。

というかコンピュータ・システムという複雑な複雑怪奇な代物を作ろうというのに、何でこの人たちは悠長に政治ムーブしているのだろう、ちょっとは空気読めよ――というのがプログラマの本音である。

つまり「コンピュータ・システム開発」という限定された文化圏においては、「プログラマはコミュ障ではなく、プログラマ以外がコミュ障である」というケースも多いのだ。

まあ、そうは言っても予算を引っ張ってくるのは政治ムーブに長けた人たちである。例えコミュ障であっても、金を握っている奴は強いのだ。そして時折彼らの目的は「システムの完成」ではない。「システムの完成」ではなく、それに付随すること――例えば「賞賛を得たい」とか、逆に「失点しない」とかが目的だったりする。だから、たとえ「システムの完成」に有効な施策であっても、彼らの真の目的に合致しないなら、すげなく拒否されるのである。かなわんなあ、ほんと。

余談:ところでプログラマプログラマ以外の人たち(主に顧客や多重下請け構造の元請社員など)の間の温度差は、プログラマ以外の人たちはプロジェクト内において「コンピュータ・システム開発技術」の技術面の能力が相対的に低いことがある、という所からも生じているように思う。ダニング=クルーガー効果より、彼らは「コンピュータ・システム開発の技術的側面」について自分たちを過信してしまうことがあるのだ。だから余裕がないのに余裕があると思い込んで悠長に政治ムーブをかましてくる上に、事の重大さを「大したことはない」と誤認して「何でできないの? 簡単でしょ?」と上から目線で言い放つ人が稀に出現するのである。かなわんなあ、ほんと。

余談その2:なおダニング=クルーガー効果はプログラマにも当てはまる。だからこそ、彼らコミュ障を侮ってはならない ;-)

さよならSDelete

新しいPCに環境を移行してから、SDeleteを使う機会が激減した。ここ1~2ヶ月は1度も使用していない。

何のことはない、ようやくSSDモデルのPCに買い替えたのだ。

以前のPCは2013年に購入したノートPCだ。当時は「クラウド・サービスのストレージにデータを置く」という行為が今ほど一般的ではなく、PC本体にデータを丸抱えしがちだった。実用レベルのストレージ・サイズをSSDで賄うには少々高額で、まだまだHDDモデルが圧倒的に多かった時期だったと記憶している*1

SDeleteを多用していた理由は、ストレージがHDDだったからだ。センシティブな情報を含むファイルはSDeleteで削除していたし、ディスク交換などのタイミングではストレージ全体にSDeleteをかけることもあった。

便利に使っていたSDeleteは、しかし、HDDに特化したツールだった。

SDeleteや、OS X Yosemite 10.10まで存在したsrm(3)、そしてGNU Coreutils付属のshred(1)などによる「指定したファイルの削除」では、ストレージ上の「指定したファイルのデータが存在するディスクセクタ」に、特殊なデータ・パターンを複数回上書きする。これにより、後でディスクセクタから「元のデータとは全くかけ離れたデータ」しか読み取れなくする――という仕組みで、データの復元が困難になるようにしている。

残念ながら今日のSSDはウェアレベリングが行われているため、SDeleteによる「特殊なデータ・パターンによる上書き」のデータは、おそらく「指定したファイルのデータが存在するブロック」ではなく「別のブロック(消去済みブロック)」に書き込まれてしまうだろう。SDeleteはユーザランドのツールであり、OS・デバイスドライバSSDファームウェアからは「通常のデータ書き込み」と「SDeleteによる『特殊なデータ・パターン』の書き込み」を区別できないのだ。

SSDではSDeleteの効果が期待できないのならば、どうすればよいのだろうか?

実のところ、SSDの場合は何もしなくても「HDDより安全」ではないかと思う。なぜなら今ではSSDもOSもTrim命令に対応しているからだ。Trim命令による「空いたブロックのデータ消去」は、データ復旧業者が「SSDのデータ復旧が難しい理由」の1つに挙げている項目だ。普段から長時間PCを使用しているならば、バックグラウンドでそこそこの頻度でTrim命令に基づくデータ削除が行われているだろう。

普段は「Trim命令によるデータ削除」に期待しつつ、ディスク交換の時にSSDメーカのメンテナンス・ツールでSSD全体を完全削除する――という運用でも、HDDよりも遥かに「削除したファイルの復元」が難しくなるように思う。

……とはいえ、Trim命令に応じて実際にブロックのデータを削除するのはSSDファームウェアな訳で、実際にどの程度の頻度でブロックが消去されるのか不透明だ。

それに、SSDについて素人な私は「メンテナンス・ツールで完全削除したはずのブロックからデータを復元できてしまう可能性は?」とか「そもそも完全削除する前にディスクがお亡くなりになった場合に、NANDチップからデータを吸い出して復元することは可能なのだろうか?」とか、色々と余計なことを考えてしまうのである。

そこでディスク全体を暗号化した。WindowsならBitLocker、macOSならFileVaultだ*2。暗号化しておけば、仮に「完全削除したブロックからデータを復元できる」としても、復元したデータは暗号化されている。また完全削除する前にディスクが死亡した場合も、仮にNANDチップからデータを吸い出して復元することが可能だとしても、そのデータは暗号化されている。どちらも、「実は簡単に復号できちゃう」みたいな欠点がない限り、データの復元は困難なはずだ。

ディスク暗号化は、PC故障時に生き残ったディスクからデータを吸い出すのが困難になるという欠点を伴うのだが、そもそもディスク故障まで考慮してデータ・バックアップ体制を構築しておく方が健全だろう。

PCのライフサイクルとしては、最初に環境構築を行う前にディスク全体を暗号化しておき、普段はOS標準の機能でファイルを削除しつつTrim命令に期待して、ディスク交換の際には「暗号化したままの状態で、メンテナンス・ツールでSSD全体を完全削除する」、という流れとなる。

そんな訳で、今では暗号化されたSSDの上で、センシティブな情報を含むファイルもOS標準の機能で削除している。

SDeleteそのものは、USB-HDDとUSBメモリ*3を物理破壊する前に念のため完全削除する作業や、仮想ディスクのダイエットのために空き領域をゼロクリアするために、ディスクの片隅に眠らせている。でも普段使いしなくなったので、もうヘルプメッセージを見ないと使い方が分からなくなってしまった。

*1:この後、2014~2015年ごろからミドルレンジのノートPCでもSSDモデルが見られるようになったと思う。

*2:Linuxは、ディストリビューションによってはインストール時に暗号化LVMを選択できるので、それを選択するのが手っ取り早い気がする。

*3:SSDUSBメモリも記憶素子としてNANDフラッシュを用いているが、大半のUSBメモリにはウェアレベリング機能がない。

Better C的なC++の使い方

C++をあまり深入りせずにBetter Cとして使うとこうなる。

事前準備:文法面の細かな差異を押さえる

細かいところにC言語C++の非互換な部分があるので、それを押さえておく。例えば:

C95以前のCプログラマ向け:ブロック先頭以外で内部変数を宣言する

C99以降ではブロック先頭以外でも内部変数を宣言できるようになったが、お仕事でC言語を使っているとC95以前が絡むことも多いので、ブロック先頭でまとめて宣言する癖がついているベテランも多いと思う。

/* C95以前のC言語の場合 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char *argv[])
{
    int i;
    size_t len;

    for (i = 0; i < argc; i++) {
        len = strlen(argv[i]);
        (void) printf("%lu\t%s\n", (unsigned long) len, argv[i]);
    }

    return EXIT_SUCCESS;
}

変数の寿命などの問題*1が絡んでくるので、内部変数は使う直前に宣言・初期化するようにする。

// C++の場合
// あえてC言語由来の機能を使用している

#include <cstdio>
#include <cstdlib>
#include <cstring>

int main(int argc, char *argv[])
{
    for (int i = 0; i < argc; i++) {
        std::size_t len = std::strlen(argv[i]);
        (void) std::printf("%lu\t%s\n", (unsigned long) len, argv[i]);
    }

    return EXIT_SUCCESS;
}

C95以前のCプログラマ向け:bool型を使う

これも、C99以降で_Boolやstdbool.h(bool、false、true)が追加されたが、お仕事でC言語を使っているとC95以(ry

/* C95以前のC言語の場合 */

/* ブール型がなく、独自定義もされてないケース */
static int valid_number(const char *s);

/* 独自定義のブール型 BOOLEAN があるケース */
static BOOLEAN valid_token(const char *s);
/* C99以降のC言語の場合 */

#include <stdbool.h>

static bool valid_number(const char *s);
static bool valid_token(const char *s);
// C++の場合
// あえてC言語由来の文字列を使用している

static bool valid_number(const char *s);
static bool valid_token(const char *s);

ファイルスコープの代わりに無名名前空間を使う

ファイルスコープと書いたが、要はstatic関数やstaticなグローバル変数のことだ。

/* C言語の場合 */

static int foo;

static int triple(const int n)
{
    return n * 3;
}

C++的には、staticではなく無名の名前空間を使うことが推奨されている。

// C++の場合

namespace {

int foo;

int triple(const int n)
{
    return n * 3;
}

} // namespace

Better Cの観点では、この項目の優先度は低い気がしないでもない。

とはいえ、よく考えれば毎度毎度staticを書くのも面倒な訳で、コードを書く時の流儀として「ファイル先頭側に『モジュール内部で使用する非公開関数群』をまとめて記述して、ファイル末尾側に『外部に公開する関数群』を記述する」というスタイルを採用している人ならば、staticから無名の名前空間に切り替えるのは悪くない判断だと思う。

……公開関数と非公開関数を混在させるスタイルの人にとっては扱いにくいだろうけど。

C++11以降:cstdint(stdint.h)の型を使う

やや組み込み系っぽいネタ。

これ、C99以降のC言語を使う場合もそうなのだが、厳密な大きさ(ビット幅)を指定したデータ型を用いるのに、そろそろ独自のtypedefではなくint8_tやuint16_tを使っても許されるのではないかと思うのだ……。

/* C95以前のC言語の場合 */

/* 独自の型定義が書かれている共通ヘッダファイル */
#include "common_types.h"

#define WORK_BUF_SIZE 4096

static BOOLEAN module_initialized = FALSE;
static U16 my_port_number = 0;
static U8 packet_send_buf[WORK_BUF_SIZE];
static U8 packet_recv_buf[WORK_BUF_SIZE];
/* C99以降のC言語の場合 */

#include <stdbool.h>
#include <stdint.h>

#define WORK_BUF_SIZE 4096

static bool module_initialized = false;
static uint16_t my_port_number = 0;
static uint8_t packet_send_buf[WORK_BUF_SIZE];
static uint8_t packet_recv_buf[WORK_BUF_SIZE];
// C++11の場合

#include <cstddef>
#include <cstdint>

namespace {

constexpr std::size_t WORK_BUF_SIZE { 4096 };

bool module_initialized { false };
std::uint16_t my_port_number { 0 };
std::uint8_t packet_send_buf[WORK_BUF_SIZE];
std::uint8_t packet_recv_buf[WORK_BUF_SIZE];

} // namespace

ちなみに、C++11的にはcstdintをインクルードするべきなのだが、現時点では移植性の問題でstdint.hをインクルードした方が安全なこともあるようだ。

C++03以前:constによる定数を積極的に使う

C言語では、定数を定義する方法は2通りある。マクロ置換を使う方法と、enumの列挙子を使う方法だ。

/* マクロ置換を使う場合 */
#define FOO_LIMIT 128
/* enumを使う場合 */
enum {
    FOO_LIMIT = 128
};

C++では、const修飾子を使用して整数や浮動小数点数を定数化することも可能だ。定数を定義する方法が増えている。

// C++03の場合
static const std::size_t FOO_LIMIT = 128;

const修飾子はC言語の頃から存在する。しかしC言語では、const修飾子を付けた変数は定数化せず、実質的に「ほぼreadonlyの変数」として振る舞う。変数なので、配列を定義する際に要素数として使うことが不可能な代物だ。加えて、不注意で値を変更できてしまう余地が大いにあるし、そのようなコードがコンパイル時に警告扱いで通過してしまうことが多い。そしてそんなオブジェクトコードを実行すると、運がよければ普段使いの範囲の操作で確実にアプリが落ちるし、運が悪ければアプリは落ちずに原因不明の不具合に悩まされることになる。

しかしC++では、コンテキスト次第ではしっかりと定数扱いされる。整数型なら配列定義の要素数として問題なく使えるし、値を変更するようなコードを書こうものなら十中八九コンパイルエラーとなる。

const修飾子による定数のメリットは、型を明示する必要があること*2だ。またプリプロセスではなく「C++の言語本体の仕様の一部」であるので、enumと同様にスコープの概念が有効となる。そしてenumとは異なり、整数以外の型も定数化できる。

C++で定数を定義する際は、基本的にはconstを使用して、適切な型の定数を用いるべきだろう。その上で、何らかのIDのような連番が必要な場合や、デバッグ時に変数の値を定数のシンボル名で確認したい場合に、enumを使うべきだろう。

#defineによる定数の定義は、C++では基本的には避けるべきだ──#defineを使うのは、constやenumによる定数のメリットが仇となるような例外的状況や、C言語とのインタフェース(C++で実装したモジュールをC言語に組み込むようなケース)にとどめるべきだ。

C++11以降:constよりもconstexprで定数を定義する

ところでC++03以前の「const修飾子を付与した変数」には、コンパイル時に値が決まっているもの(≒定数として扱えるもの)と、実行時に変数を初期化する時点にて値が決まるもの(≒constを付与した仮引数のようなケース)の2種類があった。

このうち「コンパイル時に値が決まっているもの」については、C++11にてconst修飾子の代わりとなるconstexpr指定子が登場した、という歴史的経緯がある。constexprを付ければ、コンパイラは「この定数の中身はコンパイル時に決まる(だからROM化できる)はず」と解釈してコンパイルを試みるはずだ。

// C++03の場合
static const std::size_t FOO_LIMIT = 128;
// C++11の場合
static constexpr std::size_t FOO_LIMIT = 128;

C++11以降では、定数の定義にはconstではなくconstexprを用いるべきだろう。

なお整数や浮動小数点数ならともかく、ユーザ定義型では「コンパイル時に全てを決めることができない」というケースも多く、その場合はconstexprで定数にすることができない。こういう場合はconst修飾子を使用して「(定数ではないが、外から見て)不変のオブジェクト」として扱うことになる。

constexprは関数にも適用できるのだが(何しろ「constant expression == 定数式」の略であるし)、Better Cではひとまず定数のことだけ覚えておけば何とかなる。C++14以降が使える環境になったらconstexpr関数のことを思い出す、ぐらいでちょうど良いのではないか?*3

C++11以降:autoで型推論させる

C++11以降では、変数を宣言する際に具体的な型を明示する代わりにautoキーワードを用いることで、初期化子から推論した型を適用させることが可能になった。

// autoを使わない場合

int n = 1;
const unsigned long maxval = 255;
const char *s = "";
const char *p = s;
// autoを使う場合

auto n = 1;
const auto maxval = 255UL;
auto s = "";
const auto *p = s;

例えばイテレータのように、標準ライブラリの機能に付随する型については、毎度毎度自分で長ったらしい型名を書いたり、typedefなどで省略形を定義して使うよりも、autoを使ったほうが楽だろう。

// autoを使わない場合

const std::vector<int> TABLE {
    32, 64, 96, 128, 192, 256, 512, 1024
};

// 中略

std::vector<int>::const_iterator p =
        std::find(TABLE.begin(), TABLE.end(), value);
if (p == TABLE.end()) {
    // 見つからなかった
}
// autoを使う場合

const std::vector<int> TABLE {
    32, 64, 96, 128, 192, 256, 512, 1024
};

// 中略

auto p = std::find(TABLE.begin(), TABLE.end(), value);
if (p == TABLE.end()) {
    // 見つからなかった
}

もちろん全てをautoで置き換えることは(不可能である以前に)ナンセンスであるが、しかしautoを使えるシーンが色々とあることも確かである。ということで積極的に使ってよい機能だと思う。ただし統合開発環境ないし「モダンで賢いソースコードエディタ*4」は必須だろう。推論された型をエディタ上で簡単に確認できる仕組みが無いと、ちょっとやりづらい。そしてそんな「便利な開発環境」を構築して快適に利用できる程度に「開発マシンのスペック」や「ソフトウェア導入の自由度」が担保されている必要もある……。

なおC++14ではautoを使用可能なシーンが増えており、関数宣言/定義の戻り値の型名の代わりに使用することや、後述のラムダ式の引数の型に使用すること*5も可能である。

蛇足:autoに関しては、Objective-C++でも割と便利に使わせてもらっております。何しろ仕事でObjective-C++する場合、C++部分は実質的に「clangの『GNU拡張ありのC++』」な訳なので。

C++11以降:NULLではなくnullptrを使う

さよならNULL。君のことは忘れないよ。でも正直なところ、思わぬところでポインタではなく整数定数の0だと誤解されてしまう君には、少しうんざりしていたんだ。

/* C言語の場合 */

const char *p = NULL;
// C++03の場合

const char *p = NULL;
// C++11の場合

const char *p = nullptr;

ポインタの代わりに参照を積極的に使う

C言語でポインタを使うケースの多くは、C++では参照に置き換えることが可能だ。

例えばクラス(構造体)のメンバを辿った奥底の値を使用したいが、長ったらしい名前を何度も書くのが面倒なので、一時的に別名を付ける場合:

// ポインタの場合

std::string *value = &app.main_window.text_edit.value;
if (*value != old_edit_value) {
    old_edit_value = *value;
    do_foo(value);
    do_bar(value);
}

このようなケースは、比較的簡単に参照に置き換えることが可能だ。

// 参照の場合

std::string& value = app.main_window.text_edit.value;
if (value != old_edit_value) {
    old_edit_value = value;
    do_foo(value);
    do_bar(value);
}

ポインタを使うと、例えば引数にポインタをとる関数を実装するなら、プログラム中のどこかでNULLポインタチェックが欲しくなる。

/* ポインタの場合 */

static void func_foo(const struct STRUCT_FOO *foo)
{
    /* 明示的なNULLチェックが必要な場合 */
    if (foo == NULL) {
        /* エラー処理 */
    }
}

static void func_bar(struct STRUCT_FOO *foo)
{
    /* 明示的なNULLチェックをしない場合 */
    assert(foo != NULL);

    /*
     * NULLをつっこむ上位ルーチンが悪い!
     * (NULLチェックは上位側でやる約束だったよな?)
     * テストで洗い出せ!
     */
}

NULLチェックのような細々した処理が不要となる参照は、便利といえば便利だ。

// 参照の場合

static void func_foo(const STRUCT_FOO& foo)
{
    // NULLチェックは不要。言語仕様的にありえない……はず!
}

もっとも、const参照の引数ならともかく、非const参照の引数の場合、その関数を呼び出す側にて例えば「foo(bar);」というコード片からbarが変更される可能性を予期しにくいという問題があって、参照を使うかポインタを使うか意見が割れるところだが……。

// 参照の場合

func_foo(baz);  // 関数内でbazを変更していない
func_bar(baz);  // 関数内でbazを変更している

// bazを変更しているか否か、上記コード片からは不明

しかし冷静に考えると、それは構造体のポインタでも一緒だ。

// ポインタの場合

func_foo(&baz);  // 関数内でbazを変更していない
func_bar(&baz);  // 関数内でbazを変更している

// bazを変更しているか否か、上記コード片からは不明

要するに、通常のポインタを使っていて構造体のメンバを変更しているのか、constポインタでメンバを変更できないようにしているのか、呼び出す側のコード片からは判断できない。

というか、似たような(しかし全く異なる)問題は他のメジャーな言語でも見られる。例えばRubyの変数はポインタのようなもので、メソッドの引数は値渡しだ。なので、メソッド内で引数が参照しているオブジェクトを破壊的に操作すると、変化が波及する。メソッド内でオブジェクトを破壊的に操作しているか否かは、そのメソッドを呼び出す側のコード片からは判断できない。

# Rubyの場合

foo(baz)    # メソッド内でbazを破壊的に操作していない
bar(baz)    # メソッド内でbazを破壊的に操作している

# bazを破壊的に操作しているか否か、上記コード片からは不明

まあ、C++の参照はポインタとは異なる代物なので、Rubyの例と同一視はできないのだが、しかし「メソッドを呼び出しているコード片からは、引数に設定した値が変化してしまうか否かが分からない」という点は同じだ。

そう考えると、深く考えずに非const参照の引数を使っても構わない気がしないでもない。

あとポインタを返す関数を参照を返すように単純に置換できるかというと、意外と「通常はオブジェクトを指し示すポインタだが、異常発生時にはNULLポインタを返す」という仕様の関数が多くて、一筋縄ではいかない。まあ、ポインタと参照は別物だからしかたない。この場合は「異常発生時にはNULLポインタを返す」という部分を「エラーコードを返す」や「例外をthrowする*6」などに仕様変更することになる。

キャスト演算にはC++のキャスト演算子を使用する

キャスト演算したい時、従来のC言語流のキャストではなくC++のキャスト演算子を使用した方がよい。コードの記述はやや冗長になるが、結果としてC言語流のキャストを使用した場合よりも安全なコードになりやすい。

/* C言語の場合 */

static void func(uint8_t *data)
{
    /* 中略 */

    uint16_t n = (uint16_t) strlen((const char *) data);

    /* 後略 */
}
// C++の場合

static void func(std::uint8_t *data)
{
    /* 中略 */

    auto n = static_cast<std::uint16_t>(std::strlen(reinterpret_cast<const char *>(data)));

    /* 後略 */
}

C言語流のキャストは複数の役割を担っており、少々複雑だ。そんな複雑な代物をよく理解せずに、むやみやたらに使用するのは危険だ。実のところ熟達したCプログラマは、キャストする時に「このキャストは、どのような役割のキャストか?」ということを暗黙のうちに判断しているものだ。

C++では、キャストの「複数の役割」を分解して、従来の役割から分岐した3つの演算と、C++固有の1つのキャスト演算を加えた、合計4つのキャスト演算子に整理している。

  1. const_cast
  2. dynamic_cast
  3. reinterpret_cast
  4. static_cast

ということで、C++のキャスト演算子を使用するという事は、強制的にプログラマに「このキャストは、どのような役割のキャストか?」と自問させて明記させるに等しい。考えさせる分だけ、結果としてコードが安全になる可能性が少しばかり上がるはずだ。

蛇足:なおObjective-C++においては、NSIntegerのようなプリミティブ型はC++のキャスト演算子でキャストできる一方で、Objective-Cのクラスのオブジェクトのアップキャスト/ダウンキャストにはC言語流のキャストを使用しなくてはならない。

名前の衝突回避に名前空間を使う(foo_func()ではなくfoo::func())

C言語でライブラリやモジュールを実装する際には、他のライブラリ等との名前の衝突を回避するために、名前のプレフィックスにライブラリ名を付けることが多い。

/*
 * C言語の場合
 * 例えばモジュール foo を実装するなら:
 */
typedef struct {
    int param1;
    void *param2;
} FOO_START_PARAMS;

bool foo_initialize(void);
void foo_finalize(void);
bool foo_startBar(const FOO_START_PARAMS *params);
bool foo_stopBar(void);

せっかくなのでC++では名前空間を使う。

// C++03の場合
// 例えばモジュール foo を実装するなら:
namespace foo {

struct START_PARAMS {
    int param1;
    void *param2;

    START_PARAMS() : param1(0), param2(nullptr) {}
};

bool initialize();
void finalize();
bool startBar(const START_PARAMS& params);
bool stopBar();

} // namespace foo

念のため書いておくと、複数のコンテクストを扱いたいモジュール*7は、大抵は名前空間ではなくクラスで実装して、各コンテクストごとにオブジェクト化してしまうほうがよい。一方で、複数のコンテクストを扱うことなど到底ありえない場合*8は、他のオブジェクト指向プログラミング言語ならシングルトンないし「クラスメソッドonlyのクラス」を使うところだが、C++ではベタに名前空間を使っても許されると思う。だってC++なんだよ?

構造体やenumをtypedefしない

C言語では、例えばstruct FooをFooと書くためにtypedefしておく必要があった。

/* C言語の場合 */

struct Foo {
    /* メンバは省略 */
};
typedef struct Foo Foo;

typedef struct {
    /* メンバは省略 */
} Bar;

static void func(void)
{
    struct Foo foo1;
    Foo foo2;
    Bar bar;

    /* 以下略 */
}

一方でC++ではtypedefは不要だ。struct Fooを定義した時点で、structを付けずにFooと書くことができる。

// C++の場合

struct Foo {
    // メンバは省略
};

struct Bar {
    // メンバは省略
};

static void func()
{
    struct Foo foo1;
    Foo foo2;
    Bar bar;

    // 以下略
}

全くの別名を付けたいのでもなければ、構造体やenumをtypedefする必要はない。

C++11以降:typedefよりもusingで別名を付ける

ところでC++11以降ではusingキーワードを使って型に別名を付けることができる。

// C++03の場合

typedef std::int32_t error_type;
// C++11の場合

using error_type = std::int32_t;

usingキーワードによる別名の付与には2種類ある。1つはエイリアステンプレートで、テンプレートのパラメータ(例えば「template <class T>」のT)をそのまま含む型*9の別名を定義する機能だ。もう1つはエイリアス宣言で、従来のtypedefと同様にテンプレート以外の型の別名を定義する機能だ。

Better Cではエイリアス宣言の扱いが焦点となる。今まで通りtypedefするか、それともusingするのか? 後々テンプレートに手を出した時にエイリアステンプレートの記法と若干の統一性があることや、地味に関数ポインタの別名の書き方がスッキリしている点を考慮するに、typedefからusingに移行しても罰は当たらないだろう。

C++03以前:構造体の初期化にコンストラクタのメンバ初期化子を使う

C++の構造体(struct)は実質的にclassなので*10メンバ関数を定義できるし派生(継承)も可能だ。もっとも、クラス/抽象データ型らしく使いたいならば、structではなくclassで定義した方が、コードを見ただけで意図が明確となる。structは、旧来のレコード型のようなケースで使うのが望ましい。なので個人的には、structに演算子オーバーロード以外のメンバ関数とか要らない気もするのだが……。

ただし、コンストラクタは構造体でも有用だ。メンバを初期化するコードを書いておけば、その構造体のオブジェクトをどこで定義しても、必ず同じ内容に初期化される。

/* C言語の場合 */

struct Foo {
    int a;
    int b;
};
typedef struct Foo Foo;

/* 初期化用の定数もどき */
static const Foo Foo_INIT_VALUE = { 0, ~0 };

/* 初期化用の関数 */
static void foo_initialize(Foo *foo)
{
    assert(foo != NULL);

    foo->a = 0;
    foo->b = ~0;
}

static void func(void)
{
    Foo foo1, foo2;

    /*
     * foo1、foo2を明示的に初期化する必要がある。
     * 定数もどきか初期化関数を使えば、常に同じ初期値に初期化される。
     */
    foo1 = Foo_INIT_VALUE;
    foo_initialize(&foo2);

    /* 以下略 */
}
// C++03の場合

struct Foo {
    int a;
    int b;

    Foo : a(0), b(~0) {}
};

static void func()
{
    Foo foo1, foo2;

    // foo1、foo2の明示的な初期化は不要。
    // 常に同じ初期値になっている。

    // 以下略
}

C++11以降:構造体のメンバの初期化にクラス内初期化子を使う

ところでC++11以降では、非静的メンバ変数ならコンストラクタを使わずに初期化式にて初期化パラメータを記述することもできる。

// C++03の場合

struct Foo {
    int a;
    int b;

    Foo : a(0), b(~0) {}
};
// C++11の場合

struct Foo {
    int a { 0 };
    int b { ~0 };
};

あるメンバ変数において、クラス内初期化子と「コンストラクタのメンバ初期化子」の両方が存在する場合は、メンバ初期化子の方が優先される。

個人的には、C++11以降では「既定値による初期化」にはクラス内初期化子を使用しておき、必要に応じてコンストラクタのメンバ初期化子を用いて「インスタンス生成時にユーザが既定値以外の値で初期化する」ことを可能にしておく、みたいな感じの使い分けがベターかなと思っている。

構造体の初期化にmemset(3)を使わない

そもそもC言語でも構造体をmemset(3)でゼロクリアする行為は割とダーティハックなのだけど*11C++のstructは「デフォルトアクセスがpublicなclass」なので、C互換構造体ではない構造体をmemset(3)をするのは非常に危険だ。仮想関数テーブルなどの「ソースコードに書かれているメンバ変数」以外のオブジェクトを内包するインスタンスにたいして、memset(3)したら不味い領域まで「int型の0のビットの並び」で埋め尽くしてしまうことになる。

で、コードを書く際に一々「C互換構造体か否か」その他を確認してmemset(3)するのは非常に手間だし、「C互換構造体が後で非C互換構造体になったらどうしよう?」とか心配しだすと夜も眠れないので、もう最初からmemset(3)を使うのは諦めて、素直にコンストラクタなどを使うこと(ただしC言語で実装されたモジュール由来の構造体を除く)。

というか、もう一度繰り返すけど、そもそもC言語で構造体をmemset(3)でゼロクリアするのだって狂気と正気の境目でタップダンスを踊るのと同義な訳で(以下略)

蛇足:個人的には、C言語のコードを書く場合にも「構造体と対になる『構造体のメンバを明示的に初期化する関数』を用意して初期化に使用する」とか、面倒でもそういう風にするべきだと思う。

C++11以降:構造体を定義する際にfinal指定子を付与して派生(継承)禁止しておく

C++11にてfinal指定子が追加された。構造体(というかstructとclass)を定義する際に型名の後ろに付与することで、その構造体から派生(継承)することができなくなる。

// C++03の場合

struct Foo {
    int a;
    int b;

    Foo : a(0), b(~0) {}
};
// C++11の場合

struct Foo final {
    int a { 0 };
    int b { ~0 };
};

※finalを書く場所に注意すること。C++のfinalは一般的なキーワード(予約語)ではなくコンテキスト依存キーワードである。所定の場所に記述した場合のみキーワードとしての効力を発揮するが、それ以外の場所では識別子として扱われる。

基本的に、Better Cで構造体を定義する際に「派生(継承)によるクラス階層の構築」を意識して設計・実装することは極めて稀である。つまり定義された構造体は、基底クラス(スーパークラス)として用いるには不適切な代物である可能性が高い。なので積極的にfinalを付与して派生(継承)を禁止しておく方がよいだろう。

同様に、Better Cからのステップアップでクラスを定義するようになった場合には、特定の課題の解決に特化したクラスを直接記述することが多く、派生(継承)を考慮した設計・実装になっていないことが多い。このような場合にも積極的にfinalを付与しておく方がよいだろう。

一方で「派生(継承)によるクラス階層の構築」を意識してクラス群を設計・実装する場合には、finalを控えめに使用するべきなのは言うまでもない。

構造体のオブジェクト同士で演算したいなら演算子オーバーロードを使う

頻度は低いが、時々構造体のオブジェクト同士で演算を行いたいことがある。可能性としては、比較演算(特に一致・不一致)が多いだろう*12

この時、専用の関数やメソッドとして演算処理を実装するのではなく、演算子オーバーロードを使用しておくと、標準ライブラリのalgorithmや関数オブジェクトと組み合わせて処理を記述できる可能性が高くなり、後々の実装が楽になる……かもしれない。

/* C言語の場合 */

struct Foo {
    int param1;
    int param2;
    int param3;
};
typedef struct Foo Foo;

static void foo_initialize(Foo *foo)
{
    assert(foo != NULL);

    foo->param1 = 0;
    foo->param2 = 0;
    foo->param3 = 0;
}

static int foo_less_than(const Foo *lhs, const Foo *rhs)
{
    assert((lhs != NULL) && (rhs != NULL));

        return lhs->param1 < rhs->param1 ||
               (!(lhs->param1 > rhs->param1) &&
                   (lhs->param2 < rhs->param2 ||
                    (!(lhs->param2 > rhs->param2) &&
                        lhs->param3 < rhs->param3)));
}
static int foo_greater_than(const Foo *lhs, const Foo *rhs)
{
    return foo_less_than(rhs, lhs);
}
static int foo_equal_to(const Foo *lhs, const Foo *rhs)
{
    assert((lhs != NULL) && (rhs != NULL));

    return lhs->param1 == rhs->param1 &&
           lhs->param2 == rhs->param2 &&
           lhs->param3 == rhs->param3;
}
// C++03の場合

struct Foo {
    int param1;
    int param2;
    int param3;

    Foo() : param1(0), param2(0), param3(0) {}
    ~Foo() {}

    bool operator < (const Foo& rhs) const {
        return param1 < rhs.param1 ||
               (!(param1 > rhs.param1) &&
                   (param2 < rhs.param2 ||
                    (!(param2 > rhs.param2) &&
                        param3 < rhs.param3)));
    }
    bool operator > (const Foo& rhs) const {
        return rhs < *this;
    }
    bool operator == (const Foo& rhs) const {
        return param1 == rhs.param1 &&
               param2 == rhs.param2 &&
               param3 == rhs.param3;
    }
};

例えばstd::mapのkeyとして使いたいなら、比較演算子(デフォルトではoperator<)が必須だ。

C++11以降:ホスト環境:構造体のオブジェクト同士で比較演算しそうな時に、構造体を止めてstd::pairやstd::tupleを使ってみる

std::pairやstd::tupleなら、デフォルトで比較演算子が定義されている。前提として、メンバ変数の型にて比較演算子が定義されている必要がある。

// C++03の場合

struct Foo {
    int param1;
    int param2;
    int param3;

    Foo() : param1(0), param2(0), param3(0) {}
    ~Foo() {}

    bool operator < (const Foo& rhs) const {
        return param1 < rhs.param1 ||
               (!(param1 > rhs.param1) &&
                   (param2 < rhs.param2 ||
                    (!(param2 > rhs.param2) &&
                        param3 < rhs.param3)));
    }
    bool operator > (const Foo& rhs) const {
        return rhs < *this;
    }
    bool operator == (const Foo& rhs) const {
        return param1 == rhs.param1 &&
               param2 == rhs.param2 &&
               param3 == rhs.param3;
    }
};
// C++11のstd::tupleを使用する場合

using Foo = std::tuple<int, int, int>;

構造体を単なるレコード型として用いる場合には、C++11以降の機能を使ってもよいのなら、std::tupleに切り替えるのも一つの手ではある。少なくともstd::mapとstd::tupleの相性は悪くない。

もっとも、構造体では自由なメンバ名を定義できるのにたいして、std::pairやstd::tupleには汎用な要素アクセス機能しかないため、既定の機能の範囲内では「格納している要素を含めた可読性」は構造体の方が向上させやすい。std::pairやstd::tupleの要素アクセスの可読性を向上させたいなら、typedefやusingで型に別名を付けた上で要素アクセス用関数を定義するか、std::pairやstd::tupleを基底とする派生クラスを定義してアクセサを用意するべきだろう。

// C++11の場合

using BaseFoo = std::tuple<int, int, int>;

struct Foo final : public BaseFoo {
	Foo::Foo(int a, int b, int c) : BaseFoo(a, b, c) {}

	int param1() const {
		return std::get<0>(*this);
	}
	int param2() const {
		return std::get<1>(*this);
	}
	int param3() const {
		return std::get<2>(*this);
	}
};

蛇足:「構造体とstd::tupleのどちらかを優先するべきか?」については、個人的には未だに明確な方針を得られていない(Kotlinでも「data classと『typealiasしたTriple』のどちらを使おうか?」と悩むことがある)。

おそらく公開インタフェースであったり、モジュール内で利用するデータ構造でも広範囲に使われるものについては、他者の可読性を考慮するに構造体か「『std::tuple + アクセサ』の一式」が望ましいだろう。

一方で、モジュール内で局所的に用いられる「一時的なデータセット」については、時に「わざわざ構造体を定義するよりも、std::tupleをそのまま(別名も付けずに)使ってしまった方が楽だ」というケースもある。

まあ、この辺は「自分がコードを書いている文化圏」の影響もあるからなあ……保守的なCプログラマが周囲に多いのならば、余計な摩擦を避けるために「なるべく構造体を使用する」という選択をするのも悪くない気がする。

ホスト環境:文字列は配列ではなくstd::stringやstd::stringstreamなどで取り扱う

文字列を保持する配列の大きさで悩んだり、ついうっかりstrncpy(3)の使い方を誤って終端のヌル文字が消えてバッファオーバーフローしたり、文字列の配列を引数にとる関数にてNULLポインタを引数に指定された際の対策を忘れて落ちたりするのは、何というか、非生産的だ。そうせざるをえない事情があるならともかく、普通にホスト環境でC++を使うシチュエーションで、そこまでシビアな条件である機会は少ないはずだ。

/*
 * C言語の場合
 *
 * よくありがちなインタフェース。
 * 実装する際には、色々と考えなくてはならない。
 * - 引数にNULLを突っ込まれた場合にどうするか?
 * - dst_sizeにはヌル文字分を含める? 含めない?
 * - 一々書き込み先の大きさを気にしなくてはならないのが面倒。
 * - 書き込み時にヌル終端を忘れずに……。
 */
extern void foo(const char *src, char *dst, const size_t dst_size);

C言語ならともかくC++なのだから、std::stringやstd::stringstreamで幸せになりましょうよ……なれるかなあ。

// C++の場合
//
// - 参照を使っていることもあり、NULLチェック不要。
// - 書き込み先の大きさは、とりあえず気にしなくてOK。
//   (メモリ不足の可能性との戦いはあるが……)
// - 普通に使う分には、ヌル終端を忘れることはないはず。
extern void foo(const std::string& src, std::string& dst);

状況次第では、標準ライブラリにこだわる必要はなくて、CStringやQStringでも構わないのだが、少なくとも文字列専用の型を用いるべきだろう……特段の事情があるのなら話は別だが。

ホスト環境:配列よりもstd::vectorやstd::arrayを使う

用途次第だが、配列よりもstd::vectorC++11以降ならstd::arrayも)の方が幸せになれることも多い。

個人的にはsize()とend()の存在がありがたい。size()があると、普通の配列を使う場合のように要素数を求めるマクロ/テンプレートを別途用意しておいて使ったり、配列の要素数を示す定数を用意して使いまわしたりする必要がなくなる。end()があると末尾の次の要素を簡単に参照できるので、標準ライブラリのalgorithmやnumericの関数テンプレートと組み合わせやすくなる。

// 配列の場合

static const int TABLE[] = {
    32, 64, 96, 128, 192, 256, 512, 1024
};

template <typename T, std::size_t N>
inline std::size_t NELEMS(const T (&)[N]) { return N; }

// 中略

const int *p = std::find(TABLE, &TABLE[NELEMS(TABLE)], value);
if (p == &TABLE[NELEMS(TABLE)]) {
    // 見つからなかった
}
// std::vectorの場合
// (このケースではstd::arrayの方が妥当かも)

static const std::vector<int> TABLE {
    32, 64, 96, 128, 192, 256, 512, 1024
};

// 中略

std::vector<int>::const_iterator p =
        std::find(TABLE.begin(), TABLE.end(), value);
if (p == TABLE.end()) {
    // 見つからなかった
}

あとC++11のinitializer_listは便利! 従来は、constなvectorを構築するために、vectorに設定する要素をまとめた配列を別途用意しておき、コンストラクタの引数に指定する必要があったが、その手間がなくなった。

// C++03の場合

template <typename T, std::size_t N>
inline std::size_t NELEMS(const T (&)[N]) { return N; }

static const int TABLE_SRC[] = {
    32, 64, 96, 128, 192, 256, 512, 1024
};

static const std::vector<int>
    TABLE(TABLE_SRC, &TABLE_SRC[NELEMS(TABLE_SRC)]);

// 中略

std::vector<int>::const_iterator p =
        std::find(TABLE.begin(), TABLE.end(), value);
if (p == TABLE.end()) {
    // 見つからなかった
}
// C++11の場合
// (このケースではstd::arrayの方が妥当かも)

static const std::vector<int> TABLE {
    32, 64, 96, 128, 192, 256, 512, 1024
};

// 中略

std::vector<int>::const_iterator p =
        std::find(TABLE.begin(), TABLE.end(), value);
if (p == TABLE.end()) {
    // 見つからなかった
}

ところで、個人的な話になるが、std::arrayのようにサイズを指定できて、指定されたサイズを上限としてstd::vectorのように可変として振る舞うコンテナが欲しい。標準ライブラリに追加されないだろうか?

ホスト環境:コンテナ/std::bitset/std::pair/std::tupleなどを使う

C++の標準ライブラリには、std::vector以外にも多種多様なデータ構造のコンテナが含まれている。コンテナ以外にも、固定サイズのビット集合としてstd::bitsetが、構造体を定義するまでもない軽度な用途に使えるstd::pair/std::tupleが用意されている。

用途に合わせてお好きなデータ構造をお楽しみください。こういった類のものを自前実装したり適当なライブラリを探して組み込んだりしなくてもよい(標準ライブラリに用意されている)ことも、Better Cの利点の1つだと思う。

もっともコンテナに関しては、実行速度においてstd::vector一択となりやすい傾向にある。アルゴリズムの教科書的にはリストの方が高速となるケースでも、std::listよりもstd::vectorの方が高速なことがあるようだ。なので、ちゃんとプロファイリングするべし。

独自のデータ構造を定義する際にクラステンプレートの利用を検討する

機会は少ないものの、C++の標準ライブラリのコンテナではニーズに合わないために、独自のデータ構造が必要となることがある。

こういう時こそクラステンプレートの出番である、かもしれない……。

クラステンプレートは、その性質上、どうしても汎用なアルゴリズム/データ構造の実装に用いられることが多いため、プログラミングのレイヤーによっては縁が薄かったりして、存在を忘れていることも珍しくない。

アプリケーションを実装する場合、定義するデータ構造の多くは解こうとしている課題に特化したものであるが、時々「よく考えたら、より汎用なデータ構造を抽出できそうだ」ということがある。そういう時、汎用なデータ構造を定義する手段としてクラステンプレートを思い出していただければ幸いである。

固定長の独自データ構造を定義する際にクラステンプレートの利用を検討する

要するにstd::arrayっぽいアレである。

21世紀も5分の1が経過した現在でも、組み込みだけでなくスマホアプリやPCアプリの開発においても、あえてメモリの動的確保を回避することがある。例えばiOS向けオーディオアプリでは、オーディオアプリ開発でありがちな4つの間違いにてリアルタイム性を得るためのルールの1つとして「オーディオ処理用スレッドの中で動的メモリ割り当てをしないこと」が提唱されている。

そんなわけで、実は未だに固定長のリングバッファの類は重宝するのだが……こういうものこそ、std::arrayのように非型テンプレートパラメータを使用したクラステンプレートとして実装して、各所で地味に再利用したいものである。

蛇足:C++11でstd::arrayが登場したことにより誤解していたのだが、非型テンプレートパラメータの機能自体はC++98のころから存在するようだ。なので仮にC++03縛りの環境であっても、std::arrayのようなデータ構造をクラステンプレートで実装できるはずである……が、如何せん古いコンパイラで試したことがないので真偽は不明である。

――clangやgccに「-std=c++03」を指定した状態でビルドと実行が可能なことは確認しているが、残念ながら「最近のコンパイラを旧規格のモードで使用した」だけだからなあ。

ホスト環境:値から値へのマッピング写像)にstd::mapやstd::unordered_mapを使ってみる

C言語では、値から値へのマッピングに泥臭くswitch文を使う実装がよく見られる。

/* C言語の場合:switch版 */

typedef enum { FOO_HOGE = 0, FOO_PIYO = 1, FOO_FUGA = 2, FOO_UNKNOWN = -1 } foo_t;
typedef enum { BAR_HOGE = 1, BAR_PIYO = 2, BAR_FUGA = 4, BAR_UNKNOWN =  0 } bar_t;

static bar_t bar_from_foo(const foo_t foo)
{
	switch (foo) {
	case FOO_HOGE:      return BAR_HOGE;
	case FOO_PIYO:      return BAR_PIYO;
	case FOO_FUGA:      return BAR_FUGA;
	case FOO_UNKNOWN:   /*FALLTHROUGH*/
	default:            return BAR_UNKNOWN;
	}
	/*NOTREACHED*/
}

少し工夫してデータ構造を用いる場合は、配列を使ったり(インデックス番号から、当該インデックスの要素にマッピング)、構造体の配列を使ったり(メンバAの値から、メンバBの値にマッピング)することになる。

/* C言語の場合:配列版 */

typedef enum { FOO_HOGE = 0, FOO_PIYO = 1, FOO_FUGA = 2, FOO_UNKNOWN = -1 } foo_t;
typedef enum { BAR_HOGE = 1, BAR_PIYO = 2, BAR_FUGA = 4, BAR_UNKNOWN =  0 } bar_t;

static const bar_t MAPTABLE[] = {
	BAR_HOGE, BAR_PIYO, BAR_FUGA
};

#define NELEMS(ary)  (sizeof(ary) / sizeof((ary)[0]))

static bar_t bar_from_foo(const foo_t foo)
{
	if (foo < 0 || (size_t) foo >= NELEMS(MAPTABLE)) {
		return BAR_UNKNOWN;
	}
	return MAPTABLE[foo];
}
/* C言語の場合:構造体配列版 */

typedef enum { FOO_HOGE = 0, FOO_PIYO = 1, FOO_FUGA = 2, FOO_UNKNOWN = -1 } foo_t;
typedef enum { BAR_HOGE = 1, BAR_PIYO = 2, BAR_FUGA = 4, BAR_UNKNOWN =  0 } bar_t;

typedef struct {
	foo_t foo;
	bar_t bar;
} map_table_t;

static const map_table_t MAPTABLE[] = {
	{ FOO_HOGE, BAR_HOGE },
	{ FOO_PIYO, BAR_PIYO },
	{ FOO_FUGA, BAR_FUGA },
};

#define NELEMS(ary)  (sizeof(ary) / sizeof((ary)[0]))

static bar_t bar_from_foo(const foo_t foo)
{
	size_t i;

	for (i = 0; i < NELEMS(MAPTABLE); i++) {
		if (MAPTABLE[i].foo == foo) {
			return MAPTABLE[i].bar;
		}
	}
	return BAR_UNKNOWN;
}

こういう用途にはstd::mapやstd::unordered_mapを使うことができる。

// C++の場合

enum foo_t { FOO_HOGE = 0, FOO_PIYO = 1, FOO_FUGA = 2, FOO_UNKNOWN = -1 };
enum bar_t { BAR_HOGE = 1, BAR_PIYO = 2, BAR_FUGA = 4, BAR_UNKNOWN =  0 };

static const std::map<foo_t, bar_t> MAPTABLE {
	{ FOO_HOGE, BAR_HOGE },
	{ FOO_PIYO, BAR_PIYO },
	{ FOO_FUGA, BAR_FUGA },
};

static bar_t bar_from_foo(const foo_t foo)
{
	auto p = MAPTABLE.find(foo));
	return (p == MAPTABLE.end()) ? BAR_UNKNOWN : p->second;
}

C++11のinitializer_listは便利っすね……。個人的には、この手のテーブルはソースコード上に予め記述しておくことが多いのだが、ごく稀に実行時に動的に構築したい場合もある。その場合は標準のコンテナを用いた方が手っ取り早かったりする。

ホスト環境:二次元配列による値から値へのマッピングをstd::pairとstd::mapに置き換えてみる

前項の亜種で、二次元配列のテーブルを使って整数値と整数値の組から値にマッピングするコードも、例えば std::map, T> のようなもので実現できる。

// C++の場合

enum foo_t { FOO_HOGE, FOO_PIYO, FOO_FUGA, FOO_UNKNOWN };
enum bar_t { BAR_HOGE, BAR_PIYO, BAR_FUGA, BAR_UNKNOWN };
enum baz_t { BAZ_HOGE, BAZ_PIYO, BAZ_FUGA, BAZ_UNKNOWN };

static const std::map<std::pair<foo_t, bar_t>, baz_t> MAPTABLE {
#define PAIR(x, y) std::make_pair(x, y)
	{ PAIR(FOO_HOGE, BAR_HOGE), BAZ_HOGE },
	{ PAIR(FOO_PIYO, BAR_PIYO), BAZ_PIYO },
	{ PAIR(FOO_FUGA, BAR_FUGA), BAZ_FUGA },
#undef PAIR
};

static baz_t map_flag(const foo_t foo, const bar_t bar)
{
	auto p = MAPTABLE.find(PAIR(foo, bar));
	return (p == MAPTABLE.end()) ? BAZ_UNKNOWN : p->second;
}

上記のようなケースでstd::unordered_mapを使いたいのなら、自前のハッシュ関数を実装する必要がある。それが面倒ならstd::mapを使った方がよいだろう。後でstd::mapがボトルネックになっていることが判明してから置き換えても罰は当たらないはずだ。

ホスト環境:配列やコンテナを舐める時にalgorithmやnumericを積極的に使う

標準ライブラリのコンテナを使っている際に、毎度毎度イテレータなどを使ってfor文で舐めているのは……色々な事情があってそうしているのならともかく、何も考えずに新規コードで常にそう書いているのを見ると「あまり近代的ではないな」と感じる。

// for文の場合(その1)
// (このケースではstd::arrayの方が妥当かも)

static const std::vector<int> TABLE {
    32, 64, 96, 128, 192, 256, 512, 1024
};

// 中略

std::size_t i;
for (i = 0; i < TABLE.size(); i++) {
    if (TABLE[i] == value) {
        break;
    }
}
if (i >= TABLE.size()) {
    // 見つからなかった
}
// for文の場合(その2)
// (このケースではstd::arrayの方が妥当かも)

static const std::vector<int> TABLE {
    32, 64, 96, 128, 192, 256, 512, 1024
};

// 中略

std::vector<int>::const_iterator p;
for (p = TABLE.begin(); p != TABLE.end(); ++p) {
    if (*p == value) {
        break;
    }
}
if (p == TABLE.end()) {
    // 見つからなかった
}

C++11以降の機能を使ってもよいのなら、Range-based for loopも悪くはない。悪くはないが、プリミティブなループ処理だ。

// Range-based for loopの場合
// (このケースではstd::arrayの方が妥当かも)

static const std::vector<int> TABLE {
    32, 64, 96, 128, 192, 256, 512, 1024
};

// 中略

auto found = false;
for (const auto& n : TABLE) {
    if (n == value) {
        found = true;
        break;
    }
}
if (!found) {
    // 見つからなかった
}

標準ライブラリのalgorithmやnumericの関数テンプレートでは、もう少し抽象的な機能を提供しているので、漁ってみる価値があるだろう。

// std::findの場合
// (このケースではstd::arrayの方が妥当かも)

static const std::vector<int> TABLE {
    32, 64, 96, 128, 192, 256, 512, 1024
};

// 中略

std::vector<int>::const_iterator p =
        std::find(TABLE.begin(), TABLE.end(), value);
if (p == TABLE.end()) {
    // 見つからなかった
}

ちなみに、標準ライブラリのfunctionalには基本的な演算の関数オブジェクトが用意されていて、algorithmの関数テンプレートと組み合わせて使うことができる。構造体のオブジェクト同士で演算したいなら、演算子オーバーロードで実装したり、構造体を止めてstd::pairやstd::tupleを使うようにしておけば、functionalやalgorithmの機能と組み合わせて処理を記述できるようになる。

C++03以前:関数ポインタよりは関数オブジェクトをやや優先する

algorithmに用意されている関数テンプレートには、関数ポインタを引数にとることが可能なものも多い。

// 関数ポインタの場合

static const int TABLE[] = {
    1, 1, 2, 3, 5, 8, 13, 21, 34, 55
};

template <typename T, std::size_t N>
inline std::size_t NELEMS(const T (&)[N]) { return N; }

static bool even(int n)
{
    return (n % 2) == 0;
}

// 中略

std::size_t even_count = std::count_if(TABLE, &TABLE[NELEMS(TABLE)], even);

しかし実行効率の問題*13と、あと関数ポインタはNULLを突っ込むことができてしまうが「関数オブジェクト+参照」の組み合わせではありえないという点で、どちらかと言えば関数オブジェクト推しですな。

// 関数オブジェクトの場合

static const int TABLE[] = {
    1, 1, 2, 3, 5, 8, 13, 21, 34, 55
};

template <typename T, std::size_t N>
inline std::size_t NELEMS(const T (&)[N]) { return N; }

struct Even {
    bool operator()(int n) {
        return (n % 2) == 0;
    }
};

// 中略

std::size_t even_count = std::count_if(TABLE, &TABLE[NELEMS(TABLE)], Even());

蛇足:まあでもC++11のラムダ式と比べれば、関数ポインタも関数オブジェクトも五十歩百歩な気がする。

C++11以降:ラムダ式(無名の関数オブジェクト)を積極的に使う

algorithmの関数テンプレートのお供にラムダ式。わざわざ関数/関数オブジェクトを別途定義する必要がなくなってうれしい。

// ラムダ式の場合

static const std::vector<int> TABLE {
    1, 1, 2, 3, 5, 8, 13, 21, 34, 55
};

// 中略

auto even_count = std::count_if(TABLE.begin(), TABLE.end(), [](int n) {
    return (n % 2) == 0;
});

うれしいのだが、書き方がObjective-C(というかApple独自拡張)のブロック構文と異なるのが……。Objective-C++にてObjective-CのクラスとC++のクラスが入り混じってカオスなところに、さらにブロック構文とラムダ式が追加されるだなんて、憂鬱だ。

// Objective-C++(C++11以降)でブロック構文を使用した場合の例

NSArray<NSNumber *> *table = @[
    @1, @1, @2, @3, @5, @8, @13, @21, @34, @55
];

// 中略

auto indexes = [table indexesOfObjectsPassingTest:^(NSNumber *obj, NSUInteger idx, BOOL *stop) {
    return (obj.intValue % 2) == 0;
}];
auto even_count = indexes.count;

蛇足:なんで急にObjective-C++を持ち出したのかというと、2022年現在においても、iOS/macOS向けにネイティブアプリを実装する際に局所的にお世話になることがあるからである……。

現時点において、iOS/macOS向けネイティブアプリを開発する際の第一言語はSwiftだ。それは間違いない。しかしながら、リアルタイム性が要求される機能を実装しようとした時、暗黙のうちにメモリ・アロケートや排他ロックが発生しうるSwift(そしてObjective-C)は都合が悪い。

そこで、アプリの大半はSwiftで実装しつつ、リアルタイム性が必要となる一部分のみをC言語C++で実装することになる。C言語縛りだと実装が面倒になるので、(Better Cレベルだとしても)C++を使用することが多い。

問題は「C++で実装した機能をどうやってSwiftに組み込むか?」だ。SwiftはC言語との相互運用をサポートしているため、C言語の関数を呼び出すことが可能だ。でも残念ながらC++との相互運用はサポート対象外だ。

この場合のアプローチは2つある。

1つは「モジュールの公開インタフェースはC言語互換にしつつ、中身はC++で実装する」という方法だ。Swiftからは「C言語の関数を呼び出す」という使い方になる。正直なところ、この方法はSwift側からみて「モジュールの使い勝手がちょっと微妙」という印象を受けやすい。例えば……AppleのCore MIDI frameworkをSwiftから利用しようとして「面倒くさいぞ!」と思った人はいないだろうか? あの辺のAPI、未だにC言語のままなのだ*14

もう1つは「モジュールの公開インタフェースは『Objective-Cのクラス』にしつつ、中身はObjective-C++で実装する」という方法だ。SwiftはObjective-Cとの相互運用もサポートしている。この方法で実装したモジュールは、Swiftからは「普通のクラス」として扱うことができるので、使い勝手は悪くない。ただしモジュールの実装は面倒だ。実装時にC++Objective-Cをほぼ同時に扱うことになるし、SwiftとObjective-Cの相互運用に関するマナーにも通じておく必要がある。

――というわけで、「そこそこのリアルタイム性を確保する」というニッチで泥臭い要求に対応しつつ「モジュール利用者にもやさしく」ということを目指そうとすると、C++Objective-CをちゃんぽんしたObjective-C++の世界となり、2種類のクラスと2種類のブロック構文と2種類のキャスト*15の競演で目が回るのである。悪酔いしそう。

関数テンプレートを使う(クラステンプレートは無理でも……)

型は違えどコードの見た目は瓜二つ、というコードを見かけた場合、関数テンプレートを使うことで、型を超えてコードを抽象化(一般化)できる可能性がある。

例えば値の範囲をチェックして、最小値や最大値から外れた値を範囲内にまるめたい場合、次のような関数テンプレートを用意しておけば、型に関係なく使いまわすことができる。

// C++03の場合
// (※C++17では同様の機能を提供するstd::clamp()が追加された)

template <typename T>
const T& in_range(const T& val, const T& min_val, const T& max_val)
{
    assert(min_val <= max_val);
    return std::min(std::max(val, min_val), max_val);
}

あと、上記の関数テンプレートの場合、副次的に「全ての仮引数にて同じ型を強制する」という効果もある。コンパイラの種類や設定次第だが、例えばsignedとunsignedの比較は警告が出る程度で素通りしてしまうことが多い。だが関数テンプレートで同じ型を指定しているなら、型が異なる時点でコンパイルが通らない。否応なく「異なる型による比較演算」という事実を突きつけられたとき、プログラマがとりうる行動は「この比較演算は妥当か? どうすれば妥当になるか? キャストして問題ないか?」と見直しを図るか、「面倒だから(機械的に)キャスト!」と凶行に走るか、このどちらかだ。

個人的に、「クラステンプレートの実装」となるとちょっと身構えてしまう難易度のような気がしてしまう(錯覚かもしれない)が、関数テンプレートはもう少し易しい。オブジェクト・ファイルの大きさを気にしなくてよいのなら、使う価値がある機能だ。

C++11以降:自作関数が例外を送出するか否かチェックして、送出しないならnoexceptを付与する

C++11以降では、例外を送出しない関数にnoexceptを付与することで、パフォーマンスの向上を期待できる。

// C++11の場合
// noexcept の代わりに noexcept(true) と書くことも可能

int triple(const int n) noexcept
{
    return n * 3;
}

noexceptの付与は、パフォーマンス向上の他に「例外安全性(例外を送出しないこと)の保証」の役割も果たす。例外安全性を保証するためには、自分が書いたコードに例外が発生する余地が無いことを、注意深く検証する必要がある。

C言語には例外という機能は無いので、Better Cする時に「C++には例外があり、標準ライブラリで普通に使用されている」という事実を忘れがちである。なのであえてnoexceptを用いることで、例外安全性について検証する工程をコーディング時に設けた方が安全だろう。

C++11以降:overrideを積極的に使う

派生(継承)にてメンバ関数をオーバーライドする場合、overrideキーワードは非常に便利。特に、試作中で基底クラス(スーパークラス)のメソッドのシグネチャに度々変更が発生する場合とか(あまりよい開発スタイルではないのだが……)。

蛇足:しかしBetter Cなのにclassとか派生(継承)とか、レギュレーション違反では?

C++11以降:enum class(enum struct)を使う

従来のenumは名前の衝突が起きやすかった。そのため、例えば命名規則プレフィックスを付けるなどして衝突を避けることが多かった。また型チェックが緩いため、整数型や他のenum型の変数に容易に変換できてしまった。

/* C言語の場合 */

enum KeyCode {
    KeyCode_A,
    KeyCode_B,
    KeyCode_C
};

// 中略

enum KeyCode key_code_1 = KeyCode_A;
int          key_code_2 = KeyCode_B;

C++03では、名前の衝突については、名前空間や構造体を用いることでも回避できた。しかし型チェックの問題は依然として残ったままだった。

// C++03の場合

namespace KeyCode {
    enum Type {
        A,
        B,
        C
    };
};

// 中略

KeyCode::Type key_code_1 = KeyCode::A;
int           key_code_2 = KeyCode::B;

C++11では、scoped enumeration(enum classやenum struct)を用いることで、名前の衝突だけでなく型チェックの問題も回避できるようになった。型付けの制約から逃れたいならば、明示的にキャストを用いる必要がある。

// C++11の場合

enum class KeyCode {
    A,
    B,
    C
};

// 中略

KeyCode key_code_1 = KeyCode::A;
int     key_code_2 = static_cast<int>(KeyCode::B);

scoped enumerationの強力な型付けは、enumで「関連のあるシンボル」の集合を定義して用るときにつまらない凡ミス*16を回避しやすくなる。

C++11以降:ホスト環境:文字列処理に正規表現を導入してみる

個人的には使用する機会は少ないが、正規表現が使えるようになった。検索や置換にて使用できないか、検討しても良いだろう。

蛇足:個人的に「スクリプト言語正規表現を用いた検索・置換の記法」に慣れている身としては、C++では正規表現を用いた処理を直感的に書けないというか、むしろスクリプト言語では直感的に書ける反面予期せぬオーバーヘッドが発生していそう――と「C++正規表現を使ったコード」から邪推してしまうというか、微妙な気分である。

C++11以降:ホスト環境:標準ライブラリの機能でマルチスレッドする

やっと移植先に応じてマルチスレッドのコードを書き分けなくても良くなった……。

なお古典的なスレッド・プログラミングに浸りきった人が見よう見まねでコードを書くと「std::thread・std::mutex::lock()・std::mutex::lock()unlock()・スレッド間でイベント通知するためのvolatileなフラグ変数」を多用しがちだが、処理の内容次第ではstd::threadを直接使用するよりもstd::async(std::launch::async)の方が扱いやすいことがあるし、std::mutexのアンロック忘れを回避しやすくするstd::lock_guardその他が存在するし、1回限りのスレッド間通信にはstd::promiseとstd::futureのセットがあるし、そもそも「1命令でのデータ書き換え」を期待してvolatileなプリミティブ型を使うのは止めてstd::atomicにしよう*17と声を大にして言いたい。

蛇足:個人的に、条件変数の使い道を理解しきれていない気がする――std::queueと組み合わせてスレッド間通信用のキュー(リアルタイムOSのメールボックスみたいなやつ)を作っただけで満足しちゃった。

RAIIによるリソース管理を享受する(例え提供する側にならなくとも……)

C++にはRAIIという「リソース管理に関するイディオム」がある。乱暴に言えば「コンストラクタでリソースを確保して、デストラクタでリソースを解放する」classを使用することで「変数の生存期間を利用して、自動的にリソースを解放させる」という方法だ。

これから新たにC++でBetter Cする人はほぼ確実にCプログラマだと思うので*18、Cプログラマを想定して書くと、C言語のコードのセマンティクスとしては:

  • 内部変数は、定義した時点で生成されて、定義したブロックを抜けた時に消失する。
  • ヒープ領域の変数は、malloc(3)やcalloc(3)で領域を確保した時点で生成されて、free(3)で解放した時点で消失する。

「何を急に当たり前のことを」とか「『消失した』後でもアドレスが分かっていれば残骸を参照できちゃうよね」とか突っ込み所は色々あると思うが、ちょっとだけ我慢してほしい。

C++でclassやstructを定義する際に、コンストラクタやデストラクタを追加することができる。ではコンストラクタやデストラクタはいつ実行されるか? ここで先に書いた内部変数とヒープ領域の変数の話が関わってくる。

  • インスタンスが内部変数ならば、定義した時点でコンストラクタが実行されて、定義したブロックを抜ける時にデストラクタが実行される。
  • インスタンスがヒープ領域の変数ならば、new演算子で生成した時点でコンストラクタが実行されて、delete演算子で解放する時点でデストラクタが実行される。

C++では「変数の生存期間」と「コンストラクタ/デストラクタの実行タイミング」が同期している。特にデストラクタの実行タイミングが明確である点は、ガベージコレクションを採用しているモダンな言語から見た時に特異にうつる部分だと思う*19

RAIIを念頭に置いたclassでは、コンストラクタでリソースを確保して、デストラクタでリソースを解放する。なので、仮にRAIIに則したclassのインスタンスを内部変数として生成したならば、その変数を定義したブロックを抜ける際にデストラクタが実行されて、リソースが解放される。

RAIIは標準ライブラリで多用されているので、自分自身が提供する側にならずとも、その恩恵を享受できる。

例えばある関数内で、作業用のバッファを一時的に利用したいとする。必要なバッファサイズは実行時に動的に決まる。最大サイズについて明確な仕様が存在しないこともあり、伝統的な配列は使えない。C言語でホスト環境向けにクロスプラットフォームに書くならば「malloc(3)で確保して、使い終えたらfree(3)で解放する」という感じになるだろう*20

/* C99以降のC言語の場合 */

#include <assert.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>

static bool foo(const size_t buf_size)
{
    assert(buf_size > 0);

    /* 作業用バッファを確保 */
    uint8_t *buf = malloc(buf_size);
    if (buf == NULL) {
        return false;
    }

    /* 中略:バッファを使って色々と作業 */

    /* 使い終わった作業用バッファを解放 */
    free(buf);

    return true;
}

この時、よくやりがちなミスが「作業中に問題が発生して途中returnさせた時にfree(3)し忘れていて、メモリリークが発生する」というものだ。

/* C99以降のC言語の場合 */

#include <assert.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>

static bool foo(const size_t buf_size)
{
    assert(buf_size > 0);

    /* 作業用バッファを確保 */
    uint8_t *buf = malloc(buf_size);
    if (buf == NULL) {
        return false;
    }

    /* 中略:バッファを使って色々と作業 */

    /* 途中で別の関数を呼んで: */
    int rc = bar();
    if (rc < 0) {
        /*
         * エラーコードが返ってきたので中断
         * →あれ? free(3)し忘れている……?
         */
        return false;
    }

    /* 中略:バッファを使って色々と作業 */

    /* 使い終わった作業用バッファを解放 */
    free(buf);

    return true;
}

C++ではどうか? malloc(3)/free(3)のペアをnew/deleteに置き換えただけでは、同様の問題が発生する可能性がある。そこで標準ライブラリのstd::vectorを内部変数として利用する。std::vectorはRAIIに則しているので、関数を終了して内部変数が破棄されるタイミングでデストラクタが実行されて、内部で確保しているだろうヒープメモリが解放される。なのでメモリリークは発生しない。

// C++の場合

#include <cassert>
#include <cstddef>
#include <cstdint>
#include <vector>

namespace {

bool foo(const std::size_t buf_size)
{
    assert(buf_size > 0);

    // 作業用バッファを確保
    std::vector<std::uint8_t> buf(buf_size);

    // 中略:バッファを使って色々と作業

    // 途中で別の関数を呼んで:
    auto rc = bar();
    if (rc < 0) {
        // エラーコードが返ってきたので中断
        // →bufは内部変数
        // →関数終了時にデストラクタが呼ばれてリソース解放
        return false;
    }

    // 中略:バッファを使って色々と作業

    // 使い終わった作業用バッファを解放しなくとも、
    // 関数終了時にデストラクタが呼ばれてリソース解放

    return true;
}

} // namespace

(std::vectorの生データ領域を配列代わりに使うことは、抽象化層の中に手を突っ込んでいるようで少々決まりが悪い。でもC++11でstd::vector::data()が追加されたから許されないかと考えている)

RAIIに関して個人的に興味深いのは、C11で追加されたstd::lock_guardだ。

C++のマルチスレッド機能には、排他制御用にstd::mutexが用意されている。排他したい区間の開始時にロックして、区間の終わりでアンロックする、という使い方をする。組み込みCプログラマであっても、リアルタイムOSを扱ったことがあるなら割と想像がつくと思う*21

#include <mutex>

// 中略

std::mutex sync_mutex;

// 中略

{
    sync_mutex.lock();

    // 中略:排他が必要な処理を行う

    sync_mutex.unlock();
}

ここでよくあるミスが、途中returnなどの一部の経路にてアンロックを忘れてしまう、というやつだ。この場合、正常処理では何も起きないが、特定の条件で異常処理が走るケースに限ってデッドロックが発生する――という詳細が判明しない限り「時々システムがフリーズする(WDTアリなら『時々システムが再起動する』)」といういかにも面倒くさい不具合報告に悩まされることになる*22

std::lock_guardは、RAIIを活用してミューテックスのアンロックし忘れを解消する機能だ。コンストラクタの引数でミューテックスを引き渡すと、コンストラクタの中でロックして、デストラクタにてアンロックする。

#include <mutex>

// 中略

std::mutex sync_mutex;

// 中略

{
    // std::lock_guardのインスタンスを生成→内部でロック開始
    std::lock_guard<std::mutex> lck(sync_mutex);

    // 中略:排他が必要な処理を行う

    // スコープを抜ける時、デストラクタでアンロックされる
}

つまり途中returnだろうと何だろうと、スコープを抜けた時にアンロックされる。

特に排他区間のコード行数が長くなるケースや、排他区間内で「例外をthrowする可能性がある関数」を呼び出す可能性があるケースでは、デストラクタでミューテックスをアンロックするstd::lock_guardは非常に重宝する。

もしも組み込みでC++が利用可能ならば、リアルタイムOSAPIのラッパーとしてRAIIを用いたクラスを用意して、リソース管理に使用したいところである。

蛇足:RAIIのような仕組みを簡易に実現できる言語機能がC言語にも欲しい。「Go言語のdefer」みたいな機能でもOK。リソース解放忘れ防止に役立つと思うのよ……リアルタイムOSセマフォミューテックス解放忘れでデッドロック、みたいな悪夢は嫌だ。

C++11以降:ホスト環境:どうしてもnewしたいならスマートポインタを併用する

個人的に、そもそも自前で動的メモリ確保するコード(C言語malloc(3)やC++のnew)を書くことが極端に少ないために、その影響でスマートポインタに全く習熟していない。

なので、スマートポインタについて言及するのはお門違いな気がするのだが……それでも、生ポインタに向かってnewするぐらいならば、まずはstd::unique_ptrを利用できないか検討した方が良いと思う(次点でstd::shared_ptr)。

C++のスマートポインタはRAIIに則している。std::unique_ptrの場合は、スマートポインタ自体が破棄されるタイミングで、ポイント先の「ヒープ領域に存在するだろうオブジェクト」を解放してくれる。

前項の作業用バッファを確保/解放する例で、RAIIを活用するためにstd::vectorを用いる旨を書いたが、同様のことはスマートポインタでも実現できる。

// C++11のstd::unique_ptrを使用する場合

#include <cassert>
#include <cstddef>
#include <cstdint>
#include <memory>

namespace {

bool foo(const std::size_t buf_size)
{
    assert(buf_size > 0);

    // 作業用バッファを確保
    auto buf = std::unique_ptr<std::uint8_t[]>(new std::uint8_t[buf_size]);

    // 中略:バッファを使って色々と作業

    // 途中で別の関数を呼んで:
    auto rc = bar();
    if (rc < 0) {
        // エラーコードが返ってきたので中断
        // →bufは内部変数
        // →関数終了時にデストラクタが呼ばれて、deleteでメモリ解放
        return false;
    }

    // 中略:バッファを使って色々と作業

    // 使い終わった作業用バッファを解放しなくとも、
    // 関数終了時にデストラクタが呼ばれて、deleteでメモリ解放

    return true;
}

} // namespace

ただし、スマートポインタをバリバリ使用するためには、どこかの時点で右辺値参照やムーブセマンティクスについてある程度理解する必要がある。これって、CプログラマC++03時代のプログラマからすると、微妙にハードルが高いかもしれない。

でも「ムーブによる所有権の移動」という考え方は学ぶ価値がある。先に書いたように「自前で動的メモリ確保するコードを書くことが極端に少ない」私であっても、リソース管理の観点で色々と考えた結果、std::unique_ptrを選択したことが1回、std::shared_ptrを選択したことが2回ある――というぐらいには、ホスト環境向けのコードを書く時に「タンスのいちばん下の引き出しにしまわれた道具」として出番がある。他の人ならば、もっと出番があるだろう。

まとめ

C++は難しくてよく分からない……まあ、私は所詮自称Cプログラマで、『Effective C++』すら読んでいないので*23C++ガチ勢からすると妙なコードを書いていると思う。

*1:『Code Complete 第2版 上』の10.3~10.4を参照。

*2:正確に言えば、整数や浮動小数点数リテラルは型を持っているし、接尾辞によって型を明示することも可能である。しかし私を含めて、リテラルの型を明示せずに使用する怠惰なプログラマが多いのも事実である。これに「マクロ置換による定数定義」が加わることで、変数と定数の型が食い違う(なので暗黙のうちに型変換が発生している)コードが出来上がることになる。

*3:C++14でconstexpr関数における制限が緩和されて扱いやすくなったため。constexpr関数は呼び出し時に「引数が定数であり、かつ呼び出し式の左辺がconstexpr変数である」ならばコンパイル時に計算が行われる――という特徴はプログラミング時の札の切り方に変化をもたらすと思うが、少しばかりBetter Cとは乖離があるからなあ。

*4:例えばVisual Studio Codeとか。

*5:ジェネリックラムダのこと。

*6:ホスト環境向けのコードを書いているならば、stdexceptに定義されている標準的な例外クラスを使用してもよいだろう。

*7:Windows APIに例えるなら、ハンドルを取得・参照するような設計のモジュールのこと。

*8:例えばMathクラスのような、クラスメソッド/静的メンバ関数しかもたないもの。

*9:正確には型ではなくテンプレートなのだが、Cプログラマを想定して、あえて分かりやすい(そして不正確な)表現を用いている。

*10:デフォルトアクセスが違うだけ。classではprivate、structではpublic。

*11:memset(3)による構造体のゼロクリアでは、構造体の実体が存在するメモリ領域をint型の0のビットの並びで埋め尽くす、という処理を行っている。ところで大抵の環境ではfloatやdoubleなどの「浮動小数点数における0でのビットの並び」と「int型の0のビットの並び」は異なる。また、CやC++の標準規格においては、コンピュータのハードウェア上における「ヌルポインタを意味する値のビットの並び」について定義されていないので、「int型の0のビットの並び」と同じか否かは環境依存の話となる。よって、少なくともfloat/double/ポインタ型のメンバ変数を持つ構造体をmemset(3)でゼロクリアする行為は不適切である、と考えた方がよい。

*12:構造体のオブジェクト同士で算術演算や論理演算を行いたいケースは稀では? まあ、加算ぐらいならありそうだが。

*13:コンパイラにもよるが、「関数ポインタでは関数呼び出しがインライン化されず、関数オブジェクトではインライン化される」という可能性がある。

*14:2022年2月時点の話。正直なところObjective-Cのコードの中で「バリバリのC言語の流儀」でCore MIDIAPIを叩く方が扱いやすい気がする。

*15:Objective-Cのクラスのオブジェクトのキャストは、C言語流のキャストの構文で書かなくてはならない。でも時々手が滑ってC++のキャストの構文で書いてしまうのである……。

*16:誤って異なるenumの列挙子やあさっての整数定数を代入してしまったり、算術演算をしてしまうことで、enum変数の中身が自身の型の列挙子と一致しなくなる危険のこと。

*17:言語規格としては、フリースタンディング環境でもstd::atomicは用意されているはずである。

*18:偏見である。

*19:そしてJava普及期に初めてGCに触れたプログラマjava.lang.Object.finalize()に振り回された遠因でもあるような気がする。Javaに限らず、この手の機能はGCのタイミングに依存するものであるし、言語仕様次第だがJavaのように「実行順序は不定」とか「実行されずにプロセスが終了することもある」のような制約があるものだ。

*20:C99で追加された可変長配列は、以前よりVisual Studioでは未サポートだった上に、C11でオプション扱いになってしまった。特定の環境向けでも良いのならば、LinuxBSDのalloca(3)や、Windows APIの_alloca()/_malloca()もあるが、_malloca()を除けば、C99の可変長配列を含めて「確保可能な最大サイズは、スタックサイズの影響下にある」といえるだろう。あと_malloca()は_freea()とセットで使うこと。

*21:タスク間で排他が必要な部分をバイナリセマフォミューテックスでガードするこ とがあるので。

*22:まず「特定の条件で」「異常処理が走るケース」が不明なところから調査開始するのが大半なので……。

*23:一応『C++のエッセンス』『Effective Modern C++』『プログラミング言語C++ 第4版』は本棚にあり、時々参照しているけど。

汝テストアプリと侮るなかれ

信頼性のあるテストアプリやテストデータを用意できないと、十中八九モジュールは爆発する――私の職業プログラマとしての経歴の大半は、モジュールの開発に費やされてきたのだが、その中で悟ったことだ。

振り返ると、何らかのアプリケーションなりシステムなりを丸ごと自分で担当した、という機会は少ない。それよりも、そこそこ大きなシステムの一部を開発した、という経験の方が圧倒的に多い。今もそうである。

だからクラスやコンポーネントなど――便宜上、以降ではモジュールと表現する――を実装して、他人に使用してもらったり、時には自身で組み込み先システムにモジュールを呼び出すコードを追加したりする、というスタイルが身についている。

ところで、コーディングしたモジュールは、それ単体では動作しない。動かすためには、組み込み先となる何かを用意しなくてはならない。

開発中のアプリにべったりのモジュールなら、恐る恐るアプリに組み込んで動かすし、独立性の高いモジュールなら、別途テスト用のアプリケーションを用意することになる。

この時、ままありがちなことだが、「信頼性が担保されていないモジュール」を「信頼性に乏しいソフトウェア」に組み込んで動かした場合、何か問題があった時に「モジュール」と「組み込み先のソフトウェア」のどちらに問題があるのか、もしくは双方に問題があるのか、原因の切り分けで苦労することになる。

だから、もしもテスト用のアプリケーションを別途用意することが許される環境であるならば、テストアプリの実装の際には、モジュールを書いた時と同等ぐらいに品質に気を配るべきだ。

もちろん、テストアプリだからこその手抜きは許されるだろう。ただしこの場合の「手抜き」には、「テストアプリの基本的な品質は担保されている」という前提がある。この前提を忘れてはならない。つまり、手抜きが許される部分と許されない部分がある、ということだ。

一般には、テストアプリ実装時に手抜きが許されるのはフールプルーフだろう。利用者が開発者本人ないし同等レベルのプログラマであることもあり、フールプルーフの機構を組み込まずに「運用でカバー」というやり方をしても許される余地がある。

また「無くても困らないが、あれば便利」という機能も、「実装のコスト」と「テスト省力化の期待値」との兼ね合いで省略されることが多い。例えばユーザが入力したパラメータの保存/読み出し機能などは、テストの省力化が期待されるなら実装されるが、それほど省力化に繋がらないなら見送られるだろう。

コードを書けば書くほど、潜在的なバグの数は増えるだろう。その観点では、テストアプリの機能をスリム化することは、テストアプリ自身の品質向上に結びつくはずだ。この点より「無くても困らないが、あれば便利」な機能の実装は見送られやすい、という面もある。

一方で内部品質に関しては、例えテストアプリであっても高品質が求められる。これこそが「テストアプリの基本的な品質は担保されている」という前提をもたらす。仮に内部品質が悪ければ、テストアプリは安定動作せず、モジュール組み込み後に「動作が不安定である原因はモジュールにあるのか、それともテストアプリなのか?」と悩むことになるだろう。

テストアプリに関して言えば、ベテランの言う「手抜きアプリだから」は「内部品質には気を使っているけど、フールプルーフとか便利機能とかはあまり考えていないから」という意味となる。そこだけは、次世代にしっかり伝えておくべきだ。さもなくば後進は内部品質まで怪しいテストアプリを書いてしまうだろう。

人はなぜPCを自作していたのだろうか?(Re: 自作PCってなんで人離れていったんだろ)

自作PCってなんで人離れていったんだろ

興味深い疑問である。

そもそも人はなぜPCを自作していたのだろうか? この疑問に思いをはせる際に、人々の「PCにたいする姿勢」に注目すると、ほんの少しだけ見えてくるものがある。

  1. PCはあくまで手段である、という人たち
  2. PCそのものが目的化している人たち

1990年代半ばから2000年代前半までは、PCはまだまだ高価だった。そのため「PCはあくまで手段である、という人たち」の中でも「道具にこだわる」タイプの人の一部には「安価で性能のよいPC」を求めて自作PCに手を出す人がいた。当時は自作PCの方が安く済む余地がそれなりにあったのだ。

PC自作までたどり着かなくても、メーカー製PCにたいして、例えばメモリが足りないとか、内蔵のグラフィックチップではCRTディスプレイの最大解像度にてフルカラーを選択できないとか、そういう問題を解消するためにサードパーティのPCパーツを購入して取り付ける人もそれなりにいた。PC自体の基本性能がまだまだ低かった時代で、メーカー製のPCにてその手の不満が起きることもあったのだ。

で、メーカー製PCをカスタマイズしたり自作PCに手を出したりしていた人の一部が沼に落ちる――という流れで「PCそのものが目的化している人たち」への人口流入が発生していた*1

  1. PCはあくまで手段である、という人たち
    1. 道具にこだわる系の一部 → PCを自作 → 一部は(2)に鞍替え
    2. それ以外 → PCを購入
  2. PCそのものが目的化している人たち
    • (1)からの人口流入が発生

付け加えると、2000年代前半ぐらいまでは「汎用性のあるコンピュータを持ち運ぶ」ということは常識ではなかった。ノートPCはまだまだ高価でかつ非力だったので*2、デスクトップPCやタワーPC*3が主流だった上に、まだまだブラウン管が使われたCRTディスプレイも多かった*4PDAの類はコモディティ化しておらず、どちらかといえばマニアのオモチャに近かった*5。そしてスマートフォンはまだ存在しなかった*6

PCは設置して使うもので、まだまだ筐体の大きなデスクトップPC/タワーPCが多かった。「物理的にも拡張性が高いPC」が占める割合が高かったこともあり、サードパーティから様々なPCパーツが出回っていた。

そんな環境で、PC単体が高価だったこともあり、「PCそのものが目的化している人たち」の多くは「PCパーツ」を軸としてPCを弄ることが多かった。それはPC自作だけでなく、メーカー製PCのカスタマイズ*7も含む世界だった。

  1. PCはあくまで手段である、という人たち
    1. 道具にこだわる系の一部 → PCを自作 → 一部は(2)に鞍替え
    2. それ以外 → PCを購入
  2. PCそのものが目的化している人たち
    • (1)からの人口流入が発生
    • PCパーツが軸 → メーカー製PCのカスタマイズ and PC自作

さて、2000年代半ばになると、PCがコモディティしたこともあり、価格は随分と安くなった。またPCの基本性能の向上により、普通にメーカー製の出来合いのPCをそのまま使っても、日常的な作業に支障がでることは減ってきた。

こうなると「PCはあくまで手段である、という人たち」にとってPCを自作するメリットは薄れる訳で、彼らは普通にメーカー製のPCを買うようになった。スリムタワーPCやコンパクトPCの割合が増えて「PCパーツによるカスタマイズ」の物理的な難易度が高くなったこともあり、カスタマイズのためのPCパーツを買う人も少なくなった。

コンシューマ向けの「PCパーツ」は以前ほど売れなくなった。市場の縮小によるメーカーの淘汰や、「売れるPCパーツへの『選択と集中』」によってマニアックなPCパーツが出回らなくなる、などの変化が発生した要因の1つではないかと妄想している。

PCに手を加えたり自作したりする機会が激減したことで、「PCそのものが目的化している人たち」への人口流入も減少したように思う。

  1. PCはあくまで手段である、という人たち
    1. 道具にこだわる系の一部 → PCを購入(※ただしスペック等は吟味する)
    2. それ以外 → PCを購入
  2. PCそのものが目的化している人たち
    • (1)からの人口流入が減少

加えてノートPCの性能向上と低価格化が進んだことや、後年のスマホタブレット端末の普及もあり、汎用性のあるコンピュータを持ち運ぶことが一般的になった。この影響でPCは「拡張の余地がある未完成品」ではなく「すでに完成したもの」である、という認識がより一般化したように思う(持ち運び易さや、場合によっては「防水・防塵」を考えると、完成品である方が都合がよい)。実際、当時のノートPCで弄りやすかったのは「メモリモジュールの交換」ぐらいで、拡張ボードが挿せないために「メモリモジュールの交換」の次は「内蔵ディスクの交換」と一気に難易度が高くなった*8

「PCは完成品である」という認識が普及したこともあり、「PCそのものが目的化している人たち」の中にも「PCパーツ」ではなく「PC単品」を愛でる人が出てくるなど、ある種の多様性が生じたように思う。いるでしょう、ノートPCや安鯖に特化した人が。

現在は「PCそのものが目的化している人たち≒PC自作スキー」が成立しない時代だ。

外界の影響もある。2000年代後半には「『完成品』としてのPC」の部分的な代替となるスマホタブレット端末が登場した。同じころに「『拡張の余地がある未完成品』としてのPC」の代替にもなりうるシングルボードコンピュータBeagleBoardが登場したし、2010年代半ばにはRaspberry Piが発売されてヒットした。「PCそのものが目的化している人たち」の中には「実はコンピュータそのものが目的化している」というケースもあり、そういう人たちはPCという形態にはこだわらない(かつては他に選択肢がなかっただけだ)。

あとPC-UNIXとかそっち寄りの場合、その昔はOSのインストールで苦労しないように「厳選されたパーツ」でPC自作していたパターンもあるのだが、今となってはVirtualBoxのような仮想環境もあれば、クラウドLinuxインスタンスもある訳で、そっち方面に解脱しているケースもある。*9

そんなこんなで、時の流れによってPC自作以外に軸足を移したロートルはそれなりにいると思う。

  1. PCはあくまで手段である、という人たち
    1. 道具にこだわる系の一部 → PCを購入(※ただしスペック等は吟味する)
    2. それ以外 → PCを購入
  2. PCそのものが目的化している人たち
    • (1)からの人口流入が減少
    • 「PC自作派」以外の派閥が形成されている
    • 実は「コンピュータそのものが目的化」 → SBCなど「PC以外の選択肢」がある

現状は、PC自作への人口流入が少なく、PC自作以外への人口流出が起きている状態だろう。人口流入と人口流出のどちらが多いのか、明確なエビデンスを持ち合わせていないのだが、個人的には「新規参入が少ない中、環境の変化によってロートルがPC自作を止めていっている」という印象がある。

これは根拠のない妄言だが、1990年代後半から2000年前後は「Windows 95/98の登場によるPCの爆発的普及」と「非力なメーカー製PCをサードパーティのPCパーツでカスタマイズしやすかった時期」と「PC自作で『安価で高性能なPC』を構築できた時期」が偶然にも重なった、いわば「PC自作人口のベビーブーム」といえる時代だったように思う。

現在40歳代前半の人たちは、当時は10代後半から20代前後という「流行の影響を受けやすく、かつ年齢的にバイト等で収入を得ることが不可能ではなかった」年齢だった。つまり「PC自作にハマる」ための下準備が整いやすい環境下にいたといえる。

あと、団塊ジュニアほどではないものの、それなりに出生人口が多い世代でもある。なので、仮に「人口あたりの『若い時にPC自作にハマる人』の割合」が全世代でほぼ同じだったとすると、今の若年世代と比較すれば「パイ全体が大きい」訳で、要するに40歳代前半は「PC自作人口ピラミッドボリュームゾーン」だった可能性がある。

この年代は、私を含めて氷河期世代な訳で、結婚している人は家庭の事情(空間的にも金銭的にも)で、未婚の場合でも経済的事情などで、PC自作から撤退する契機が多々ある。そして経済的事情と晩婚化の組み合わせは、昨今の「中高年のバイクブーム」の類似としての「PC自作への復帰」が後年に生じる可能性を氷河期世代から奪っているように思う。

  1. PCはあくまで手段である、という人たち
    1. 道具にこだわる系の一部 → PCを購入(※ただしスペック等は吟味する)
    2. それ以外 → PCを購入
  2. PCそのものが目的化している人たち
    • (1)からの人口流入が減少
    • 「PC自作派」以外の派閥が形成されている
    • 実は「コンピュータそのものが目的化」 → SBCなど「PC以外の選択肢」がある
    • 家庭の事情による「ボリュームゾーン」のロートルの撤退?

ところで、ここ数年のPC自作といえばゲーミングPCだが、これまでの分類を当てはめるならば、ゲーミングPCは「PCはあくまで手段である、という人たち」の世界の産物だと思う。今は諸事情により自作している人もいる状況だが、この傾向がどこまで続くのか、どこかの時点で「メーカー製のゲーミングPCで十分だよね」という転換点を迎えるのか、少し興味がある。

PCパーツを買う人が減れば、コンシューマ向けの流通が減り、パーツ購入が難しくなり、結果として私がPCパーツを買うのに苦労することになる――という個人的なワガママより、PC自作の今後の動向を暇な時に注視していきたい。

……さてと、いい加減そろそろ2年前にパーツを揃えた「積み自作PC」を消化しないとなあ。それと、勢いだけで買って持て余しているJ5005-ITXをどうするかも決めないと。

*1:当然ながら、それ以外の経路で「PCそのものが目的化」した人たちもいたことに留意すること。

*2:ノートPCの普及については、2003年にIntelPentium Mを発売して、それを搭載したノートPCが出回ったあたりで、潮目が変わったように思う。2006年発売のモバイル向けCore 2 Duoによって、CPUに起因する性能面の不満は(普段使いの範囲では)ほぼ解消された感じだった。

*3:もっともタワーPCについては、少なくとも1990年代末には「フルタワーはデカすぎる、せめてミドルタワーじゃないと」という認識だったように思う。なおここ1~2年ぐらいのゲーミングPC向けタワー型ケースを1990年代末のミドルタワー型のケースと比較すると、高さは概ね同じくらいだが、幅と奥行きは今のケースの方が大きくて、そのおかげでメンテナンス性が向上している。

*4:メーカー製PCに液晶ディスプレイが付属することが一般化したのは2000年を過ぎたぐらいの時期だったと記憶している。

*5:……まあ、持っていたんだけどね。SONYCLIE。仕事でも使ってた。

*6:なおガラケーも中身はコンピュータであるが、アレはどちらかといえばワープロのような専用機で「汎用性のあるコンピュータ」という感覚はなかった

*7:メモリを増設したり、拡張ボードを挿したり、オーバードライブプロセッサに手を出したり……。

*8:主に「OSを含むデータ移行」という面で……。

*9:あえてハードウェアの存在にこだわる場合も、Macという「BSD流のユーザランドを完備した、吊るしのコンピュータ」があるからなあ。

まだ生きているMIDI:「神田伯山のこれがわが社の黒歴史 (2) ヤマハ・半導体の落とし穴」の感想にかえて

NHK「これがわが社の黒歴史」第2回はヤマハの半導体。23日放送 - AV Watch

YMF715EやYMF744-B*1の元ユーザとして、興味深く視聴した。

放送では「半導体」と一括りにされていたが、個人的にはアレは「音源チップ」の話だと解釈した。ざっくりといえばMIDI音源として機能するチップである。もちろんヤマハはそれ以外の機能の集積回路も扱っていると思うのだが、放送内容の中核となっていたのはMIDI音源のチップだと感じた。

MIDI音源といえば、数年前に20代前半の技術者から「MIDIって過去の遺物ですよね?」みたいなことを言われた。よく考えれば、パソコンを中心に据えた歴史認識だと「MIDIは廃れた」ということになっても不思議ではないのかもしれない(DTMの分野をかじっていれば別だろうけど)。

私としては、今のMIDIは一周して原点回帰した状態だと思っている。番組の感想にかえて、この辺を文章にまとめておきたい。

MIDIの原点は、シンセサイザーにおける「鍵盤ユニット」と「音源ユニット」の分離にある。分離するにあたり、「鍵盤ユニット」と「音源ユニット」の間で通信が必要となるが、通信の物理仕様と論理仕様について「楽器メーカー間の共通規格」として定められたのがMIDI 1.0である。

物理仕様はMIDIケーブルやコネクタの形状、あと「31.25kbpsのシリアル通信*2」といったあたりだろうか?

論理仕様は、一般に「MIDIメッセージ」といわれているアレである。大半は1~3byteのメッセージで構成されている。また「メッセージを1byteずつ読み進めて処理していく」という暗黙の前提の下で、可変長のシステムエクスクルーシブや、データ量を削減するランニング・ステータスのような仕様も含まれている。MIDI 1.0の仕様がまとめられたのは1980年代初頭だが、当時の8bitマイコンで処理を行うことを考慮した仕様である――と風の噂で聞いたことがあるが、真偽は定かではない。

ところで、論理仕様としての「MIDIメッセージ」が登場したことにより「人間が鍵盤ユニットを操作しなくても、プログラムなどでMIDIメッセージ生成して音源ユニットに流し込めば、音が鳴る」という可能性が出てきた。後にSMF(スタンダードMIDIファイル)が登場したことや、GMによる音色配列の最低限の統一がなされたこともあり、1990年代にはパソコン向けの音楽ファイルの1つとしてMIDI(というかSMF)が挙げられることもあった。当時のパソコンのCPUは今よりも遥かに貧弱で、メモリ容量は少なくて、ハードディスクの容量も少なくてかつ高価だった訳で、実際の演奏を録音した大きなWAVファイルをメモリにロードしてCPUを使って再生する代わりに、コンパクトなSMFに演奏情報を格納しておいて「MIDI音源」というハードウェア*3で発音させてCPUはフリーハンドにしておく――という手法にはそれなりの合理性があったように思う。

さて、ここまでに挙げたMIDIの3つの側面について「物理仕様」「論理仕様」「音楽ファイル」とラベリングした上で、それぞれが現在どうなっているか見てみたい。

まず「音楽ファイル」としてのMIDIは、少なくとも一般向けとしては姿を消した。元々SMFを再生した時の演奏音は使用するMIDI音源に依存していた訳で、特定のMIDI音源に決め打ちでもしない限り、どうしても楽曲作成における制約が大きくなりがちだった。パソコンの性能向上によりPCM再生の負荷が低減していくと、必然的に「実際に録音した音」の波形を再生するWAV(そしてMP3に始まる圧縮された音声フォーマット)に置き換わっていった。PCゲームでは「MIDICD-DA → WAVファイル」みたいな変遷があったように思う。

次に「物理仕様」としてのMIDIはどうだろうか? プロ向けの機器には今でもMIDIケーブル用の端子が付いている。付いているものの、出番は少なくなった。少なくとも今では、パソコンやタブレットDTM機器の接続でMIDIケーブルが使われることは皆無だろう。有線ならUSBで、無線ならWi-FiBluetoothで接続することが多いはずだ。MIDIケーブルが使われるのは、既存のDTM機器同士の接続だろう。

最後に「論理仕様」としてのMIDIだが、こちらはまだまだ現役である。「物理仕様」はUSB・Wi-FiBluetoothに置き換わっても、その中を流れる論理的なデータはMIDIメッセージだ。というかUSB-MIDI*4・RTP-MIDI*5・BLE-MIDI*6といった「MIDIメッセージを送受信するためのプロトコル」が制定され、利用されている。

2021年の現在、パソコンとDAWで音楽制作を完結させることが可能となって久しい。パソコンとMIDIコントローラ*7をUSBで接続し、DAWで録音する時、MIDIコントローラからDAWに送信されるのはMIDIメッセージだ。MIDIノートオン/オフに応じて鳴る音は、しかし、かつてのMIDI音源ではなく、DAWのプリセット音や、DAWのその先にあるVSTプラグインが発する音だ。パソコンの性能向上(あと内蔵ディスクの容量拡大)により、専用のハードウェアを用いずとも「パソコン上でのソフトウェア処理」だけで実用レベルで発音させることが可能となった。つまり今では「パソコン+DAW」が「音源ユニット」として振る舞うのだが、MIDIコントローラのような「音源ユニットを外部から制御する装置」との間の通信では、今でもMIDIメッセージが使用されている。

最近では「鍵盤ユニット」側が「MIDIコントローラ」のような専用ハードウェアではないケースもある。ローランドやヤマハから楽器と連携するAndroid/iOSアプリが複数リリースされているが、そのうち何割かは、おそらく内部でMIDIメッセージを併用した通信を行っているはずだ。USB・Wi-FiBluetoothと、楽器との接続方法が複数あり、それぞれ独自のプロトコルを開発・実装するのは辛いから、透過的にMIDIメッセージを送受信できる既存のプロトコルを用いて「MIDIメッセージのやりとり」で完結させよう――という発想は的外れではないはずだ。

そんな訳で、MIDIの「物理仕様」の側面や、MIDI音源というハードウェアが絡んだ「音楽ファイル」の側面は廃れたものの、「論理仕様」であるMIDIメッセージはまだ生きている。とはいえ約40年前の代物で、現在では色々と厳しい――というのがMIDI 2.0の仕様策定に繋がっている。まあ、今のところすぐさま廃れてしまうような兆候はなさそうだ。

*1:どちらも音源チップ。MIDI音源としても機能した。YMF715EのMIDIFM音源の音だった。YMF744-BのMIDIはピアノの音がすごく良かった記憶がある。

*2:ちなみに「31.25kbps」は「1MHzの32分周=31.25kHz」からきているようだ。

*3:なお後に「ソフトウェアMIDI音源」という「専用ハードウェアじゃなくてCPU使ってMIDIの音を鳴らす」という手法も出てきた。ローランドのVSC-88やヤマハXG WDM SoftSynthesizerあたりが界隈ではよく知られていたと思う。

*4:ヤマハのUX-16のようなUSB-MIDI変換ケーブル(変換ボックス)ではなくて、USBを使ってMIDIメッセージを送受信するためのデバイスクラスのこと。USBオーディオクラスのサブクラスとして定義されている。

*5:TCP/IP上でMIDIメッセージを送受信するプロトコル

*6:Bluetooth LEを使用してMIDIメッセージを送受信するためのプロファイル。

*7:キーボードのような形状だが、音源を持たない(だから単体では音を鳴らせない)機器。

Re: オブジェクト指向はすでに粒度が時代にあっていない

この記事はオブジェクト指向はすでに粒度が時代にあっていない - きしだのHatenaの個人的補足である……本当は書くつもりはなかったのだけど、ちょっと気になったので。

あの記事の趣旨なのだが、乱暴に言うと:

OOA + OOD + OOP」の勝利の方程式でV10達成必至――そんなふうに考えていた時期が俺にもありました

――といった感じだと考えている。ちなみに上記の「俺」は特定の誰かを示している訳ではないので注意すること。特に、「俺」は id:nowokay を指していない。というか元記事のニュアンスは「最近の大規模な『業務システム』や『Webサービス』を『1から丸ごと構築する』という粒度のシステム開発では、その構図は成立しないよね」といった感じのことを言っているだけなので*1

オブジェクト指向」という言葉を使用しているためか、元記事について妙な反応が多い気がするのだが、一応、この辺は元記事に:

とりあえずオブジェクト指向の話をすると定義が人によって違いすぎるので、改めてここでの定義を書いておくと、基本的にはOMTの「データ構造と振る舞いが一体となったオブジェクトの集まりとしてソフトウェアを組織化すること」に従うのですが

――と書いてあって、要は「『システム≒オブジェクトの集合』という前提に基づいて『要件モデリング → 分析モデリング → 設計モデリング → プログラミング』と開発を進めていく」みたいな流れを想定しているのだと思う。

で、ここから半分雑談。

元記事について「結局は『OOA + OOD + OOP』は銀の弾丸ではなかっただけ。他のソフトウェア開発手法と同様に、状況次第で薬にも毒にもなる」と感想を述べるのは容易いのだけど、では薬と毒の境界はどの辺にあるのだろうか?

個人的に、目安の1つとして「そのシステムが『1つのコンピュータ上の1つのプロセス』ぐらいの粒度におさまるか?」という視点があるのではないか、とぼんやり考えている*2

開発するソフトウェアの性質に依存する話ではあるのだけど、例えば単一の「アプリケーション/デーモン/ちょっとリッチな組み込み機器のファームウェア」を開発するケースでは、開発するソフトウェアの中身を「オブジェクトの集合」として組織化する前提で「OOA + OOD + OOP」で開発するのは悪くない考えであるし、2021年の時点でもそれなりに通用すると思っている。もちろん、それが「常にベストな方法」だとは思っていないのだけど*3

ところでモダンな大規模システムは、システム全体を見渡すと「複数のマイクロサービスと複数のクライアント・アプリケーション」で構成されていたりするのだけど、これは「システム≒オブジェクトの集合」という発想との相性が悪いと思う。

なぜなら、プログラマの視点から見た時、例えば個々のマイクロサービスは「オブジェクト」として抽象化されていないから。だから「システム≒オブジェクトの集合」として組織化したいならば、個々のマイクロサービスを「オブジェクト」として抽象化して、他の有象無象のオブジェクトと同様に扱えるように作りこむ必要がある。だけどそれは一大事業になってしまうから、例えば「内部でHTTPS通信してWeb APIを叩く」みたいなお手軽class実装でお茶を濁してしまう。

その「お手軽class実装」をもって「マイクロサービスを『オブジェクト』として抽象化した」と言えるのか……? 多分、言えない。その「お手軽class実装」は「マイクロサービスAとの通信モジュールのオブジェクト指向プログラミングによる実装」でしかない。

これは、例えるなら「UnixPlan 9でのネットワーク・プログラミングの違い」の拡大解釈版みたいな話だ。Plan 9は「everything is a file」が徹底されているので、TCPクライアント/サーバは「ファイルのopen/read/write/close」として実装する。でもUnixではそこまで抽象化されていない。では、Unix上で動くTCPクライアント/サーバを実装する時に「Plan 9と同程度の『ファイルによる抽象化』の階層(※ただしTCP/IPネットワーク限定)」を構築してからクライアント/サーバを実装するか?」と問われれば、そんなことはなくてBSD Socketプログラミングしちゃうよね……という話だ。

TCP/IPネットワークが「ファイル」として抽象化されているのならば、「ファイル操作」という視点で「分析 → 設計 → プログラミング」することは適切だろう。でも、「ファイル」として抽象化されていないならば、「ファイル操作」という視点に基づいて開発するのは間違っているはずだ。

最近の大規模システムでは、もはやシステム全体について「システム≒オブジェクトの集合」と考えることは適切ではないので、「システム≒オブジェクトの集合」を前提とした「OOA + OOD + OOP」を「システム全体」に適用するのはミスマッチだという話になる。

(元記事とは違ってボトムアップな議論ではあるけど、元記事にもある「粒度があっていない」とは、こういった側面も指し示している気がする)

もちろん「システム全体」ではなくて「個々のサブシステム」――それはマイクロサービスの中身だったり、クライアントアプリだったり、何らかのソフトウェア・コンポーネントだったりするのだろうけど、「サブシステム」ぐらいの粒度においては「サブシステム≒オブジェクトの集合」と見なして「OOA + OOD + OOP」する余地があるだろう*4

そして、小規模システムならば、つくるモノ次第ではあるけど、今でも「システム全体」について「システム≒オブジェクトの集合」として「OOA + OOD + OOP」するアプローチがそれなりに通用する。

……うーん、アレか、うまく言えないけど「ネットワークを介した分散システム」か否か、みたいな点がキーなのだろうか? この辺の諸々って、実用的で汎用的なライブラリ/フレームワークで「大きな粒度」での「オブジェクトとして抽象化」がなされてないと思う。その上で、「全てをオブジェクトと見なせる抽象化層」を作りこむのは非常に大変だろう。だから「システム全体」を分析・設計する時には「そんな抽象化層は存在しない」という前提で話を進めることになる

(この辺が元記事にて「マイクロサービス」や「Web API」といった単語が出てくる遠因となっている気がする。「マイクロサービス」そのものは「オブジェクトとして抽象化」せず、マイクロサービスのまま扱って「Web API」を叩く――という前提でサーバサイドをサービス単位に切り分けてWeb APIを設計する、みたいな感じ? うまく表現できないのだけど……)

一方で、前述でいう「個々のサブシステム」みたいな粒度だと、出来合いのライブラリ/フレームワークによる抽象化がちょうど良い感じであったり、自前で抽象化層を造りこむコストがそこそこ低いことが多いから、「じゃあ『サブシステム≒オブジェクトの集合』として組織化する前提で、分析・設計・プログラミングしましょうか」という話がコスト面で許される余地がある、という感じだろうか。

蛇足:ところで「元記事は『最近の大規模システム』の粒度を念頭に語っている」というのは半分以上私の推測なのだが、元記事の最後の方に「オブジェクト指向開発のコンサル」への言及があるので、おそらく暗黙の了解として「コンサルを入れるだけの資金を確保できる大規模エンタープライズ開発」あたりを想定しているのだと思う。Enterprise JavaBeansといった単語も出てくるぐらいだから、的外れではないと思っている。

*1:――と私は読み取っている。

*2:あくまでも「目安の1つ」でしかない点に留意すること。

*3:つくるモノに依存する話なので。

*4:そして、もちろんそれが「常にベストな方法」という訳ではないのだろうけど。

Re: シェルスクリプトを書くのをやめる

この記事はシェルスクリプトを書くのをやめる - blog.8-p.infoの個人的感想である。

なお元の記事を書いた人と私は全くの赤の他人であり、何の接点もない。つまりこの記事は単なる外野のたわ言である旨をご承知いただきたく。

Re: シェルは悪いプログラミング言語である

シェルスクリプトの文法やら何やらが、最近のモダンなプログラミング言語と比較すれば色々とアレなのは確かだ。

しかし冷静に考えてほしいのだが、現在使われている/bin/shのもとになったBourne Shellは1977年生まれで、44年前の代物だ。それぐらい大昔の、まだ言語設計もシステム開発技法も未成熟だった時代に、プログラミング言語ではなく「プログラマブルな対話型コマンドラインインタプリタ」の一種として開発された代物だということは押さえておくべきだろう。

コンパイラ本になるが『コンパイラ―原理・技法・ツール〈1〉 (Information & Computing)』の原書が出版されたのは1986年だ。プログラミング技法に関しては、『ソフトウェア作法』でさえも原書が出版されたのは1976年だ)

言語設計が未成熟だった故に言語文法とか色々とアレだし、未成熟だったプログラミング技法のあおりで微妙な振る舞いをするようになってしまった部分がPOSIXやら何やらで「仕様」と化してしまった点もあるかもしれない。知らんけど。

そもそもUnixシェルはプログラミング言語の処理系ではないプログラミング言語ではないのだから、REPLとは言えない。プログラミング言語と「対話型コマンドラインインタプリタ」の間には「野球とクリケット」ぐらいの違いがあって、「プログラマブルな対話型コマンドラインインタプリタ」であるUnixシェルは野球に近づいているものの、依然として「野球とソフトボール」ぐらいの違いがある。だから、プログラミング言語の視点では微妙だと感じられる妥協点がある文法となっている。

プログラミング言語ではないものをプログラミング言語として認識してしまう我々プログラマの業が、思わぬ認識の齟齬に繋がっている気がしないでもない。

その上で、良いか悪いか判断に迷うところだが、40年前にBourne Shell向けに書かれたシェルスクリプトは(使用しているコマンドの有無や、コマンドの仕様の変化などで躓かなければ)今でも動作する。例えば原書が1984年に出版された『UNIXプログラミング環境 (海外ブックス)』の第3章~第5章が、2021年になってもシェルプログラミングの入門文書としてほぼ修正なしで通用するのである。

見方を変えれば、シェルスクリプトのコアな部分は40年以上前から何も変わっていない、ということだ。

適切な例えではないかもしれないが、ある意味でシェルプログラミングには「21世紀のプログラマから見た『FORTRAN 77でプログラミング』や『COBOL-80でプログラミング』の世界」みたいな世代差による断絶があるのではないかと思う。知らんけど。*1

Re: シェルはあまりパワフルではないので、結果として複数の言語を混ぜることになる

……まあ、シェルスクリプトはグルー言語の王様だからなあ。

CLIの対話型コマンドラインインタプリタでは、どうしてもコマンドが主役となる。Unixシェルがそれ以前のコマンドラインインタプリタよりも優れていた点の1つは、パイプのような「コマンドとコマンドを容易に組み合わせられる機能」が組み込まれたことだ。

シェルは、例えるなら現場監督にすぎない。職人たち(各種コマンド群)を連れてくる(インストールする)か、もしくは職人を養成(コマンドを自作)したうえで、彼らに作業を割り振って工事を進める(コマンドを組み合わせて処理をする)のが、もっとも効率のよいシェルの使い方である。

言い換えれば、シェル自身に職人の役割が求められるようになった時点で非効率なのだ。他の、もうちょっと本格的なプログラミング言語に切り替えた方がよい。

Unixシェル環境に慣れている」という前提の下での話となるが、個人的な体感としては、単機能のプログラムなら小さなシェルスクリプトで実装してしまうのが手っ取り早い。シェル上でのワンライナーも含めて、小さなシェルスクリプトで済む範囲であるならば、シェルは現場監督として大活躍する。しかし少し複雑なプログラムをシェルスクリプトで実装しようとすると、シェルに配列のような「現場監督以外の役割」が欲しくなってくる――その時点で他のプログラミング言語を検討するべきだろう。

Re: シェルが存在しない環境のことを考えると、書いたものも、そこまでポータブルではない

そもそもシェルが存在する環境であっても、シェルスクリプトは移植性の問題を抱えやすい。この点はEric S. Raymondも『The Art of UNIX Programming』で書いている。以下、同書のP.365より:

複雑なシェルスクリプトは、移植性問題を抱えていることが多い。それはシェル自身に問題があるというよりも、コンポーネントとして他のプログラムが存在することが前提となっていることに起因するものだ。Bourne ShellKorn Shellのクローンは、Unix以外のオペレーティングシステムにも散発的に移植されているが、シェルプログラムは(現実的にいって)Unix以外のオペレーティングシステムには移植性がない。

以上をまとめると、シェルの長所は小さなスクリプトを書くためには非常に自然で手っ取り早いことだ。欠点は、特に大きなシェルスクリプトでは、すべてのターゲットマシンで同じように動作するとは限らず、ない場合もあるようなコマンドに大きく依存してしまうことだ。大規模なシェルスクリプトでは、依存関係を分析するのも容易ではない。

個人的には、シェルスクリプトの移植性は「C言語の移植性」と同程度に疑わしいと思っている。教科書の類には「C言語は移植性が高い」と書かれているけど、現実として、移植性の高いC言語ソースコードを書くためにプログラマは色々と苦労している訳で――Unix環境間の移植性であっても、それなりの苦労をしないとシェルスクリプトの移植性は得られないと考えるべきだろう。

先生誰にも言わないから、Ubuntu上で使っていたシェルスクリプトmacOSでうまく動かなかった子は手を上げなさい――先生は動かなかったことがあります。

Re: じゃあ代わりに何を使うの?

これな、これなあ、物凄く悩ましいの。

正直なところ、周囲の開発者の同質性が高めなら、そこまで悩まなくてもよいと思うの。だってみんなが慣れている言語から検討を始めればよいし、仮にちょうど良い言語が無かったとしても、モダンでメジャーでそれっぽい言語を採用して周囲に広めるのにあまり苦労しないはずだから。

でも皆が割と違うことをやっていて、作業環境もバラバラだと、使用する言語を統一しようという試みは概ね失敗する。どの言語を選んでも角が立つものだ。

個人的には、最近は色々と割り切って「プラットフォームごとに違う言語を使う」というようにしている。Windows向けの小ツールはPowerShell 5.1で実装する*2macOSなら頑張って「標準のコマンドを使用する前提のシェルスクリプト」を書くか、思い切ってSwiftでスクリプトを書いてしまうこともある*3

――Linux向けのツール? macOS以外のUnixユーザランド環境をまともに触っているの、自分だけだから……好き勝手にやらせてもらってる。小さなツールならシェルスクリプトで書いてしまうことが多いし、ちょっと複雑なツールは「スクリプト処理系が存在するプログラミング言語」の中からその時々の気分で選んで実装する。でもバイナリデータの処理を書くときはC言語C++を併用しがちだ――生のバイナリデータを直接操作することに慣れてしまった弊害だよなぁ。

なお状況次第ではコンパイル型言語を採用することもある。こういう時は、大抵は「各々のマシンにスクリプト処理系をインストールしたくない」という要求があるので、その影響で必要なライブラリ類を全て静的リンクしたFatな実行ファイルを配布することが多い――要するに「各々のマシンに追加の共有ライブラリ類をインストールすることも避けたい」ということだ。

Re: ShellCheck 使えばいいのでは?

よくある「シェルスクリプトの代替」はPerl/Python/Rubyあたりだと思うのだが、動的型付けのスクリプト言語を本格的に使うときは「lintツールによる静的解析 + 単体テストツール」は必須だと思っている。コンパイル型言語におけるコンパイル・チェックという名の静的テストに相当するテストを、lintによる静的テストと単体テストによる動的テストで補完するのが目的だ。

シェルスクリプトを「動的型付けのスクリプト言語」に含めるのは何か違うと思うのだが、しかし、コンパイル・チェックがなくて実行時でないと洗い出せないエラーが多々あるという点では、シェルスクリプトPerl/Python/Rubyと同じだ。だからシェルスクリプトへの「lintによる静的テスト」は効果的だと考えている。

――なんだけど、シェルスクリプトを書くときにShellCheckを使うのは、何というか大袈裟すぎる気がするというか、むしろ「ShellCheckが必要な規模のシェルスクリプト」を書くこと自体がアカンのではないか、というもにょもにょしたものを感じてしまう。

あれだな、「シェルスクリプトはシンプルで単純なもの」という思い込みがあるのだ。

Re: Python とか Rubyシェルスクリプトみたいなことするの面倒くさくないですか?

ひんしゅくを買う発言だと思うけど、そもそもPythonRubyで「シェルスクリプトみたいなこと」をする必要があるのだろうか……?

実現したい内容によるのだろうけど、私ならまず最初に「PythonRubyでコマンドを書いて、シェルスクリプトでラッピングしてコマンド同士を組み合わせる」というアプローチを検討すると思う。

……あれ? 結局シェルスクリプト自体は書いているような……しかも追加の依存関係が発生したぞ?

ま、まあ、アレだ、シェルが得意とする処理はシェルスクリプトで行えばよいし、シェルが不得意な処理は他のプログラミング言語で行えばよい、という考え方だ。そのアプローチの1つとして、私は「他のプログラミング言語でコマンドを実装して、シェルスクリプトで連結する」というシェルスクリプト・ファーストな方法を採用することが多い。

その結果として、結局はシェルスクリプトを書くことになるのだが、シェルスクリプト自体は小さくシンプルなコードで済む。まあ、単なる分割統治ですな、複数の言語を使っているだけの。

Re: Python とか Ruby で書いたときに、依存パッケージとかどうしてますか?

標準ライブラリ縛りは割と鉄板ですな。

もう少し真面目に書くと、例えばチーム開発をしていて、みんなPythonRubyを使っていて、自分と周囲の開発者の開発環境の同質性が高い――とかならpipやgemを使う前提でも問題ないと思うの。類似案件として「macOSでHomebrew」みたいなケースもそう。

でも世の中そんな環境ばかりじゃない訳で、開発環境の同質性が低い環境にて「他人も使うツール」を書くとなると、スクリプト言語なら標準ライブラリ縛りにせざるを得ないことが多い。

例えばscipy必須のPythonスクリプトを社内に配布すると、絶対に何人かはscipyの導入やらpipenvの問題やら「そもそも吾輩は中身を理解しないまま手順書通りにvirtualenvを使っているでござる」やらでトラブルを報告してくる。実装に要するコスト次第だが、配布後のユーザ・サポートのコストを抑える目的で、あえてサードパーティのライブラリを使わないケースは少なくない。

別のアプローチとして、コンパイル型言語を使って、必要なライブラリ類を全て静的リンクした実行ファイルを配布することもある。

人月の神話【新装版】』の第1章「タールの沼」に曰く、単なるプログラムをプログラミング製品に磨き上げるには最低3倍のコストがかかる。自分が使うだけのツールと「他人も使うツール」とでも、やはり相応のコスト差があると考えた方がよいだろう。

おわりに:2021年にシェルスクリプトについて学ぶべきか否か?

そもそもUnixシェルが本質的に「Unixマシンを操作するための対話型コマンドラインインタプリタ」である以上、まず肝となるのは「Unixマシン上でシェルというCLIを多用するか否か」という点だろう。

というのも「Unixシェルでの操作に慣れて、パイプなどでコマンドを組み合わせることを覚えたユーザが、『コマンドの組み合わせ』を再利用するためにスクリプト化する」というのが、伝統的な「シェルスクリプトに手を出す契機」の1つだと思うからだ。

もしくは「Unixシェルでの操作に慣れて、既存のコマンドに不満を感じたユーザが、そのコマンドを拡張するためにスクリプトでラッピングする」というケースもあるだろう。というか『UNIXプログラミング環境』の第5章の導入部分がもろにそれである。

つまりUnixシェルの住人ならば、住民歴が長くなるにつれてシェルスクリプトに手を出すようになるのは自然であるし、その恩恵を享受しやすいのだ。でもUnixシェルの住人になる気が無いのならば、シェルスクリプトに手を出しても得られる恩恵は少ない。

だから、コンテナではなくUnixマシンそのもの(VirtualBox上の仮想マシンを含む)をCLIで管理したいとか、Unixシェル上の柔軟で効率的な「テキストファイル処理環境」で日常の作業を行いたいとか、そういう動機があるならば、2021年の今でもシェルスクリプトについて学ぶ意味はあるだろう(ただしUnixシェルにある程度親しんだ後に、だが)。

でも主眼が「ツールを自作する」というプログラミングにあるのならば、プログラミング環境としてUnixシェルを選択する意義は今でもあるかもしれないが*4、言語処理系としてUnixシェルを選択する意義は薄いだろう――もちろん作ろうとしているツールに課せられた制約次第だが。

*1:FORTRAN 77やCOBOL-80でのプログラミング経験が無いので、本当のところは分からない。

*2:PowerShell 5.1なら大抵のWindows 10マシンで動作する。

*3:swift(1)の引数にSwiftで書いたソースファイルを指定することで、ビルドしないで実行することができる。shebangにだって対応している。周囲のmacOSユーザはiOS/macOSアプリの開発者だけなので、大抵はXcodeがインストール済みであり、swift(1)を使用できる環境が整っている。

*4:とはいえGUIの便利なエディタを使ってしまうものである。