数字をカンマ区切りの文字列に変換する方法

2015/04/30

書くこともないので、ちょっと調べたことだけ書いておく。

ロケールにあわせて、数値をカンマで区切って表示することを考える。

難しいことを何も考えなければ、1の位から順番に数字に変換していって、3桁毎カンマを入れてやればいい。例えばこんなロジックが考えられる。

CString NumberToStr( long v )
{
  TCHAR buf[64] = { 0 };
  int i = 0;

  if ( v == 0 )
    return _T( "0" );

  while ( v > 0 ) {
    int d = v % 10;
    if ( i % 4 == 0 ) {
      buf[ 63 - i ] = _T( ',' );
      i++;
    }
    buf[ 63 - i ] = _T( "0123456789" )[d];
    v = v / 10;
    i++;
  }
  i--;
  return CString( &( buf[ 63 - i ] ), i );
}

効率の善し悪しは別として、とりあえず日本とアメリカであれば、多分これでも大丈夫だろう。だが、それ以外の地域だったらどうなるのか? 何でも、2桁毎に切りたい地域とかもあるらしい。そうした場合、上記のロジックでは対応できない。

かといって、いろいろなパターンを全て自分で実装して、アプリケーションの設定や定義で変更できるようにする、というのも明らかにおかしい。どう考えても、システム全体のロケールの定義に従って変換しなければならないはずである。

具体的に言うと、Windowsであれば、コントロールパネルの「地域と言語」での設定に従って変換するようにしたい。



恐らく、この定義に従って自動的に変換してくれるような機能があるはずだ、ということで調べてみると、GetNumberFormatExというWindowsのAPIが使えそうである。具体的には、こんなロジックになる。

TCHAR buf2[256] = { 0 };
GetNumberFormatEx(
  LOCALE_NAME_USER_DEFAULT,
  0,
  _T( "1234567890123456789" ),
  NULL,
  buf2,
  _countof( buf2 )
);

上記を実行すると、buf2には以下のような値が設定される。

"1,234,567,890,123,456,789.00"

しかも、「地域と言語」の設定を以下の様にしてやれば、



こういう結果になる。

"12,34,56,78,90,12,34,56,789.00"

期待通りの挙動である。しかしながら気に入らないのは小数点と00が付いていることである。小数点以下の数値が存在するのであればまぁこれでもいいのかもしないが、明らかに整数を変換したい場合には都合が悪い。

ならばピリオド以降の文字を切り捨てればいいではないかと考えたくなるが、それは浅はかな考えである。なぜならば、小数点がピリオドである保証はどこにもないからだ。現に、「地域と言語」の設定画面で、小数点として使用する文字をユーザが指定することができる様になっている。

ではどうするのか。GetNumberFormatEx関数の引数にある、NUMBERFMTという構造体を指定してやれば良さそうな気がする。この構造体は以下の様に定義されている。

typedef struct _numberfmt {
  UINT   NumDigits;
  UINT   LeadingZero;
  UINT   Grouping;
  LPTSTR lpDecimalSep;
  LPTSTR lpThousandSep;
  UINT   NegativeOrder;
} NUMBERFMT, *LPNUMBERFMT;


各メンバには、以下の値を指定しろと言う。

NumDigits : 小数点以下の桁数。LOCALE_IDIGITSで指定されるロケールの情報と同じ。
LeadingZero : 整数桁に0を付けるのか否か。LOCALE_ILZEROで指定されるロケールの情報と同じ。
Grouping : 整数桁の区切り方。0~9の値、もしくは32が指定できるという。
lpDecimalSep : 小数点の文字
lpThousandSep : 整数桁の区切り文字
NegativeOrder : 負の値の形式。LOCALE_INEGNUMBERで指定されるロケールの情報と同じ。

だったら、NumDigitsに0を指定してやれば、小数点以下の0が表示されずに済むのではないか、と思われる。つまり、

