[PR] 本記事にはプロモーションが含まれています。

【入門編 第5回】TypeScript を C と比べる ― 構造体、enum、タグ付き共用体(Step 1)

TypeScript

はじめに

前回は、Vite を組み込みの言葉で読み解きました。今回は、Step 1 の単位換算ツールで実際に書いた TypeScript のコードを、C と並べながら読んでいきます。

JavaScript すら書いたことがない状態から始めましたが、計算の部分に限って言えば、C と大きくは変わらないと感じています。一方で、書き方の見た目が違うものや、C にはない便利な仕組みもありました。

この回で取り上げるのは、次の4つです。

  • 構造体、enum、const のテーブルにあたる書き方
  • 配列の検索と、関数を渡す書き方
  • import と export(C のヘッダにあたるもの)
  • タグ付き共用体を使った、入力チェックの結果の返し方

その前に、TypeScript を C のコンパイラのように厳しく働かせる設定から紹介します。

型チェックを厳しくする

前回紹介したとおり、Vite は TypeScript を JavaScript に変換してくれますが、型の誤りは検査しません。変換を速くするために、型の情報を読み飛ばしているからです。

そこで、型の検査だけを TypeScript のコンパイラ(tsc)に任せることにしました。C でいえば、コンパイラの警告を -Wall -Werror で厳しくしておくのに近い役割です。

設定ファイル

ブラウザ用のコードを検査する設定は、次のとおりです。

JSON
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022", "DOM"],
    "strict": true,
    "noEmit": true
  },
  "include": ["src/main.ts", "src/conv.ts"]
}
設定意味
lib: ["DOM"]ブラウザの画面の部品(document など)を使うと宣言する
strict: true厳しく検査する。警告をすべて有効にするイメージ
noEmit: true検査だけ行い、ファイルは出力しない(出力は Vite の担当)
include検査の対象。ブラウザ用の2つのファイルだけ

検査用のコマンドを登録して、実行します。

Bash
$ npm pkg set scripts.check="tsc -p tsconfig.json"
$ npm run check

何も表示されずに終われば合格です。C のコンパイルと同じく、何も言われないのが一番よい結果です。

わざと間違えてみる

型チェックのありがたみは、わざと間違えるとよくわかります。後で紹介する換算の関数では、換算の向きを 'toSi' か 'fromSi' のどちらかで指定します。これを、大文字の I にした 'toSI' と書き間違えて npm run check を実行すると、次のようなエラーになります(行番号などは例です)。

Plaintext
src/main.ts:8:31 - error TS2345: Argument of type '"toSI"' is not assignable to parameter of type 'Direction'.

「'toSI' は Direction という型には入れられない」という意味です。JavaScript では、この誤字に気づかずに実行され、条件分岐の結果として黙って逆向きの計算をしてしまいます。TypeScript なら、実行する前に見つけられます。

構造体・enum・const テーブル:conv.ts を C と並べる

換算の計算は、conv.ts というファイルにまとめました。第3回で紹介した設計書の換算テーブルを、そのままコードにしたものです。

TypeScript
// 検査項目の id と換算の向き(C の enum に相当)
export type ItemId = 'glucose' | 'tchol' | 'tg' | 'cre';
export type Direction = 'toSi' | 'fromSi';

// 検査項目1件分の情報(C の struct に相当)
export interface Item {
  id: ItemId;
  name: string;      // 表示名
  convUnit: string;  // 従来単位
  siUnit: string;    // SI 単位
  factor: number;    // SI = mg/dL × factor
}

// 換算テーブル(C の const 構造体配列に相当)
export const ITEMS: readonly Item[] = [
  { id: 'glucose', name: '血糖',             convUnit: 'mg/dL', siUnit: 'mmol/L', factor: 1 / 18.016 },
  { id: 'tchol',   name: '総コレステロール', convUnit: 'mg/dL', siUnit: 'mmol/L', factor: 1 / 38.67 },
  { id: 'tg',      name: '中性脂肪',         convUnit: 'mg/dL', siUnit: 'mmol/L', factor: 1 / 88.57 },
  { id: 'cre',     name: 'クレアチニン',     convUnit: 'mg/dL', siUnit: 'µmol/L', factor: 88.4 },
];

同じものを C で書くと、おおよそ次のようになります。

C
typedef enum { GLUCOSE, TCHOL, TG, CRE } ItemId;
typedef enum { TO_SI, FROM_SI } Direction;

