これは、「Jsonnetで○○するには、どうしたらいいの?」について、つらつらと列挙するためのノートです。随時更新。
質問や「もっといいやり方があるよ」などあればコメント欄で教えていただけると助かります。
参照
他の場所で定義された値を参照する
これにはいくつかやり方がある。
まずは最も単純な例である、「オブジェクトの同じレベルの別フィールドの値を参照する」方法。
これは self.<フィールド名> で参照できる。
{
f1: 1,
f2: self.f1 + self.f1,
}
↓
{
"f1": 1,
"f2": 2
}
この Jsonnet のトップレベルオブジェクトの f1 フィールドの値は 1 なので、 それを参照して計算する f2 の値は $1+1 = 2$ となる。
オブジェクトの異なるレベルのフィールドについては、いくつか方法がある。
まず同じレベルの別フィールドの子孫について。これは先ほどのように self を使って参照したあとは、通常のオブジェクトを参照するように . (ドット)でフィールド名を繋いで参照したいフィールドまで辿ればよい。
{
f1: {
c1: 1,
c2: 2,
},
f2: self.f1.c1 + self.f1.c2,
}
↓
{
"f1": {
"c1": 1,
"c2": 2
},
"f2": 3
}
このように、同じレベル配下のフィールドについては self から辿れば全て参照できる。
問題は上方向の参照だ。
上方向の参照については、まず $ を利用する方法がある。トップレベルオブジェクトを $ で参照できるので、これを利用できる。
{
f1: 2,
f2_child: {
grandchild: {
f3: $.f1 * 3,
},
},
}
↓
{
"f1": 2,
"f2_child": {
"grandchild": {
"f3": 6
}
}
}
grandchild はトップレベルオブジェクトからすると孫にあたる階層だが、 $.f1 と書くことで一息でトップレベルオブジェクトの f1 フィールドを参照できる。
なお、直接の親を参照する名前、さらに言えば上方向に相対的に参照する名前は存在しないようだ。
{
f1: 2,
f2_child: {
f3: 3,
grandchild: {
f4: parent.f3 * 3,
},
},
}
↓
$ jsonnet foo.jsonnet
STATIC ERROR: ref3.jsonnet:6:11-17: Unknown variable: parent
例えば上のように、 grandchild から見て直接の親を参照できる parent のような識別子があると便利だが、これはエラーになる。 Jsonnet の予約されたキーワードは assert else error false for function if import importstr in local null tailstrict then self super true なので、 parent 的な意味に相当するものはなさそうである。残念。( super は親オブジェクトではなく継承元オブジェクトを参照するキワードである)
親オブジェクトを参照するには、 local によるローカル変数宣言を行えば、これに近いことが可能になる。
{
f1: 2,
f2_child: {
f3: 3,
local parent = self,
grandchild: {
f4: parent.f3 * 3,
},
},
}
↓
{
"f1": 2,
"f2_child": {
"f3": 3,
"grandchild": {
"f4": 9
}
}
}
先程との違いは f2_child 内で local parent = self, という行を足してあるだけだ。
これで parent という名前で f2_child のオブジェクトを参照可能になる。
local で宣言・定義した変数のスコープは、記述上の当該オブジェクトおよびその子供以下全体である。(local 行より上の行でも同一オブジェクト内なら参照可能だ。もちろん読みやすさを考えるとやらないほうが良い。)
文字列操作
フォーマット
+ で必要な文字を連結して組み立てるか、 Python 風フォーマット書式を使う。
{
// `+` での連結
f1: 'foo' + 'bar',
// Python風フォーマット書式
local target = 'world',
f2: 'Hello %s! (1)' % target,
// 変数のリスト指定
local greeting = 'hello',
f3: '%s %s! (%d)' % [greeting, target, 2],
// 変数のオブジェクト指定
local args = {
greeting: 'hello',
target: 'world',
count: 3,
},
f4: '%(greeting)s %(target)s! (%(count)d)' % args,
}
↓
{
"f1": "foobar",
"f2": "Hello world! (1)",
"f3": "hello world! (2)",
"f4": "hello world! (3)"
}
スライス
Python風のスライスが可能。ただし、負の数によるインデックスおよびステップ指定はエラーになる。
文字数カウントはUTFコードポイントで行われる(つまり、例えば日本語文字列であれば日本語の1文字を1とカウントする)。
{
local s = 'All work and no play makes Jack a dull boy',
f1: s[1:],
f2: s[:5],
f3: s[:10:2],
local t = '犬も歩けば棒に当たる',
f4: t[1:],
f5: t[:5],
f6: t[:10:2],
}
↓
{
"f1": "ll work and no play makes Jack a dull boy",
"f2": "All w",
"f3": "Alwr ",
"f4": "も歩けば棒に当たる",
"f5": "犬も歩けば",
"f6": "犬歩ばにた"
}
キャピタライズ(頭文字を大文字化)
文字列全部を大文字にするには std.asciiUpper() を使えばよいが、文字列の先頭だけを大文字にする方法は標準になさそうである。
この場合次のようにすればOK
local capitalize = function(s) std.asciiUpper(s[:1]) + s[1:];
{
f1: 'abc',
f2: capitalize(self.f1),
f3: std.asciiUpper(self.f1),
}
↓
{
"f1": "abc",
"f2": "Abc",
"f3": "ABC"
}
フィールド名に記号やスペースを含めたい
_ 以外の記号をフィールド名に含めたい場合、クォートすればOK
{
'f1-foo': 1,
'f2 foo': 2,
"f3'foo": 3,
'f4"foo': 4,
}
↓
{
"f1-foo": 1,
"f2 foo": 2,
"f3'foo": 3,
"f4\"foo": 4
}
インポート
別のJsonnetファイルの内容を読み込む
import を使う。オブジェクトはもちろん、対象ファイルの定義されたトップレベルオブジェクトであれば、
数値であっても関数であっても読み込める。
例えば次のような x.jsonnet というファイルがあるとする
//
// x.jsonnet
//
{
foo: 1,
bar: 2,
}
次のJsonnetのように x.jsonnet を import で読み込んで利用することができる。
local x = (import 'x.jsonnet');
{
field: x,
}
↓
{
"field": {
"bar": 2,
"foo": 1
}
}
なお、 import 文でファイル名を指定する文字列は文字列リテラルのみを受け付けるようになっており、
文字列の組み立てや変数を利用した import はエラーになる。
local filename = 'x.jsonnet';
local x = (import filename);
{
field: x,
}
↓
STATIC ERROR: <stdin>:2:19-27: computed imports are not allowed.
この挙動は環境からの独立性を重視する意図的な設計と思われる。
(Jsonnetは設定ファイル用途を主目的としており、環境外とのデータのやり取りを意図的に制限している言語である)
別ファイルの内容を文字列として読み込む
importstr を使う。例えば data.txt に
hello world
と書いてあるとすると、次のようになる。
{
field: importstr 'data.txt',
}
↓
{
"field": "hello world\n"
}
バイナリ形式の別ファイルを読み込む
importbin を使う。この書式だけだとシングルバイトごとの数値の配列として読み込んでしまうので、バイナリデータを Base64 エンコードした文字列で表現することの多いJSONの慣例に合わせて std.base64 を使って次のように読み込むのが実用的だ。
{
field: std.base64(importbin 'data.bin'),
}
↓
{
"field": "caXboX8nJPw="
}
JSON を読み込む
Jsonnetファイル同様、 JSON ファイルの場合も import で読み込めば良い。例は省略。
マージ
オブジェクトをマージする
+ でマージできる。同じ名前のフィールドがある場合、+の左辺がベースとなり、右辺で上書きされる。
各フィールドの値も再帰的にマージしたい場合、 std.mergePatch を使う。
{
obj1:: {
field1: 'value1',
field2: 'value2',
},
obj2:: {
field2: 'value2-2',
field3: 'value3',
},
obj1_on_obj2: self.obj2 + self.obj1,
obj2_on_obj1: self.obj1 + self.obj2,
}
↓
{
"obj1_on_obj2": {
"field1": "value1",
"field2": "value2",
"field3": "value3"
},
"obj2_on_obj1": {
"field1": "value1",
"field2": "value2-2",
"field3": "value3"
}
}
オブジェクトを再帰的にマージする
単純な + だと各フィールドは右辺のオブジェクトの値で上書きされるが、 std.mergePatch で再帰的にマージできる。
ただし、値が配列の場合(次の例の field4 参照)は第2引数側のオブジェクトで上書きされるので注意。
{
obj1:: {
field1: 1,
field2: {
field3: 3,
},
field4: [4.1, 4.2],
},
obj2:: {
field2: {
field5: 5,
},
field4: [4.3, 4.4],
field6: 6,
},
merged: std.mergePatch(self.obj1, self.obj2),
shallow: self.obj1 + self.obj2,
}
↓
{
"merged": {
"field1": 1,
"field2": {
"field3": 3,
"field5": 5
},
"field4": [
4.2999999999999998,
4.4000000000000004
],
"field6": 6
},
"shallow": {
"field1": 1,
"field2": {
"field5": 5
},
"field4": [
4.2999999999999998,
4.4000000000000004
],
"field6": 6
}
}
フィールド操作
フィールド名を動的に決める
[field_string]: value のように [ ] でフィールド名を囲い、そのなかに string の値を入れると動的に決定した値をフィールド名にできる。
{
local prefix = 'foo',
local name1 = 'bar',
local name2 = 'buz',
obj: {
[prefix + name1]: 1,
[prefix + name2]: 2,
},
}
↓
{
"obj": {
"foobar": 1,
"foobuz": 2
}
}
ただし、今定義しようとしているフィールドと同じ階層のフィールドの値や変数を用いることはできない。これらは、そのオブジェクトの作成が完了した時点で値が決まるので、今定義しようとしているフィールドを作る段階ではまだ値が決定していないからである。
{
obj: {
local prefix = 'foo',
local name1 = 'bar',
local name2 = 'buz',
[prefix + name1]: 1,
[prefix + name2]: 2,
},
}
↓
STATIC ERROR: dynamic-fieldname.jsonnet:7:6-12: Unknown variable: prefix
条件によってフィールドを追加する
これは フィールド名を動的に決める のようにフィールドを動的に決定した場合に、[]の評価結果が null だとフィールドが削除されることを応用して、 [if <cond> then <fieldname>]: <value> のようにすれば良い。
今、定義しようとしているオブジェクトのフィールドやローカル変数は [] 内から参照できない点も注意が必要。
{
gen(cond1, cond2, value):: {
[if cond1 then 'field1']: value,
[if cond2 then 'field2']: value,
field3: 3,
},
obj1: self.gen(true, false, 1),
obj2: self.gen(false, true, 2),
}
↓
{
"obj1": {
"field1": 1,
"field3": 3
},
"obj2": {
"field2": 2,
"field3": 3
}
}
オブジェクトの特定のフィールドを削除したい
出力されたJSONを利用するアプリケーション側が、「存在しないフィールド」と「値がnullのフィールド」を区別してしまう場合、フィールドの削除が必要になることがある。
フィールドを削除するいくつかの方法が知られているが、どれも一長一短であり、将来的により簡易な方法がサポートされる可能性がある。
- hidden field を持つオブジェクトとマージする
- 当該フィールドが
nullなオブジェクトをstd.mergePatch()でマージする - 当該フィールドが
nullなオブジェクトとマージしたうえでstd.prune()に通す - 当該フィールド以外のフィールドを移植する
詳細はそれぞれ次のとおり:
hidden field を持つオブジェクトとマージする
次の del のように hidden field ( :: で定義されるフィールド)を持つオブジェクトをマージすると、そのフィールドが不可視になり、最終的に出力される obj1 のJSONオブジェクトからは削除されたように見える。
{
local base = {
field1: 1,
field2: 2,
field3: 3,
},
local del = {
field2:: null,
},
obj1: base + del,
obj2: self.obj1 {
field4: self.field2,
},
}
↓
{
"obj1": {
"field1": 1,
"field3": 3
},
"obj2": {
"field1": 1,
"field3": 3,
"field4": null
}
}
この方法は最も手軽にフィールドの削除が可能である。ただし、以下の点に注意が必要:
- この方法では出力の直前まで削除されないので、
obj2のような場合にエラーにならず、消したはずのフィールドの値が見える。 - コードを見ても削除の意図が分かりづらい。上の例のように削除するためだけのオブジェクトだとまだ意図を把握できるが、 フィールドの追加や削除が入り乱れるとわかりにくくなる。
当該フィールドが null なオブジェクトを std.mergePatch() でマージする
std.mergePatch(target, patch) は target オブジェクトを patch オブジェクトで上書きするような動作をするが、 patch 側に値が null のフィールドがあると、そのフィールドは削除される。これを利用して target 側のフィールドを削除できる。
{
local base = {
field1: 1,
field2: 2,
field3: 3,
child: {
do_not_delete: null,
},
},
obj1: std.mergePatch(base, {
field2: null,
}),
}
↓
{
"obj1": {
"child": {
"do_not_delete": null
},
"field1": 1,
"field3": 3
}
}
これも比較的削除の意図が読み取りづらく、初見では少し驚くかもしれない。それでも patch 側で指定されていないフィールドについては触らないので、次の方法よりはマシだろう。
当該フィールドが null なオブジェクトとマージしたうえで std.prune() に通す
std.prune() は値が null なフィールドを削除する関数であり、これを利用すると任意のフィールドを削除可能(prune は「不要な枝を切り取る」というイメージの英単語であり、削除の意図はわかりやすい)。この方法であれば、次の例でコメント化してある部分のように削除した値を参照するとエラーになる。削除対象の値は null 以外に空オブジェクト {} および空配列 [] がある。空文字列 ''は削除されない。
{
local base = {
field1: 1,
field2: 2,
field3: 3,
child: {
do_not_delete: null,
},
empty_obj: {},
empty_string: '',
empty_array: [],
},
local del = {
field2: null,
},
obj1: std.prune(base + del),
// the following becomes error
// obj2: std.prune(self.obj1 {
// field4: self.field2,
// }),
}
↓
{
"obj1": {
"empty_string": "",
"field1": 1,
"field3": 3
}
}
ただし、この方法では、値が null なフィールドは再帰的に削除されるので、意図しない削除が発生しないように注意が必要。削除対象には、上記の base.child のようにフィールドの削除によって中身が空になったオブジェクトも含まれる。
当該フィールド以外のフィールドを移植する
最も手間がかかるが、細かいコントロールはしやすい。
{
local base = {
field1: 1,
field2: 2,
field3: 3,
child: {
do_not_delete: null,
},
},
obj1: {
field1: base.field1,
field3: base.field3,
child: base.child,
},
}
↓
{
"obj1": {
"child": {
"do_not_delete": null
},
"field1": 1,
"field3": 3
}
}
この方法であっても、コピー漏れなどが発生しやすい、コードの見通しが悪くなるといった欠点があるので注意が必要。
なお、同様の移植をオブジェクト内包表記でおこなうことで記述量を削減できるが、内包表記に慣れていないと読みづらさがある。
{
local base = {
field1: 1,
field2: 2,
field3: 3,
child: {
do_not_delete: null,
},
},
obj1: {
[f]: base[f]
for f in std.objectFieldsAll(base)
if f != 'field2'
},
}
↓
{
"obj1": {
"child": {
"do_not_delete": null
},
"field1": 1,
"field3": 3
}
}
さらにこれを応用して次のような関数を定義しておくことで簡易な削除が可能になるが、再帰関数等についてそれなりの理解が必要になる
local delSingleField(obj, field) = {
[f]: obj[f]
for f in std.objectFieldsAll(obj)
if f != field
};
local delMultiField(obj, fields) =
if std.length(fields) == 0 then
obj
else
delSingleField(delMultiField(obj, fields[1:]), fields[0]);
local delField(obj, field) =
local tp = std.type(field);
if tp == 'string' then
delSingleField(obj, field)
else if tp == 'array' then
delMultiField(obj, field)
else
error 'delField(): second parameter must be a string or an array of strings';
{
local base = {
field1: 1,
field2: 2,
field3: 3,
child: {
do_not_delete: null,
},
},
obj1: delField(base, 'field2'),
obj2: delField(base, ['field2', 'field3']),
}
↓
{
"obj1": {
"child": {
"do_not_delete": null
},
"field1": 1,
"field3": 3
},
"obj2": {
"child": {
"do_not_delete": null
},
"field1": 1
}
}
関数
文字列を返す関数を定義したい。数値を返す関数を定義したい
よくある関数はオブジェクトや配列を返すように定義するが、単体の文字列や数値を返す関数も定義できる。
ただその場合、返却値を組み立てるための事前計算はどこに記述すればよいのだろうか?
これは、オブジェクトのキーと値の間に local 変数を定義することが可能なので、そこで計算すれば良い。
local lib = {
fn(a):
local b = 2 * a;
local c = a * a;
'%d + %d = %d' % [b, c, b + c],
};
{
foo: lib.fn(3),
}
↓
{
"foo": "6 + 9 = 15"
}
再帰関数を定義したい
普通に書けば定義できる。
オブジェクトを返さない関数であれば、自分自身は self.<関数名> で参照できるし、オブジェクトを返す関数であれば、 $.<関数名> などで参照すれば良い。(オブジェクトのキーやlocal変数は、値を記述する場所もスコープ内である)
local lib = {
fib(n):
if n < 2 then
n
else
self.fib(n - 1) + self.fib(n - 2),
fib2(o): {
n:
if o.n < 2 then
o.n
else
$.fib2({ n: o.n - 1 }).n + $.fib2({ n: o.n - 2 }).n,
},
};
{
fib: [
lib.fib(x)
for x in std.range(1, 10)
],
fib2: [
lib.fib2({ n: x }).n
for x in std.range(1, 10)
],
}
↓
{
"fib": [
1,
1,
2,
3,
5,
8,
13,
21,
34,
55
],
"fib2": [
1,
1,
2,
3,
5,
8,
13,
21,
34,
55
]
}
相互再帰する再帰関数を定義したい
オブジェクトのフィールドで関数を定義すればよい。
再起構造をパースするときなどに相互再帰は便利だが、Jsonnet の local 宣言では事前に定義されていない変数は見えないし、変数への再代入も不可能なので local 変数で相互再帰する関数を書くことはできない。
しかし、オブジェクト内のフィールド名またはフィールド記述箇所にて local 宣言された変数名は、そのオブジェクト内からであれば定義順に関係なく可視なので相互再帰する関数も書くことができる。
例えば複雑なオブジェクト構造を解析して、中にある数値だけを全て合計して返す関数は次のように定義できる
// 与えられた値を解析して、その中にある数値だけを全て合計して返す
local add_all_numbers(e) =
// 数値の配列を受け取って合計を返す補助関数
local sum(arr) =
std.foldl(function(a, b) a + b, arr, 0);
local parse = {
// メインの再帰関数
rec(e)::
local tp = std.type(e);
if tp == 'number' then
number(e)
else if tp == 'object' then
object(e)
else if tp == 'array' then
array(e)
else
other(e),
// object(), array()から簡易に呼び出すため local で名前をつけておく
// これを定義しなくても $ を使って $.rec で呼び出しても良い。
local rec = self.rec,
// オブジェクト用の再帰関数
// オブジェクトの各フィールドの値に対して rec を呼び出し、それらを合計する
local object(o) =
sum([rec(o[f]) for f in std.objectFields(o)]),
// 配列用の再帰関数
// 配列の各要素に対して rec を呼び出し、それらを合計する
local array(a) =
sum([rec(e) for e in a]),
// 数値用関数。単にそのまま返せばよい
local number(n) = n,
// その他の値は解析に関係ないので0を返せば良い
local other(e) = 0,
};
parse.rec(e);
例えば次のようなデータに対して呼び出すと 15 を返す。
local data = {
object1: {
array1: ['foo', 'bar', { buz: 1 }],
object2: {
number1: 2,
number2: 3,
},
string1: 'foo',
},
array2: [4, 5],
func1(a, b): a * a + b * b,
};
add_all_numbers(data)
↓
15
オブジェクトを使った相互再帰のほかに、相互再帰する関数それぞれを別ファイルに分けて記述し、相互に import することでも相互再帰を定義可能だが、見通しが悪くなるので特に理由がない限りおすすめはしない。
先ほどの相互再帰する例を、複数ファイルを使ったバージョンに書き換えたものが以下である。
//
// rec.libsonnet
//
local object = (import 'object.libsonnet');
local array = (import 'array.libsonnet');
// 数値用関数。単にそのまま返せばよい
local number(n) = n;
// その他の値は解析に関係ないので0を返せば良い
local other(e) = 0;
// メインの再帰関数
local rec(e) =
local tp = std.type(e);
if tp == 'number' then
number(e)
else if tp == 'object' then
object(e)
else if tp == 'array' then
array(e)
else
other(e);
rec
//
// object.libsonnet
//
local rec = (import 'rec.libsonnet');
local sum(arr) =
std.foldl(function(a, b) a + b, arr, 0);
// オブジェクト用の再帰関数
// オブジェクトの各フィールドの値に対して rec を呼び出し、それらを合計する
local object(o) =
sum([rec(o[f]) for f in std.objectFields(o)]);
object
//
// array.libsonnet
//
local rec = (import 'rec.libsonnet');
local sum(arr) =
std.foldl(function(a, b) a + b, arr, 0);
// 配列用の再帰関数
// 配列の各要素に対して rec を呼び出し、それらを合計する
local array(a) =
sum([rec(e) for e in a]);
array
//
// main.jsonnet
//
// 与えられた値を解析して、その中にある数値だけを全て合計して返す
local add_all_numbers = (import 'rec.libsonnet');
local data = {
object1: {
array1: ['foo', 'bar', { buz: 1 }],
object2: {
number1: 2,
number2: 3,
},
string1: 'foo',
},
array2: [4, 5],
func1(a, b): a * a + b * b,
};
add_all_numbers(data)
繰り返し処理
複雑な繰り返し処理は再帰関数で行う。
まず、いわゆる map / reduce な処理、つまり配列やオブジェクトの変換、また配列の要素を一定の演算で1つの値にまとめる処理、については配列内包表記 (例えば [x * 2 for x in arr] ) や std.map, std.foldl などを使えば良い。
ただし、これらの抽象で実現可能なこと以上に複雑な繰り返し処理は、再帰関数で行う必要がある。
例えば配列 arr の数値を合計する関数 sumOfArray は次のようにかける。
local sumOfArray(arr) =
if arr == [] then
0
else
arr[0] + sumOfArray(arr[1:]);
sumOfArray([1, 2, 3, 4])
↓
10
これはいかにも「再帰っぽい」書き方になっているが、これをもっと「繰り返し処理」っぽく書くこともできる。
local sumOfArray(arr) =
local iter(sum, a) =
if a == [] then
sum
else
iter(sum + a[0], a[1:]);
iter(0, arr);
この書き換えで多少パフォーマンスは良くなる場合もあるが、そこまで大きくは変わらないはずなので、好みの問題だ。ただし、後述する TCO をかける場合は、末尾呼び出しする必要があるので後者の書き方のほうが末尾呼び出しにしやすい。
スタックあふれへの対処
さきほどの sumOfArray に、要素数260程度の配列を入力すると最大スタックフレームの制限にあたってプログラムが実行エラーになる。
local sumOfArray(arr) =
if arr == [] then
0
else
arr[0] + sumOfArray(arr[1:]);
local n = 260;
sumOfArray(std.range(1, n))
↓
$ jsonnet x.jsonnet
RUNTIME ERROR: max stack frames exceeded.
<std>:219:22-42 thunk from <thunk from <function <build>>>
<std>:219:22-42 thunk from <thunk from <function <build>>>
<std>:219:22-42 thunk from <thunk from <function <build>>>
<std>:219:22-42 thunk from <thunk from <function <build>>>
<std>:219:22-42 thunk from <thunk from <function <build>>>
<std>:219:22-42 thunk from <thunk from <function <build>>>
<std>:219:22-42 thunk from <thunk from <function <build>>>
<std>:219:22-42 thunk from <thunk from <function <build>>>
<std>:219:22-42 thunk from <thunk from <function <build>>>
<std>:219:22-42 thunk from <thunk from <function <build>>>
...
x.jsonnet:5:14-33 function <sumOfArray>
x.jsonnet:5:14-33 function <sumOfArray>
x.jsonnet:5:14-33 function <sumOfArray>
x.jsonnet:5:14-33 function <sumOfArray>
x.jsonnet:5:14-33 function <sumOfArray>
x.jsonnet:5:14-33 function <sumOfArray>
x.jsonnet:5:14-33 function <sumOfArray>
x.jsonnet:5:14-33 function <sumOfArray>
x.jsonnet:9:1-28 $
During evaluation
shell returned 1
これは繰り返し処理風に記述しても同じだ。
これを回避するには、 jsonnet コマンドラインオプションに -s <フレーム上限> を追加する
$ jsonnet -s 1000 x.jsonnet
33930
ただし、限度があるので注意が必要。
TCO (末尾呼び出し最適化)
繰り返し処理の場合はTCOをかけることでもスタック溢れをある程度回避できる。
やりかたは末尾呼び出しの箇所に tailstrict というキーワードを補うことだ。
local sumOfArray(arr) =
local iter(sum, a) =
if a == [] then
sum
else
iter(sum + a[0], a[1:]) tailstrict;
iter(0, arr);
local n = 260;
sumOfArray(std.range(1, n))
TCO とは Tail Call Optimization, 末尾呼び出し最適化のこと。末尾呼び出しとは、ある関数内から別の関数(自分自身の再帰呼び出しでも良い)を呼び出す際、呼び出した関数の結果を用いた処理が存在せず、戻ってきた値をそのまま返すような場所での関数呼び出しをいう。
例えば先ほどの sumOfArray 繰り返し処理版は iter の再帰呼び出しが末尾呼び出しになっているが、再帰版の sumOfArray の再帰呼び出しでは arr[0] + sumOfArray(arr[1:]);のように sumOfArray を呼び出した結果を使った処理(+による足し算)が存在するので末尾呼び出しではない。
末尾呼び出しでは戻ってきた値に対して何も計算しない。ということは、呼び出し側の変数などは用済みであるため、関数呼び出しで変数などのコンテキストをコールスタック上に保存する必要がなく、いわゆる goto のように呼び出し先の計算に遷移することができる。これを利用した最適化をTCOと呼ぶ。
ただし、 tailstrict の利用には二つ注意点がある。
1つめはスタックの利用を完全にゼロにはできない、ということだ。 tailstrict を付与すると深い再帰呼び出しにも多少耐えるようになるが、ある一定以上の呼び出しではスタックを使い切ってエラーになる。そのため必要に応じてスタックサイズを拡張する必要がある。
2つめは、関数呼び出しの引数が遅延評価ではなくなるということだ。おそらくこれに起因する問題はあまり起こらないだろうが、Jsonnetの通常の評価戦略とは変わってしまうため想定しない挙動になってしまうことがあるかもしれない。知識としては押さえておこう。
関数のテスト
できるだけJSON+多少の拡張、的な使い方をするのがベストプラクティスではあると思うが、プロジェクトの規模が多くなってくると、関数の利用は避けられない。そして関数を利用しはじめると、その関数に対するテストが必要になる。
ではテストはどうするのか。公式に提供されている方法はないが、今の所はテスト自体も Jsonnet で記述してしまうのが良いのではないかと思う。
たとえばユーティリティ関数を集めた util.libsonnet ファイルのテストを util_test.libsonnet に記述する、という感じだ。
ポイントとしては jsonnet util_test.libsonnet を実行するだけでテストが走るようにしておき、
テストの実行は適切なタスクランナーに任せる、というものだ。
その他
JSON を Jsonnet に変換する
JSON は Jsonnet のサブセットであり、 Jsonnet ファイルとしても読み込める。これを利用して、 Jsonnet 標準のフォーマッタ jsonnetfmt を使うと JSON を Jsonnet に変換できる。
$ cat foo.json
{
"foo": 1,
"bar": "hello world"
}
↓
$ jsonnetfmt foo.json
{
foo: 1,
bar: 'hello world',
}
オブジェクトキーの不要なクォートが省略されていること、ぶら下がりカンマがあることなどから、Jsonnetフォーマットであることが分かる。
また、実用的には、エディタに Jsonnet サポートプラグインなどをインストール済みであれば、ファイル名のサフィックスを json から jsonnet に変更して当該エディタで開き、セーブすることで暗黙的に jsonnetfmt を呼び出して Jsonnet 化することも可能である。
できないこと
(もしかしたら新しいバージョンではできるようになっているかも)
import するファイル名を動的に決定する(文字列を組み立てるなどで)
できない。
次のように記述して、import するファイルを動的に切り替えたいと思うかもしれないけど、これは無理。
次の例では外部変数 TARGET の値を使っているが、外部変数の値でなくても同様。
local target = std.extVar('TARGET');
local vars = (import target + '-vars.libsonnet');
{
field1: vars.var1,
}
↓
STATIC ERROR: dynamic-import-incorrect.jsonnet:2:22-48: computed imports are not allowed.
かわりの方法としては、次のようにファイル名部分はベタ書きにして、それを if 文 で分岐させて読ませる、などがある。
// dynamic-import.jsonnet
local target = std.extVar('TARGET');
local vars = if target == 'prod' then
(import 'prod-vars.libsonnet')
else if target == 'staging' then
(import 'staging-vars.libsonnet')
else
(import 'devel-vars.libsonnet');
{
field1: vars.var1,
}
// prod-vars.libsonnet
{
var1: 'this is production',
}
// staging-vars.libsonnet
{
var1: 'this is staging',
}
↓
$ jsonnet -V TARGET=prod dynamic-import.jsonnet
{
"field1": "this is production"
}
$ jsonnet -V TARGET=staging dynamic-import.jsonnet
{
"field1": "this is staging"
}