4
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FlutterAdvent Calendar 2023

Day 14

DartとJavaScriptでPythonやRubyみたいに%書式(sprintf)を使えるようにしましょう

Last updated at Posted at 2024-04-09

はじめに

PythonとRubyを使っているみんなさん、いつも「%書式」を使っていますか?私はいつも使っています。これは元々C言語の機能で、printfとsprintfで文字列を出力する時に使われていたが、その後色んな言語で同じように搭載されています。

PHPやMATLABなどでは「sprintf」という関数の形で使えますが、文法の一部として扱えるPythonとRubyの方が直感的で便利です。

勿論、PythonとRubyは%書式以外に他の書式の機能がありますね。前にもPythonで使える書式を比較する記事を書きました。それぞれ特徴があって違いがあります。色んな言語共通で使えるのは%書式の長所です。

ただし残念ながらJavaScriptはこんな便利な機能を持っていません。関数という使いたいとしても追加パッケージをインストールする必要があります。

最初にJavaScriptを勉強した時に不便を感じて、ないなら自分で作ればいいよね、と思って結局頑張って実装してみました。

その後最近Dartも勉強し始めましたが、やはりDartも%書式が使えなくて残念です。

ただDartはクラスの演算子に対する使用を書き換えることができるから、それはつまりPythonとRubyみたいに%という演算子で文字列を変換することができます。

ということでDartに%書式の機能を追加するコードを書いてみました。ここではそのコードを載せて解説します。

ついでに以前使っていたJavaScriptでの実装コードも載せます。この2つの言語はとても似ているから、JavaScriptからDartに書き換えることは難しくないです。見て比べたら大体似ているとわかります。

なお、できるだけ本来の%書式を再現できるように頑張ってみましたが、それでも完全に同じにできるとは言えません。ただ普段よく使っている機能は揃えているつもりです。

そもそもPythonとRubyの間でも相違点があります。例えば%bはなぜかPythonで使えません。それに対しRubyは%Fが使えません。例外が発生する時の作動も違います。

まだ足りないところや改善できるところがあれば教えていただけたら幸いです。

使い方

文字 意味
%b 2進の整数。ただしPythonで使えない
%B %bと同じだが、大文字で書く
%c 一文字
%d
%i
%u
整数
%e 指数の形の小数
%E %eと同じだが、大文字で書く
%f 浮動小数点数
%F %fと同じ。ただしRubyで使えない
%g 数字の桁によって適切な形で表記する
%G %gと同じだが、大文字で書く
%o 8進の整数
%s 普通の文字列
%x 16進の整数
%X %xと同じだが、大文字で書く

Rubyでの動き

まずは作りたいものの模範としてRubyでの作動を試しす例を挙げます。

入力 出力
"%#7b"%5
"%#06B"%3
"%c"%12366
"%08d"%13
"%011.2e"%0.0012
"%12.3E"%43000
"%6.1f"%55
"%g"%1000000
"%G"%0.000012
"%07o"%333
"%4s"%"梨"
"%#09x"%11111
"%#8X"%2222
"  0b101"
"0B0011"
"ぎ"
"00000013"
"0001.20e-03"
"   4.300E+04"
"  55.0"
"1e+06"
"1.2E-05"
"0000515"
"   梨"
"0x0002b67"
"   0X8AE"

こんな感じで、Rubyを使う時にこんな文字の扱いができてとても便利です。

Dartでの実装

Dartではextensionを使って既存のクラスのオブジェクトに新しいメソッドやプロパティを追加することができます。そして演算子の扱いも定義できるので、ここではRubyと同じように「%」という演算子で文字列変換の機能を与えます。

後で呼び出して使うためにこのようなファィルを書きます。