typedef struct {
    ItemId      id;
    const char *name;
    const char *convUnit;
    const char *siUnit;
    double      factor;
} Item;

static const Item ITEMS[] = {
    { GLUCOSE, "血糖",             "mg/dL", "mmol/L", 1.0 / 18.016 },
    { TCHOL,   "総コレステロール", "mg/dL", "mmol/L", 1.0 / 38.67 },
    { TG,      "中性脂肪",         "mg/dL", "mmol/L", 1.0 / 88.57 },
    { CRE,     "クレアチニン",     "mg/dL", "µmol/L", 88.4 },
};

並べてみると、形はほとんど同じです。対応は次のとおりです。

TypeScriptC で近いもの違い
type ItemId = 'glucose' | …enum値が数字ではなく文字列。決めた値以外を入れるとコンパイルエラー
interface Item { … }struct の型定義メンバの並び順やメモリ配置は気にしなくてよい
readonly Item[]const 配列書き換えようとするとコンパイルエラー
numberdouble整数と小数の区別がなく、数値はすべて同じ型

TypeScript の enum にあたるものが、文字列の並び('glucose' | 'tchol' | …)になっているのが面白いところです。この書き方をユニオン型と呼び、| は「どれか1つ」を表します。値がそのまま意味のある文字列なので、画面の選択肢の値(<option value="glucose">)とも、そのまま対応づけられます。

計算の関数

換算の関数は、C とほぼ同じ見た目です。

TypeScript
export function convert(value: number, item: Item, dir: Direction): number {
  return dir === 'toSi' ? value * item.factor : value / item.factor;
}

export function formatResult(v: number): string {
  return v.toFixed(2);   // 小数2桁の文字列にする(printf の %.2f)
}

違うのは、型を名前の後ろに : number のように書く点です。C の double convert(double value, …) と、前後が入れ替わっています。また、=== は「型も値も等しいか」を調べる比較で、C の == に相当します。JavaScript の == は '1' == 1 が true になるような暗黙の型変換をしてしまうので、通常は === を使います。

計算部分だけで単体確認

この conv.ts は画面のことを一切知らないので、画面を作る前に、計算だけを確かめられます。100 mg/dL を各項目で換算し、ブラウザの開発者ツール(F12)のコンソールに表示させました。組み込みでいう、UART への printf デバッグです。

Plaintext
血糖: 100 mg/dL = 5.55 mmol/L
総コレステロール: 100 mg/dL = 2.59 mmol/L
中性脂肪: 100 mg/dL = 1.13 mmol/L
クレアチニン: 100 mg/dL = 8840.00 µmol/L

4つとも手計算と一致しました。演算ライブラリを、ドライバより先に単体でテストするのと同じ進め方です。

配列の検索と、関数を渡す書き方

画面で選ばれた検査項目(たとえば 'tg')から、テーブルの該当する行を探す関数です。

TypeScript
export function findItem(id: string): Item | undefined {
  return ITEMS.find((it) => it.id === id);
}

最初に見たとき、ITEMS.find((it) => it.id === id) の1行が何をしているのか、まったく読めませんでした。分解すると、次の3つの部品でできています。

部分意味
ITEMS探す対象の配列
.find(…)配列に最初から付いている検索関数。先頭から順に③を呼び、true になった最初の要素を返す。最後まで見つからなければ undefined を返す
(it) => it.id === id判定用の小さな関数(アロー関数)。it に要素が1つずつ渡され、id が一致するかを true / false で返す

C で書くと

素直にループで書けば、次のとおりです(画面から文字列で届く id と比べるため、ここでは id を文字列で持つ構造体として書いています)。find は、このループを中に持っている関数です。

C
const Item *findItem(const char *id) {
    for (size_t i = 0; i < ITEMS_COUNT; i++) {
        if (strcmp(ITEMS[i].id, id) == 0) {
            return &ITEMS[i];   /* 見つかった */
        }
    }
    return NULL;                /* 見つからない */
}

find と同じく「判定の関数を外から渡す」形にすると、標準ライブラリの qsort や bsearch に比較関数を渡すのと同じ形になります。

C
typedef bool (*MatchFunc)(const Item *it, const void *ctx);

const Item *find(const Item *arr, size_t n, MatchFunc match, const void *ctx) {
    for (size_t i = 0; i < n; i++) {
        if (match(&arr[i], ctx)) return &arr[i];
    }
    return NULL;
}