nbf.NumDigits = 0;
nbf.LeadingZero = 0;
nbf.Grouping = 3;
nbf.lpDecimalSep = _T( "." );
nbf.lpThousandSep = _T( "," );
nbf.NegativeOrder = 1;
GetNumberFormatEx(
  LOCALE_NAME_USER_DEFAULT,
  0,
  _T( "1234567890123456789" ),
  &nbf,
  buf2,
  _countof( buf2 )
);

としてやればいいのではないかと。そうすると、以下の様な結果が得られる

"1,234,567,890,123,456,789"

ここまでは期待通りである。しかし、NumDigits以外に固定値を設定しているのが気に入らない。これでは、システムの設定が利用されなくなってしまうので本末転倒である。だから、GetLocaleInfo関数を用いて、1つずつ設定値を取得してきて、上記の構造体に値を設定してやる。そうすると、以下の様になる。

NUMBERFMT nbf;
TCHAR DecimalSep[4];
TCHAR ThousandSep[4];
TCHAR NegativeOrderVal[4];

memset( &nbf, 0, sizeof( nbf ) );
// 小数点で使用する文字列を取得する
GetLocaleInfo(
  LOCALE_USER_DEFAULT,
  LOCALE_SDECIMAL,
  DecimalSep,
  _countof( DecimalSep )
);
// 桁区切りで使用する文字列を取得する
GetLocaleInfo(
  LOCALE_USER_DEFAULT,
  LOCALE_STHOUSAND,
  ThousandSep,
  _countof( ThousandSep )
);
// 負数の表示方法を取得する
GetLocaleInfo(
  LOCALE_USER_DEFAULT,
  LOCALE_INEGNUMBER,
  NegativeOrderVal,
  _countof( NegativeOrderVal )
);

nbf.NumDigits = 0;
nbf.LeadingZero = 0;
nbf.lpDecimalSep = DecimalSep;
nbf.lpThousandSep = ThousandSep;
nbf.NegativeOrder = _ttoi( NegativeOrderVal );

ここまではいい。問題はGroupingに指定する値である。GetLocaleInfo関数の仕様をいろいろと調べても、ここに指定する値を取得する方法が解らない。LOCALE_SGROUPINGという値を指定してGetLocaleInfo関数を呼び出してやると、桁区切りの方法を取得することができるらしいのだが、その結果得られる値は、セミコロンで区切られた文字列なのだという。

ドキュメントには、以下の様な例が記載されている。
指定値結果
3;03,000,000,000,000
3;2;030,00,00,00,00,000
33000000000,000
3;230000000,00,000

しかしながら、NUMBERFMT::Groupingのデータ型はUINTである。つまり、このセミコロン区切りの値から、UINTに何らかの方法で変換してやらなければならない。ということである。ではそもそもGroupingには何を指定すればいいのか? 試してみた。

Groupingの値結果
0"12345678901234567890"
1"1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0"
2"12,34,56,78,90,12,34,56,78,90"
3"12,345,678,901,234,567,890"
4"1234,5678,9012,3456,7890"
5"12345,67890,12345,67890"
6"12,345678,901234,567890"
7"123456,7890123,4567890"
8"1234,56789012,34567890"
9"12,345678901,234567890"
10"1234567890123456789,0"
11"1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0"
12"1,23,45,67,89,01,23,45,67,89,0"
13"1,234,567,890,123,456,789,0"
20"123456789012345678,90"
21"1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,90"
22"12,34,56,78,90,12,34,56,78,90"
23"123,456,789,012,345,678,90"
30"12345678901234567,890"
31"1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,890"
32"1,23,45,67,89,01,23,45,67,890"
100"1234567890123456789,,0"
101"1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,,0"
102"1,23,45,67,89,01,23,45,67,89,,0"
103"1,234,567,890,123,456,789,,0"
120"12345678901234567,89,0"
121"1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,89,0"
122"1,23,45,67,89,01,23,45,67,89,0"
123"12,345,678,901,234,567,89,0"

