6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Siv3DAdvent Calendar 2022

Day 25

Siv3DのイテレータをC++20に対応させようの会

Last updated at Posted at 2022-12-25

0. Siv3D Advent Calendar 2022 25日目です

24日目 | 制作中のGUIライブラリの妄想と進捗 by sthairno さん

こんにちは、「Siv3DのイテレータをC++20に対応させようの会」会長のtomolatoonです。今回は、Siv3DのJSONクラスのイテレータに改良を加えたので、それに関する記事になります。

私自身は、普段C++界隈に生息していて、C++ Advent Calendar 2022 4日目を担当しています。そちらでは、この記事の主題である「イテレータ」についての概論をしていますので、イテレータがわからない場合はそちらも参照してください。

0.1. 記事構成

この記事は

  1. JSONとJSONクラス
    1. 内容: JSONとSiv3DのJSONクラスについて
    2. 対象: Siv3DでのJSONの扱い方を知らない人
  2. 使いやすくなったJSONクラス
    1. 内容: 今回私が改善した点について
    2. 対象: §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
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++のそれと大体同じで、指数表記も可能です。
  • 「真偽値」は、truefalseです。
  • 「文字列」は、C++の文字列と大体同じです。
  • 「null」は、nullです。無効値として扱うものです。

1.1.2. 配列

配列は、角括弧で囲った中に値をカンマ区切りで配置していく値です。C++における一般的な配列とは異なり、好きな値を並べることが出来ます。ケツカンマは許されません。

これはOK
[0, "a", true, [0, "a", true]]
これはNG
[0, "a", true, [0, "a", true],]

1.1.3. オブジェクト

オブジェクトは、波括弧で括った中に「変数名と値の組」をカンマ区切りで配置していく値です。1つの「組」は、変数名には文字列を、値には好きな種類の値を置き、それらをコロンで繋ぐことで作られます。こちらもケツカンマは認められません。

間違い探しです。(JSON Online Validator and Formatter - JSON Lint のようなサイトを使うとすぐに答えがわかっちゃったりはしますが...)

これはOK
{
    "books": [
        {
            "title": "想像ラジオ",
            "author": "いとうせいこう",
            "date": "2013/3"
        },
        {
            "title": "こころ",
            "author": "夏目漱石",
            "date": "1914/8"
        },
        {
            "title": "海辺のカフカ",
            "author": "村上春樹",
            "date": "2002/9"
        }
    ]
}
これはNG
{
    "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>などのすべての要素に前からアクセスしていく(このことを走査と言います)のを短く書くことが出来ます。

範囲for文
#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文に入れることは出来ません。(実行時エラーになったりします)

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から取得するイテレータは前方向イテレータ相当です)

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文で使用可能で、それも両者のコードは異なったものでした。

これまで(OpenSiv3D 0.6.6 まで)
// オブジェクトを持っている JSON
for (const auto&& [key, value] : json)

// 配列を持っている JSON
for (const auto&& value : json.arrayView())

// それ以外の値を持つ JSON は使用不可能

今回の変更によって、次のように、全てオブジェクトの場合と同様の書き方で使用できるようになりました。

これから(OpenSiv3D 0.6.7 から)
// 全てこれで OK!
for (const auto&& [key, value] : json)

変数名(keyの部分)に関しては、次のように設定されます。

JSONの持つ値の種類 変数名(keyの部分)
オブジェクト 変数名
配列 インデックス
その他 空文字列
範囲for文での統一的使用
#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のイテレータペアに適用できる標準ライブラリのアルゴリズムが増えた筈です。

これまで(OpenSiv3D 0.6.6 まで)
auto it = json.begin();

// 進めることは出来るが、戻れはしない
++it;
これから(OpenSiv3D 0.6.7 から)
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 ライブラリに対応する事が出来ました。そのため、次のようなコードがコンパイル可能となります。

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())
	{
	}
}

Console 出力
Ranges Library も使用可能に
key: '2', value: '7'
key: '1', value: '6'
key: '0', value: '5'

2.4. JSON::const_iteratorJSON::iteratorから構築可能に

指し示すJSONの要素を書き換えられないイテレータのことを、const イテレータなどと呼びます。const イテレータは、イテレータが指すJSONが const である場合や、メンバ関数cbegin/cendを使用した場合に取得されるイテレータです。

ところで、一般に const 性が追加されることに問題が生じることはありません。そのことから、const イテレータは通常イテレータから構築可能であるべきで、一般的な C++ のイテレータは実際にそうなっています。

というわけで、今回の変更で、JSON::const_iteratorJSONの const イテレータの型)はJSON::iteratorJSONのイテレータの型)から構築可能になりました。

const イテレータを通常イテレータで構築する
#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 対応」のどちらかになると思います。乞うご期待?

4. 参照

6
1
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
6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?