extension Sprintf on String {
  // 文字列のオブジェクトに「%」という演算子に書き換える
  String operator %(a) {
    // 入力がリストでなければリストにしておく
    if (a is! List) {
      a = [a];
    }
    String e;
    int i = -1;
    return this.replaceAllMapped(
      RegExp(r'(%+)(#+)?(\+)?(0)?(\d+|\*)?(\.(\d+))?([bBcdeEfFgGiosuxX])'),
      (Match m) {
        /* 各変数の説明
        m[1]: 前にある「%」群
        m[2]: (もしある)前に置いた「#」 (b, o, x, Xに適用)
        m[3]: (もしある)「+」
        m[4]: 「0」があれば「0」で、もしなければ空白「 」で埋める
        m[5]: 最短の文字列の長さを示す数字
        m[6]: 小数に関する点「.」と数字
        m[7]: m[6]の中の小数の桁を示す数字
        m[8]: 書式を決める文字
        */

        // 前に置いてある「%」の処理
        if (m[1]!.length % 2 == 0) {
          // 「%」の数が偶数であれば半分にして、それだけで終わり
          return m[0]!.replaceAll('%%', '%');
        }
        // 「%」の数が奇数である場合は代入を行う
        i++;

        // まず(もしあれば)「%」で始まる。ただし半分だけ
        String s = m[1]!.substring(0, (m[1]!.length / 2).toInt());
        String k = '';
        // 「+」が入った場合、もしプラスの数字なら「+」を入れる
        if (m[3] != null && !'cs'.contains(m[8]!) && a[i] > 0) {
          k = '+';
        }

        // 「0」か空白「 」を入れる数
        int p = 0;
        // もし「*」が書かれたら前にある数字を使って、代わりに次へ進む
        if (m[5] == '*') {
          p = a[i];
          i++; // 次へ進む
        } // 数字で指定されたらその数字を使う
        else if (m[5] != null) {
          p = int.parse(m[5]!);
        }

        // 指定された書式によって変換を行う
        // 2進数
        if ('bB'.contains(m[8]!)) {
          // ถ้ามี #
          if (m[2] != null) {
            if (m[8] == 'B')
              k += '0B';
            else
              k += '0b';
          }
          k += a[i].toRadixString(2);
        } // 文字のコード
        else if (m[8] == 'c') {
          k += String.fromCharCode((a[i]));
        } // 整数
        else if ('diu'.contains(m[8]!)) {
          k += a[i].toInt().toString();
        } // E書式の小数
        else if ('eE'.contains(m[8]!)) {
          if (m[6] != null) // 小数の桁を指定した場合
            e = a[i].toStringAsExponential(int.parse(m[7]!));
          else // 小数の桁が指定されなければ6になる
            e = a[i].toStringAsExponential(6);
          // もし「+」か「-」の後ろに1桁しかなければ「0」を入れる
          if ('+-'.contains(e[e.length - 2]))
            e = e.replaceAll('e+', 'e+0').replaceAll('e-', 'e-0');
          if (m[8] == 'E') // 大文字のEの場合
            e = e.replaceAll('e', 'E');
          k += e;
        } // 普通の小数
        else if ('fF'.contains(m[8]!)) {
          if (m[6] != null) // 小数の桁を指定した場合
            k += a[i].toStringAsFixed(int.parse((m[7]!)));
          else // 小数の桁が指定されなければ6になる
            k += a[i].toStringAsFixed(6);
        } // 適切に書式を変えられる数字
        else if ('gG'.contains(m[8]!)) {
          if (a[i] >= 0.0001 && a[i] < 1000000) // 大きくもなく小さくもない場合
            k += a[i].toString();
          else {
            // ある程度大きい数字の場合
            e = a[i].toStringAsExponential();
            // もし「+」か「-」の後ろに1桁しかなければ「0」を入れる
            if ('+-'.contains(e[e.length - 2]))
              e = e.replaceAll('e+', 'e+0').replaceAll('e-', 'e-0');
            if (m[8] == 'G') // 大文字のGの場合
              e = e.replaceAll('e', 'E');
            k += e;
          }
        } // 8進数
        else if (m[8] == 'o') {
          if (m[2] != null) k += '0o'; // 「#」があれば
          k += a[i].toRadixString(8);
        } // 小文字を使う16進数
        else if (m[8] == 'x') {
          if (m[2] != null) k += '0x'; // 「#」があれば
          k += a[i].toRadixString(16);
        } // 大文字を使う16進数
        else if (m[8] == 'X') {
          if (m[2] != null) k += '0X'; // 「#」があれば
          k += a[i].toRadixString(16).toUpperCase();
        } // 普通の文字列
        else {
          k += a[i].toString();
        }

        // 「0」か空白「 」を入れる
        String _0 = '';
        if (p != 0) {
          int n0 = p - k.length;
          while (n0 > 0) {
            n0--;
            // 「0」が指定された場合、もし文字列でなければ「0」で埋める
            if (!'cs'.contains(m[8]!) != 's' && m[4] == '0')
              _0 += '0';
            // 空白「 」で埋める
            else
              _0 += ' ';
          }
        }

        // 「0」で埋める場合、もし「+」か「-」があれば「0」がその後ろに置かれる
        if ('+-'.contains(k[0]) && _0[0] == '0')
          return s + k[0] + _0 + k.substring(1);
        else // 「+」も「-」もなければ直接前に「0」か空白「 」を置く
          return s + _0 + k;
      },
    );
  }
}

そしてこれをimportしたらすぐ使用できます。これは使う例です。

import 'sprintf.dart';

void main() {
  String ss = '''%+04d
%+4d
%+3d
%%d
%%%%02d
%%%d
%8.3G
%9.3E
%#03o
%#05x
%+07X
%#b
%c
%+7s
%7G
%g
%0*d
%0*.2f''';
  print(ss%[1, 1.1, -2, 3.1, 4.2, 0.0053, 9, 1965, 1965, 61, 100, 'は', 0.02, 2e-5, 5, 1, 7, 1.11]);
}