static bool matchId(const Item *it, const void *ctx) {
    return strcmp(it->id, (const char *)ctx) == 0;
}

const Item *p = find(ITEMS, ITEMS_COUNT, matchId, id);   /* 呼び出し */

C との大きな違い:外側の変数がそのまま見える

アロー関数の中の id は、アロー関数の引数ではありません。外側にある findItem(id: string) の引数です。

C では、判定関数から外側の変数が見えないので、void *ctx 経由で渡す必要がありました。TypeScript の関数は、自分が書かれた場所から見える変数を、そのまま使えます。これをクロージャと呼びます。void *ctx を自動で用意してくれる仕組み、と考えると、すんなり理解できました。

undefined は NULL

戻り値の型 Item | undefined は、「Item か、見つからなかったことを表す undefined のどちらか」という意味です。C の「構造体へのポインタ、または NULL」と同じです。

C との違いは、確かめずに使おうとすると、型チェックがエラーにしてくれることです。

TypeScript
const item = findItem(itemSel.value);
item.factor;           // エラー:undefined かもしれないので使えない

if (!item) return;     // 見つからなければここで終わる
item.factor;           // OK:ここでは必ず Item

ヌルポインタの参照を、実行する前に防いでくれるわけです。

import と export:ヘッダにあたるもの

画面の処理を書く main.ts の先頭には、次の1行があります。

TypeScript
import { findItem, convert, formatResult, type Direction } from './conv';

「conv.ts が公開している部品のうち、この4つを main.ts で使います」という宣言です。

部分意味
importよそのファイルから部品を持ってくる
{ … }持ってくる部品の名前の一覧
from './conv'持ってくる元のファイル。./ は同じフォルダ、拡張子 .ts は省略できる

C のヘッダとの対応

C では、公開する関数や型をヘッダファイルに書き、#include して使いますね。TypeScript には、ヘッダファイルがありません。代わりに、ファイルの中で export を付けたものが、ヘッダに書いた公開部分になります。

CTypeScript
ヘッダに宣言を書く(extern)ファイルの中で export を付ける
static を付けて外から隠すexport を付けない
#include "conv.h"import { … } from './conv'
リンカが実体をつなぐVite がビルドのときにつなぐ

C の #include は、ヘッダの中身を丸ごと貼り付けます。TypeScript の import では、使うものを { } の中で名前を指定して持ってきます。名前を書き間違えたり、export していないものを書いたりすると、型チェックがエラーにしてくれます。C でいう「未定義の参照」のリンクエラーを、実行前に見つけてくれるイメージです。

type の付いたものは、実行時には消える

type Direction の type は、「これは型としてだけ使う」という目印です。import で取り込めるのは関数だけではなく、export されていれば定数も型も取り込めますが、目印が必要なのは型のときだけです。

取り込むもの例目印ビルド後
関数findItem、convertなし残る
定数ITEMSなし残る
型Direction、Itemtype消える

型は TypeScript の検査のためだけに存在し、JavaScript に変換されると跡形もなく消えます。C の typedef や struct の型定義が、コンパイル後のバイナリに何も残らないのと同じです。

TypeScript を C と比べる ― 構造体、enum、タ:type の付いたものは、実行時には消える

実は、今の設定では type を付けなくても動きます。Vite が型だと判断して取り除いてくれるからです。それでも付けるようにしているのは、読む人に「これは実行時には何もない」と伝えるためです。volatile のように、書かなくても動くことはあっても、意図を明示しておくと事故を防げる、という類のものだと考えています。

タグ付き共用体で、入力チェックの結果を返す

入力欄に -5 や abc が入ったときに、結果の代わりにメッセージを出す入力チェック(要件 F-05)を作りました。ここで、C のタグ付き共用体とまったく同じ考え方の書き方に出会いました。

成功なら数値、失敗ならメッセージ

チェックの結果を返す型を、次のように定義しました。

TypeScript
type ParseResult =
  | { ok: true; value: number }    // 成功の形
  | { ok: false; error: string };  // 失敗の形

「ok が true なら value を持ち、false なら error を持つ」という型です。ポイントは、ok の型が boolean ではなく true や false そのものになっていることです。TypeScript では、値そのものを型として使えます(リテラル型)。ok は、どちらの形かを見分けるためのタグとして働きます。

C で書くと、次のようになります。

C
typedef struct {
    bool ok;                 /* タグ */
    union {
        double value;        /* ok == true のときだけ有効 */
        const char *error;   /* ok == false のときだけ有効 */
    } u;
} ParseResult;

