0. Siv3D Advent Calendar 2022 25日目です
24日目 | 制作中のGUIライブラリの妄想と進捗 by sthairno さん
こんにちは、「Siv3DのイテレータをC++20に対応させようの会」会長のtomolatoonです。今回は、Siv3DのJSON
クラスのイテレータに改良を加えたので、それに関する記事になります。
私自身は、普段C++界隈に生息していて、C++ Advent Calendar 2022 4日目を担当しています。そちらでは、この記事の主題である「イテレータ」についての概論をしていますので、イテレータがわからない場合はそちらも参照してください。
0.1. 記事構成
この記事は
- JSONと
JSON
クラス- 内容: JSONとSiv3Dの
JSON
クラスについて - 対象: Siv3DでのJSONの扱い方を知らない人
- 内容: JSONとSiv3Dの
- 使いやすくなった
JSON
クラス- 内容: 今回私が改善した点について
- 対象: §1 の内容を知っている人
となっています。知っている知識量に応じて、好きな所から読み進めてください。
1. JSONとJSON
クラス
Siv3Dでは設定ファイルを扱うクラスという立ち位置で、JSON/INI/CSV
のそれぞれについて読み書き、TOML/XML
のそれぞれについて読みが出来るクラスが用意されています。今回主題になるのはJSON
クラスと、そのイテレータについてです。
1.1. JSON
JSONは「JavaScript Object Notation」のことで、Web開発で用いられる言語「JavaScript」における「Object」というデータ表現のプリミティブな部分を抜き出したかのような見た目をしているデータ表現です。近年ではWebでのAPIなどを中心に、様々なシーンで使われるデータ表現になりました。(Siv3Dでは設定ファイルという文脈でも扱われているのは先述の通りです)
実態はUTF-8(BOMなし)でエンコードされているただの文字列で、各種言語でJSONを読み書きできるライブラリが用意されています。ちなみに、ファイルに保存する場合の拡張子はjson
にするのが普通です。
1.1.1. JSONのデータの種類
とりあえず、JSONのデータ例を見てみましょう。
1
1e1
true
"string"
null
[0, 0.0, true, "string"]
{
"number": 0,
"boolean": true,
"string": "string",
"array": [0, 0.0, true, "string"]
}
このように、「数字(number)」、「真偽値(boolean)」、「文字列(string)」、「null」、「配列(array)」、「オブジェクト(object)」がJSONで使用できる値で、1つのJSONにこれらのうち1つの値が存在する必要があります。とはいえ、「数字」「真偽値」「文字列」「null」のどれかが1つだけ存在するようなJSONというのはデータとして殆ど意味を為さないので、普通は「オブジェクト」が使用されます。
- 「数字」は、整数と小数の区別は特にありません。
- C++で言う所の多くの処理系における
double
(IEEE 754 binary64)と同じ扱われ方がされます。 - 表記については、C++のそれと大体同じで、指数表記も可能です。
- C++で言う所の多くの処理系における
- 「真偽値」は、
true
とfalse
です。 - 「文字列」は、C++の文字列と大体同じです。
- 「null」は、
null
です。無効値として扱うものです。
1.1.2. 配列
配列は、角括弧で囲った中に値をカンマ区切りで配置していく値です。C++における一般的な配列とは異なり、好きな値を並べることが出来ます。ケツカンマは許されません。
[0, "a", true, [0, "a", true]]
[0, "a", true, [0, "a", true],]
1.1.3. オブジェクト
オブジェクトは、波括弧で括った中に「変数名と値の組」をカンマ区切りで配置していく値です。1つの「組」は、変数名には文字列を、値には好きな種類の値を置き、それらをコロンで繋ぐことで作られます。こちらもケツカンマは認められません。
間違い探しです。(JSON Online Validator and Formatter - JSON Lint のようなサイトを使うとすぐに答えがわかっちゃったりはしますが...)
{
"books": [
{
"title": "想像ラジオ",
"author": "いとうせいこう",
"date": "2013/3"
},
{
"title": "こころ",
"author": "夏目漱石",
"date": "1914/8"
},
{
"title": "海辺のカフカ",
"author": "村上春樹",
"date": "2002/9"
}
]
}
{
"books": [
{
"title": "想像ラジオ",
"author": "いとうせいこう",
"date": "2013/3"
},
{
"title": "こころ",
"author": "夏目漱石",
"date": "1914/8"
},
{
"title": "海辺のカフカ",
"author": "村上春樹",
"date": "2002/9"
},
]
}
1.2. Siv3DにおけるJSONの扱い方
Siv3DではJSON
クラスが存在しており、このクラスを使用してJSONを扱うことが出来ます。ちょっとした(多分)楽しいサンプルコードを用意しました。これで概要を掴んでもらえるとありがたいです。
#include <Siv3D.hpp> // OpenSiv3D 0.6.6
// JSON から情報を取得して背景色を設定する
void setBackgroundColor(const JSON);
// JSON から情報を取得してウィンドウサイズを設定する
void setWindowSize(const JSON);
// JSON から情報を取得してウィンドウタイトルを設定する
void setWindowTitle(const JSON);
// JSON から情報を取得して画像を読み込む
AsyncTask<Texture> setPicture(const JSON);
// 幾つかは許して...
namespace Config
{
constexpr inline double scale_coe_to_cursor = 0.1;
constexpr inline double scale_min = 0.01;
constexpr inline double scale_max = 1000.0;
} // namespace Config
void Main()
{
// JSON::Parse で文字列から構築
// JSON::Load でファイルから構築
const JSON settings = JSON::Parse(UR"({
"backgroundColor": [47, 50, 50],
"windowSize": [1755, 810],
"windowTitle": "ぼっち・ざ・くりすます!",
"pictureURL": "https://bocchi.rocks/assets/img/page/character/hitori/main.png"
})");
Texture pic{U"🐈"_emoji, TextureDesc::Mipped};
AsyncTask<Texture> asyncPic;
// Parse/Load に成功してる時に設定をする
if (not settings.isEmpty())
{
setBackgroundColor(settings);
setWindowSize(settings);
setWindowTitle(settings);
asyncPic = setPicture(settings);
}
Window::Centering();
double scale = 1.0;
Vec2 diff = Scene::CenterF();
while (System::Update())
{
// 拡大率
scale = Clamp(scale - Mouse::Wheel() * Config::scale_coe_to_cursor * scale, Config::scale_min, Config::scale_max);
// 移動関係
if (MouseL.pressed())
{
diff += Cursor::DeltaF();
}
// 位置戻し
if ((KeyR | KeyD).down())
{
diff = Scene::CenterF();
}
// 拡大率戻し
if ((KeyR | KeyS).down())
{
scale = 1.0;
}
// 画像が読み込めたら
if (asyncPic.isReady())
{
pic = asyncPic.get();
}
// 描画
pic.scaled(scale).drawAt(diff);
}
}
// この関数の中では、エラーを例外で処理する形にしてみる
void setBackgroundColor(const JSON settings)
{
try
{
// オブジェクトの値にアクセスするときは変数名を渡す
// const な JSON の時に存在しない要素にアクセスすると例外が投げられる
const JSON backgroundColor = settings[U"backgroundColor"];
// get は値が正常に取得できない場合に例外を発生させる
const int32 r = backgroundColor[0].get<int32>();
const int32 g = backgroundColor[1].get<int32>();
const int32 b = backgroundColor[2].get<int32>();
// rgb は各要素 0 - 255 に収まる必要がある
const auto check = [](int32 i) {
return 0 <= i && i <= 255;
};
// チェックして設定する
if (check(r) && check(g) && check(b))
{
Scene::SetBackground(Color(r, g, b));
}
else
{
// 例外でチェックしてるので例外を投げとく
throw Error{};
}
}
// Siv3D の例外は Error をキャッチすれば取れる
catch (const Error&)
{
Print << U"3つの整数(0 ~ 255)から成る配列を値に取る \"backgroundColor\" が存在しないか、制限を守っていません。";
}
}
// この関数の中では、エラーを事前に回避して例外を出さないようにしてみる
void setWindowSize(const JSON settings)
{
// hasElement でオブジェクトが指定した変数名の要素を持っているかチェックできる
if (settings.hasElement(U"windowSize"))
{
const JSON windowSize = settings[U"windowSize"];
if (windowSize.isArray() && windowSize.size() == 2)
{
// getOpt は値が正常に取得できない場合は none が入った Optional を返す(ので例外を出さない)
const Optional<int32> w = windowSize[0].getOpt<int32>();
const Optional<int32> h = windowSize[1].getOpt<int32>();
// Optional は値が入っているかの bool 値に変換できる
if (w && h)
{
// チェックして設定する
if (w.value() > 0 && h.value() > 0)
{
Window::Resize(w.value(), h.value());
// ここで関数から脱出すれば、正常な時はここより下に制御が流れないようになる
return;
}
}
}
// ここに来てしまったらダメだったという流れにしてあるのでこれでいい
Print << U"\"windowSize\" は存在しますが、2つの自然数から成る配列を値に取っていません。";
}
else
{
Print << U"2つの自然数から成る配列を値に取る \"windowSize\" が存在しません。";
}
}
// 例外を使わない書き方
void setWindowTitle(const JSON settings)
{
if (settings.hasElement(U"windowTitle"))
{
const JSON windowTitle = settings[U"windowTitle"];
// getOr は値が正常に取得できない場合は、渡した値を返す(ので例外を出さない)
// ちなみに .data() を付けているのは StringView -> String の変換周りの話のエラー回避
String title = windowTitle.getOr<String>(Window::DefaultTitle.data());
Window::SetTitle(title);
}
// 無くてもメッセージを出さないことにする
}
// 本筋ではないので細かく解説はしません
AsyncTask<Texture> Loadimg(String url);
// 例外を使わない書き方
AsyncTask<Texture> setPicture(const JSON settings)
{
if (settings.hasElement(U"pictureURL"))
{
const JSON pictureURL = settings[U"pictureURL"];
const Optional<String> url = pictureURL.getOpt<String>();
if (url && url.value().size() > 0)
{
return Loadimg(url.value());
}
else
{
Print << U"\"pictureURL\" は存在しますが、URLが書かれていません。";
}
}
else
{
Print << U"URLを文字列として値に取る \"pictureURL\" が存在しません。";
}
// ダメだったらねこ
return AsyncTask<Texture>([=]() { return Texture{U"🐈"_emoji, TextureDesc::Mipped}; });
}
void response_print(HTTPResponse response)
{
Print << U"getHeader : {}"_fmt(response.getHeader());
Print << U"getStatusCode : {}"_fmt((uint32)response.getStatusCode());
Print << U"getStatusLine : {}"_fmt(response.getStatusLine());
Print << U"1xx isInformational : {}"_fmt(response.isInformational());
Print << U"3xx isRedirection : {}"_fmt(response.isRedirection());
Print << U"4xx isClientError : {}"_fmt(response.isClientError());
Print << U"404 isNotFound : {}"_fmt(response.isNotFound());
Print << U"5xx isServerError : {}"_fmt(response.isServerError());
}
AsyncTask<Texture> Loadimg(String url)
{
// ファイルシステムに存在していたら
if (FileSystem::Exists(url))
{
Texture t{url, TextureDesc::Mipped};
return AsyncTask<Texture>([=]() { return Texture{url, TextureDesc::Mipped}; });
}
// ネット上から検索するとき
else
{
return AsyncTask<Texture>(
[=]() {
MemoryWriter buffer;
// URLさえあればアクセスできる画像へのURLから画像をダウンロードする
auto simple = [&](FilePathView path) {
return SimpleHTTP::Load(path, buffer);
};
// ダウンロードする
auto download = [&](FilePathView path) -> HTTPResponse {
// ドメインで検索しやすくするための補助
auto domain = [&](FilePathView path) {
auto it = path.indexOf(U"//");
return path.substr(it + 2);
};
return simple(path);
};
// 読み込めたらそれを返す
if (auto response = download(url); response.isOK())
{
return Texture{MemoryReader(std::move(buffer).getBlob()), TextureDesc::Mipped};
}
// ダメだったらレスポンスを表示してねこ
else
{
response_print(response);
return Texture{U"🐈"_emoji, TextureDesc::Mipped};
}
});
}
}
1.3. 特筆すべき機能の解説
それでは、§1.2. とはまた別に、この後のセクションでの話で必要な機能についてここで解説しておきます。
1.3.1. JSON
と範囲for文
範囲for文という言語機能があります。これを使うと、std::array<T, N>
やArray<T>
などのすべての要素に前からアクセスしていく(このことを走査と言います)のを短く書くことが出来ます。
#include <Siv3D.hpp> // OpenSiv3D 0.6.6
void Main()
{
Array<int32> array = {1, 2, 3, 4, 5};
// 従来の書き方
for (size_t i = 0; i < array.size(); ++i)
{
Print << array[i];
}
// 範囲for文(C++11以降)
// 要素の書き換えが必要なければ const を付けておく方がいいかも?
for (const auto&& e : array)
{
Print << e;
}
while (System::Update())
{
}
}
範囲for文は、JSON
でも使用することが出来ます。ただし、OpenSiv3D 0.6.6 までの場合、少し使い方が面倒です。
オブジェクトの場合はそのままでいいですが、配列の場合は.arrayView()
を付ける必要があり、渡される値もオブジェクトの場合とは異なります。更に、オブジェクトでも配列でもない値を持つJSON
を範囲for文に入れることは出来ません。(実行時エラーになったりします)
#include <Siv3D.hpp> // OpenSiv3D 0.6.6
void Main()
{
// 構造化束縛
// 生配列やタプル、一定の条件を満たした構造体などを要素ごとに分解出来る機能
auto [a, b] = std::tuple{0, 1};
// assert(...) は ... が false の時プログラムを強制終了するもの
// プログラムが落ちなければ ... が真であることがわかる
assert(a == 0);
assert(b == 1);
const JSON json1 = JSON::Parse(UR"({
"key1": 0,
"key2": 1,
"key3": 2
})");
// JSONItem のインスタンスは構造化束縛可能で、1つめが変数名、2つめが値になる
for (const auto&& [key, value] : json1)
{
Print << U"key: {}, value: {}"_fmt(key, value.format());
}
const JSON json2 = JSON::Parse(UR"([3, 4, 5])");
// OpenSiv3D 0.6.6 までだと arrayView を付けないとダメ!
// オブジェクトの変数名に相当するものは提供されず、値だけ渡される
for (const auto&& value : json2.arrayView())
{
Print << value;
}
// OpenSiv3D 0.6.6 までだと
// オブジェクトでも配列でもない JSON は範囲for文に入れることは出来ない!
while (System::Update())
{
}
}
1.3.2. JSON
とイテレータ
範囲for文を実際に動かせるようにしているのは「イテレータ」と呼ばれているクラスであったりします。詳しくは私のC++ Advent Calendar などを見てほしいのですが、イテレータは要素を指し示すものです。
メンバ関数begin
で先頭の要素を指すイテレータが、メンバ関数end
で終端の一個次の要素を指すイテレータ(無効状態のイテレータとも言えなくはない)が取得できます。この時にも、オブジェクトのJSON
場合はそのままでいいのですが、配列のJSON
の場合は.arrayView()
を挟む必要があります。
イテレータはインクリメントで次の要素を指し示すように変更できたり、間接参照すると値を取得することが出来たりします。(ちなみに、OpenSiv3D 0.6.6 までは オブジェクトのJSON
から取得するイテレータが前方向イテレータ相当で、配列のJSON
から取得するイテレータは前方向イテレータ相当です)
#include <Siv3D.hpp> // OpenSiv3D 0.6.6
void Main()
{
// オブジェクトの JSON の場合
{
const JSON json = JSON::Parse(UR"({
"key1": 0,
"key2": 1,
"key3": 2
})");
// そのまま begin と end を呼べば OK
auto beg = json.begin();
auto end = json.end();
// インクリメントすると次の要素を指すように変更
// 間接参照で値を取得できる(範囲for文の宣言部と同じようなもの)
auto [key, value] = *(++beg);
// key: key2, value: 1
Print << U"key: {}, value: {}"_fmt(key, value.format());
}
// 配列の JSON の場合
{
const JSON json = JSON::Parse(UR"([3, 4, 5])");
// やっぱり arrayView を通す必要がある
auto beg = json.arrayView().begin();
auto end = json.arrayView().end();
// インクリメントすると次の要素を指すように変更
// 間接参照で値を取得できる(範囲for文の宣言部と同じようなもの)
auto value = *(++beg);
// 4
Print << value;
}
while (System::Update())
{
}
}
§2. 使いやすくなったJSON
クラス
§1.3. では、JSON
の持つ値がオブジェクトか配列かでコードを書き換える必要があったり、そもそもオブジェクトでも配列でもない場合は範囲for文に入れられないなど、面倒な点を強調しました。
これらは、今回私が変更を加えて解消されることとなりました。該当する Pull Request のページは以下です。どうやら OpenSiv3D 0.6.7(記事執筆時の次期バージョン)から使用可能になるようです。
ここからは、OpenSiv3D 0.6.7 から利用できるJSON
について、コードの断片を交えて説明をしていきます。
§2.1. 範囲for文での使用が統一的に
§1.3.1. に対応する話です。繰り返しになりますが、OpenSiv3D 0.6.6 までは、オブジェクトや配列を持っているJSON
のみが範囲for文で使用可能で、それも両者のコードは異なったものでした。
// オブジェクトを持っている JSON
for (const auto&& [key, value] : json)
// 配列を持っている JSON
for (const auto&& value : json.arrayView())
// それ以外の値を持つ JSON は使用不可能
今回の変更によって、次のように、全てオブジェクトの場合と同様の書き方で使用できるようになりました。
// 全てこれで OK!
for (const auto&& [key, value] : json)
変数名(key
の部分)に関しては、次のように設定されます。
JSON の持つ値の種類 |
変数名(key の部分) |
---|---|
オブジェクト | 変数名 |
配列 | インデックス |
その他 | 空文字列 |
#include <Siv3D.hpp> // OpenSiv3D 0.6.7
void Main()
{
JSON json = JSON::Parse(UR"({"key":[5,6,7,8,9]})");
Console << U"object に対して";
for (auto&& [key, value] : json)
{
Console << U"key: '{}', value: '{}'"_fmt(key, value.format());
}
Console << U"";
Console << U"array に対して";
for (auto&& [key, value] : json[U"key"])
{
Console << U"key: '{}', value: '{}'"_fmt(key, value.format());
}
Console << U"";
Console << U"その他の値に対して";
for (auto&& [key, value] : json[U"key"][0])
{
Console << U"key: '{}', value: '{}'"_fmt(key, value.format());
}
Console << U"";
while (System::Update())
{
}
}
object に対して
key: 'key', value: '[
5,
6,
7,
8,
9
]'
array に対して
key: '0', value: '5'
key: '1', value: '6'
key: '2', value: '7'
key: '3', value: '8'
key: '4', value: '9'
その他の値に対して
key: '', value: '5'
§2.2. イテレータが双方向イテレータに
JSON
は内部的にnlohmann-jsonというライブラリを使用しているのですが、そのライブラリのイテレータを調査した所、イテレータが双方向イテレータに対応していることが発見されました。その結果、JSON
のイテレータも双方向イテレータになりました(従来は前方向イテレータ相当)
これによって、イテレータをインクリメントで次へ進めるだけでなく、デクリメントで前に戻す事ができるようになりました。これによって、JSON
のイテレータペアに適用できる標準ライブラリのアルゴリズムが増えた筈です。
auto it = json.begin();
// 進めることは出来るが、戻れはしない
++it;
auto it = json.begin();
// 進めることも出来るし
++it:
// 戻ることも出来る
--it;
§2.3. JSON
のイテレータが C++20 イテレータに準拠
C++20 から、イテレータは C++20 から追加された機能である、コンセプトを用いて定義されることとなりました。また、コンセプトによる制約を課したアルゴリズム関数が多数std::ranges
以下に追加されました。
従来の状態だと、コンセプトを満たしていなかったため、それらの新しいアルゴリズム関数が全て利用不可能となっていました。今回の変更でコンセプトを満たすように定義が追加されたため、利用可能になったアルゴリズム関数があるはずです。(とは言っても、std::ranges::reverse
のように、JSON
のイテレータが持つ他の性質のために利用不可になっているものがあります...)
また、これによって JSON
が C++20 から追加されたライブラリの Range ライブラリに対応する事が出来ました。そのため、次のようなコードがコンパイル可能となります。
#include <Siv3D.hpp> // OpenSiv3D 0.6.7
#include <ranges>
void Main()
{
static_assert(std::bidirectional_iterator<JSON::iterator>);
static_assert(std::bidirectional_iterator<JSON::const_iterator>);
Console << U"Ranges Library も使用可能に";
JSON json = JSON::Parse(UR"({"key":[5,6,7,8,9]})");
// std::views::reverse で並びを逆にして
// std::views::filter で値が7以下のものだけに絞っている
for (auto&& [key, value] : json[U"key"]
| std::views::reverse
| std::views::filter([](auto j) { return j.value.get<int32>() <= 7; }))
{
Console << U"key: '{}', value: '{}'"_fmt(key, value.format());
}
while (System::Update())
{
}
}
Ranges Library も使用可能に
key: '2', value: '7'
key: '1', value: '6'
key: '0', value: '5'
2.4. JSON::const_iterator
がJSON::iterator
から構築可能に
指し示すJSON
の要素を書き換えられないイテレータのことを、const イテレータなどと呼びます。const イテレータは、イテレータが指すJSON
が const である場合や、メンバ関数cbegin/cend
を使用した場合に取得されるイテレータです。
ところで、一般に const 性が追加されることに問題が生じることはありません。そのことから、const イテレータは通常イテレータから構築可能であるべきで、一般的な C++ のイテレータは実際にそうなっています。
というわけで、今回の変更で、JSON::const_iterator
(JSON
の const イテレータの型)はJSON::iterator
(JSON
のイテレータの型)から構築可能になりました。
#include <Siv3D.hpp> // OpenSiv3D 0.6.7
#include <ranges>
void Main()
{
JSON json = JSON::Parse(UR"({"key":[5,6,7,8,9]})");
// const iterator は 通常 iterator から構築可能に
std::ranges::iterator_t<JSON> i1 = std::ranges::begin(json);
std::ranges::iterator_t<const JSON> i2 = std::ranges::begin(json);
std::ranges::iterator_t<const JSON> i3 = std::ranges::cbegin(json);
while (System::Update())
{
}
}
3. あとがき
1日で書いたので、結構粗が目立つかもしれませんが、楽しんで頂ければ幸いです。OpenSiv3D 0.6.7 で利用できるようになりますので、乞うご期待!
ちなみに、C++20 イテレータのことをわかっている人は、Pull Request の方での私のコメントを見て頂ければ、詳しい変更についてわかっていただけると思いますので、細かい説明は省くことにしました。
最後に。Siv3D への Pull Request の次回予告としては、「JSON Schema による JSON の Validation」か、「CSV/INI
のイテレータの C++20 対応」のどちらかになると思います。乞うご期待?