出てきた結果

+001
  +1
 -2
%d
%%02d
%3
     4.2
5.300E-03
0o11
0x7ad
+0007AD
0b111101
d
      は
   0.02
2e-05
00001
0001.11

これをRubyでやってみても同じ結果が出るので、大体%書式の働きを再現できたとわかりますね。

JavaScriptでの実装

Dartと同じようにコードをJavaScriptで書きます。大体同じなので重複しないように説明は省略してコードだけ乗せます。

String.prototype.$ = function (...a) {
  i = -1
  return this.replace(
    /(%+)(#+)?(\+)?(0)?(\d+|\*)?(\.(\d+))?([bBcdeEfFgGiosuxX])/g,
    function (...m) {
      if (m[1].length % 2 == 0) {
        return m[0].replace(/%%/g, "%")
      }
      i++

      s = m[1].slice(0, m[1].length / 2)

      k = ""
      if (m[3] && !"cs".includes(m[8]) && a[i] > 0) {
        k = "+"
      }
      
      p = 0
      if (m[5] == "*") {
        p = a[i]
        i++
      }
      else if (m[5]) {
        p = parseInt(m[5])
      }

      if ("bB".includes(m[8])) {
        if (m[2]) {
          if (m[8] == "B") k += "0B"
          else k += "0b"
        }
        k += a[i].toString(2)
      }
      else if (m[8] == "c") {
        k += String.fromCharCode(a[i])
      }
      else if ("diu".includes(m[8])) {
        if (typeof (a[i]) != "number")
          throw TypeError
        k += parseInt(a[i])
      }
      else if ("eE".includes(m[8])) {
        if
          (m[6]) e = a[i].toExponential(m[7])
        else
          e = a[i].toExponential(6)
        if ("+-".includes(e[e.length - 2]))
          e = e.replace("e+", "e+0").replace("e-", "e-0")
        if (m[8] == "E")
          e = e.replace("e", "E")
        k += e
      }
      else if ("fF".includes(m[8])) {
        if (m[6])
          k += a[i].toFixed(m[7])
        else
          k += a[i].toFixed(6)
      }
      else if ("gG".includes(m[8])) {
        if (typeof (a[i]) != "number")
          throw TypeError
        if (a[i] >= 0.0001 && a[i] < 1000000)
          k += a[i]
        else {
          e = a[i].toExponential()
          if ("+-".includes(e[e.length - 2]))
            e = e.replace("e+", "e+0").replace("e-", "e-0")
          if (m[8] == "G")
            e = e.replace("e", "E")
          k += e
        }
      }
      else if (m[8] == "o") {
        if (m[2]) k += '0o'
        k += a[i].toString(8)
      }
      else if (m[8] == "x") {
        if (m[2]) k += '0x'
        k += a[i].toString(16)
      }
      else if (m[8] == "X") {
        if (m[2]) k += '0X'
        k += a[i].toString(16).toUpperCase()
      }
      else {
        k += a[i]
      }

      _0 = ""
      if (p) {
        n0 = p - k.length
        while (n0 > 0) {
          n0--
          if (!"cs".includes(m[8]) && m[4] == "0")
            _0 += "0"
          else
            _0 += " "
        }
      }

      if ("+-".includes(k[0]) && _0[0] == "0")
        s += k[0] + _0 + k.slice(1)
      else
        s += _0 + k
      return s
    }
  )
}

そして使う時。(結果もDartと同じなので省略)

ss = `%+04d
%+4d
%+3d
%%d
%%%%02d
%%%d
%8.3f
%9.3E
%#03o
%#05x
%+07X
%#b
%c
%+7s
%7G
%g
%0*d
%0*.2f`
console.log(ss.$(1, 1.1, -2, 3.1, 4.2, 0.0053, 9, 1965, 1965, 61, 100, "", 0.02, 2e-5, 5, 1, 7, 1.11))

%ではなく.$を使うのはちょっとぎこちない気もしますが、PHPより使いやすいはずです。

JavaScriptでは変数の宣言も必要なくて、データ型も気にする必要がなくて楽だが、間違ったデータ型を入れてもエラーが出ずに平気で動くというところはあまり望ましくないですね。データ型をチェックしてエラーをthrowするのも面倒でそこまでする必要ないかもしれません。

参考

Dartのextensionに関して

JavaScriptでsprintf

Rubyでの%書式の使い方

終わりに

余談ですが、この前の記事で書いた通り、4月になって転職して福岡に引っ越ししました。今仕事も住む場所もまだ慣れていなくて色々大変でかなり忙しいところですが、元気でやっています。

この記事も実は2月から書き始めたが、忙しい時期に入って結局保留して今更書き終わったのです。

4
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?