組み込みでよく使う「戻り値でエラーコード、出力引数で値」という形(int parse(const char *s, double *out))を、1つの戻り値にまとめたもの、とも言えます。

関数の中身

チェックは、上から順に調べて、当てはまったところで結果を返します。

TypeScript
function parseInput(text: string): ParseResult {
  if (text === '') {
    return { ok: false, error: '値を入力してください' };
  }
  if (/^-\d+(\.\d+)?$/.test(text)) {
    return { ok: false, error: '0 以上の値を入力してください' };
  }
  if (!/^\d+(\.\d+)?$/.test(text)) {
    return { ok: false, error: '半角の数字で入力してください(例: 5.5)' };
  }
  return { ok: true, value: Number(text) };
}

{ ok: false, error: '…' } は、その場で構造体の値を作る書き方で、C の複合リテラル (ParseResult){ .ok = false, .u.error = "…" } にあたります。

/^\d+(\.\d+)?$/ は正規表現と呼ばれる、文字列の形を調べるための記法です。

記号意味
^ と $文字列の先頭と末尾
\d+数字(0〜9)が1文字以上
(\.\d+)?「小数点と数字1文字以上」が、あってもなくてもよい

つまり「先頭から末尾まで、数字 または 数字.数字 の形だけ」です。受信したバイト列を、状態遷移で1文字ずつ照合するパーサーを1行で書けるようにしたもの、と考えると近いと思います。こうしておくと、100(全角)、1e3(指数表記)、0x10(16進数)のような、意図しない入力をまとめて弾けます。

呼び出す側:タグを確かめないと中身に触れない

入力欄の文字列は、前後の空白を trim() で削ってから渡します。コピー&ペーストで紛れ込んだ空白で、エラーにならないようにするためです。

TypeScript
const r = parseInput(valueInput.value.trim());
if (!r.ok) {
  result.textContent = '';
  message.textContent = r.error;   // 失敗の形だと確定しているので使える
  return;
}
message.textContent = '';
result.textContent = `${formatResult(convert(r.value, item, dir))} ${toUnit}`;   // 成功の形だと確定
TypeScript を C と比べる ― 構造体、enum、タ:呼び出す側:タグを確かめないと中身に触れない

C の共用体では、タグを確かめずに r.u.value を読んでも、コンパイラは何も言いません。TypeScript では、if (!r.ok) { … return; } を通過するまで r.value に触れさせてくれません。通過した後は、「この先の r は成功の形だ」と判断してくれます。これを型の絞り込みと呼びます。

タグの確認漏れによる「失敗時のゴミ値を使ってしまう」バグを、実行前に防げるわけです。

まとめと次回予告

この回で出てきた TypeScript の書き方を、C と対応づけてまとめます。

TypeScriptC でいうと
interface / typestruct / typedef
ユニオン型・リテラル型('toSi' | 'fromSi')enum
readonly 配列const 配列
Item | undefined構造体へのポインタ、または NULL
{ ok: true; value } | { ok: false; error }タグ付き共用体
export / importヘッダの公開宣言(extern)と #include
アロー関数とクロージャ関数ポインタ+コンテキスト(void *ctx)
strict: true の型チェック-Wall -Werror

計算部分だけを見れば、C と大きく変わらないというのが正直な感想です。違いを感じたのは、TypeScript の型チェックが、C のコンパイラよりも一歩踏み込んで見張ってくれる点でした。NULL の確認漏れ、タグの確認漏れ、enum にない値の代入といった、C では実行してみるまで気づきにくい誤りを、実行前に指摘してくれます。

次回は、この関数たちを画面の操作とつなぐ部分を取り上げます。入力欄に1文字打つたびに計算が走る仕組みは、組み込みの割り込みとそっくりでした。

この記事は、シリーズ「組み込みエンジニアの Web 開発入門」の1本です。ほかの回は、シリーズの目次からどうぞ。

ほかの開発・プログラミングの記事は、開発・プログラミングメモの目次から、テーマ別に探せます。

このブログは、にほんブログ村と人気ブログランキングに参加しています。記事が役に立ったら、下のランキングのバナーのどれかを押して応援してもらえると励みになります。

ブログランキング・にほんブログ村へにほんブログ村

サーバー構築・運用ランキング プログラムランキング

TypeScript
watasoftdevをフォローする

コメント