解りにくいが、どうやらGroupingの値の上位桁から順に、下位桁から何桁ごとに区切るのかを指定するらしい。だから例えば、123を指定した時には、下から1桁(=0)、2桁(89)、3桁(=567)のところでカンマがおかれている。また、Groupingの値の1の位が0の場合は上位桁が区切られなくなり、0以外であれば、その桁数で最上位桁まで区切られることになる。

だから例えば、Groupingに2468を指定した場合は"12,34567890,12345678,901234,5678,90"という区切られ方になり、3450を指定した場合には"123456789012345678,90123,4567,890"になる。

結局、GetLocaleInfo関数にLOCALE_SGROUPINGを指定して得られる値とどう違うのか。どうも、セミコロンが存在することと、末尾の0の扱いを除いては、数字の指定の仕方は同じであるらしい。

まず、セミコロンは単純に無視すればいい。また末尾の0であるが、これは、LOCALE_SGROUPINGの場合には末尾に0が存在するとその1つ前の値が繰り返す事を意味すると規定されている。つまり、末尾の0の扱いがLOCALE_SGROUPINGとNUMBERFMT::Groupingで完全に逆である。

だから、以下の様な変換となる。

LOCALE_SGROUPINGの値Groupingの値
3;03
3;2;032
330
3;2320

ここまで解れば、ロジックに落とすことが可能である。全部ひっくるめて、以下の様になる。

memset( &nbf, 0, sizeof( nbf ) );
// 小数点で使用する文字列を取得する
GetLocaleInfo(
  LOCALE_USER_DEFAULT,
  LOCALE_SDECIMAL,
  DecimalSep,
  _countof( DecimalSep )
);
// 桁区切りで使用する文字列を取得する
GetLocaleInfo(
  LOCALE_USER_DEFAULT,
  LOCALE_STHOUSAND,
  ThousandSep,
  _countof( ThousandSep )
);
// 桁の区切り方を取得する
GetLocaleInfo(
  LOCALE_USER_DEFAULT,
  LOCALE_SGROUPING,
  Grouping,
  _countof( Grouping )
);
// 負数の表示方法を取得する
GetLocaleInfo(
  LOCALE_USER_DEFAULT,
  LOCALE_INEGNUMBER,
  NegativeOrderVal,
  _countof( NegativeOrderVal )
);
// 小数点で使用する文字列を指定する
nbf.lpDecimalSep = DecimalSep;
// 桁区切りで使用する文字列を指定する
nbf.lpThousandSep = ThousandSep;
// 負数の表示方法を指定する
nbf.NegativeOrder = _ttoi( NegativeOrderVal );

// 桁の区切り方を指定する
nbf.Grouping = 0;  // 初期値
GroupingStrLen = _tcslen( Grouping );

for ( i = 0; i < GroupingStrLen; i++ ) {
  if ( i < GroupingStrLen - 1 ) {
    // 末尾でなく、セミコロン以外であれば、数字として取得する
    if ( Grouping[i] >= _T( '0' ) && Grouping[i] <= _T( '9' ) )
      nbf.Grouping = nbf.Grouping * 10 + ( Grouping[i] - _T( '0' ) );
  }
  else {
    // 末尾が0だった場合は無視するが、0以外だった場合は、その数字と0が指定されたものとして扱う
    if ( Grouping[i] >= _T( '1' ) && Grouping[i] <= _T( '9' ) )
      nbf.Grouping = nbf.Grouping * 100 + ( Grouping[i] - _T( '0' ) ) * 10;
  }
}

// 文字列化した数値を指定し、桁を区切る
GetNumberFormatEx(
  LOCALE_NAME_USER_DEFAULT,
  0,
  _T("123456789012345678901234567890"),
  &nbf,
  buf2,
  _countof( buf2 )
);

これで目的の変換を達成することができる。