[今日のPiet]GridPietGenerator入門編2(カウンタ付きのループを作る)

('21/8/21 追加修正)プログラムにpop命令を追加・解説

どもです。

前回から筆者は、手を抜く継続して投稿するために、

GridPietGeneratorの 入門編、つまり、GridPietGeneratorの遊び方講座を開催しています。

今回は、いわゆるforループ的なものの作り方を紹介します。

ループ処理を作ろう

さて、ループ処理の基本は次のような処理になります。

push3 dup mul  #1
:loop
dup if::end
dup outn       #2
push1 sub
goto:loop
:end
pop end

出力結果

今回も解説が長くなりそうなので、先にPiet画像だけお見せしておきます。

f:id:y-mos:20210821161953p:plain
 

こんな感じの画像が出力されました。

npietデバッグすると次のようになります。

f:id:y-mos:20210821162026p:plain
 

どうやら出力画像は上段・中段・下段に分かれているようです。

まず上段、左上から出発して、中段に降りてきます。

続いて中段では、∞マーク(横倒し8の字)のルートで、延々ループしています。

そして、ループを抜けると下段に向かい、そこで処理終了、

となっているようです。

 

なお、このプログラムは、9、8、・・・、1を順に出力するプログラムです。

最終的には「987654321」が出力されます。

では解説に入るよ!

説明の前に、ひとつ文法事項を。

1行コメント

#は1行コメントの先頭を表す記号です。

プログラム中に#が現れると、それ以降行末まで無視されます。

注意点としては、#の直前に命令などがある場合は、必ず半角スペース、タブなどで命令と#とを区切ることです。

たとえば、

goto:loop #区切りのあるよいコメント

は命令「goto:loop」と正しく解釈されますが、

goto:loop#区切りのない悪いコメント

では「goto:loop#区切りのない悪いコメント」が一つの命令と解釈されて、

「そんな命令わかりませぬ」とエラーを吐きます。1

なお、C言語式のブロックコメント(/**/)はサポートされていません。

 

今回のケースだと、1行めの「#1」と、4行めの「#2」は

コメントとして無視されます。

解説

では解説に入ります。

まず、コメント「#1」の部分では、ループカウンタの初期値をメモリにプッシュしています。

C++風な書き方でいうと、

const int N=10;
for(int i=0; i<N; i++) { ...  }

の定数Nに相当するものです。

スタック

さて、ここでメモリへの「プッシュ」とは何かについて説明します。

Pietのメモリは「スタック」という構造を持っています。

スタックのイメージは、「底のついた筒状の入れ物」です。

この入れ物の中に値を入れることで、値を記憶します。

f:id:y-mos:20210818205509p:plainf:id:y-mos:20210818205532p:plainf:id:y-mos:20210818205609p:plainf:id:y-mos:20210818205626p:plain → ...

(何故か内容物に合わせて入れ物の高さが変わっていますが、気にしない方向で。)

値はいくつでも入れることができます。

このように、スタックに値を入れる操作を「プッシュ」と言います。

 

また、値は取り出すこともできます。

取り出された値は忘れ去られてしまい、基本的には戻ってきません。

ここで取り出しについて注意点です。値はこの筒にピッタリはまる大きさなので、

値は常にからしか取り出せません。

f:id:y-mos:20210818205626p:plainf:id:y-mos:20210818205609p:plainf:id:y-mos:20210818205532p:plainf:id:y-mos:20210818205509p:plain → ...

(何故か内容物に合わせて入れ物の高さが(ry)

このように、スタックから値を出す操作を「ポップ」と言います。

 

足し算、引き算といった四則演算や、比較演算、条件分岐の判定などで、

メモリ内の値にアクセスしたい時があります。

そのときは、一度値をスタックの外に出さなければなりません。

正確にいうと、スタックの取出口付近に使う値をまとめておいて、

f:id:y-mos:20210818210924p:plain

演算するときに必要な値が上から順に自動的に取り出されるのです。

f:id:y-mos:20210818210954p:plain

計算結果がある場合は、その結果は再びスタックに入れられます。

f:id:y-mos:20210818211008p:plain
たとえば掛け算の場合

 

これが「スタック」や、スタック上での演算のイメージです。

なんとなくスタック上での演算の仕方が伝わったのではないでしょうか。

特に、使う値をスタックの取出口に集めなければならないという準備の大変さが・・・。

(解説の続き)

さて、ループの初期値Nをプッシュします。

今回はN=9としてみますが、直接9をプッシュするのではなく、

3\times{3}=9の計算の結果として、9をプッシュしましょう。

手順としては、次のようになります。

  1. 3をプッシュ
  2. 先ほどプッシュした3を複製(コピー)
  3. 2つの「3」を掛け算する

 

先ほどのプログラムの「#1」の行では、上記の手続きを順に行っています。

これまでの説明を踏まえると、最初の「push3」は簡単です。

スタックに整数3をプッシュする操作にあたります。

初め、スタックは空の状態ですので、この操作後はスタックが次の状態になっています。

f:id:y-mos:20210818211341p:plain

次に「dup」命令を実行します。

dup命令は、値の複製(duplicate)を実行します。

つまり、スタック最上部の値をコピーしたものを、スタックに入れるという操作をおこないます。

先ほどプッシュした3のコピーがプッシュされるので、スタックは次のようになります。

f:id:y-mos:20210818211957p:plain
 

最後に「mul」命令です。

mul命令は、乗算命令です。

つまり、スタック最上部の2つの値をポップして、最上部にあった値と、その下にあった値の積を計算し、

結果をスタックにプッシュします。

このように、「最上部の2つの値に着目する」タイプの命令は結構多いので、

イメージをつけておくと後々楽です。

今の例だと、スタックの最上部には値がちょうど2つあるので、

それら2つがポップされ、それらの積3\times{3}=9がプッシュされます。

f:id:y-mos:20210818212620p:plain
 

以上で、定数Nのプッシュが完了しました。

 

なお、今プッシュした値は、今後イテレータとして使用します。

イテレータとは、繰り返し回数を管理する変数のことで、

先ほどのC++風プログラムでいう変数iのことです。

 

したがって、今後スタックの状態を表すときは、

スタックに「9」という「数値」が入っているという表現ではなく、

スタックに「9」という値で初期化された「変数i」が入っていることを表すため

次のような表現に切り替えます。

f:id:y-mos:20210818213544p:plain
 

(解説の続き)ループ部

いよいよループに入ります。

2行めにはラベル:loopがあり、6行めにはgoto命令goto:loopがあります。

したがって、2行めと6行めの間でループしていることがわかります。

このように、基本的には無限ループと同様、

:loopgoto:loopで囲んだ領域がループすると考えるといいかと思います。2

 

さて、基本と違うのは3行めです。

ここが無限ループを回避するポイントとなります。

まず3行めではdup命令を実行します。

これによってスタック内ではイテレータiが複製されます。

f:id:y-mos:20210818213819p:plain
 

ここで今回の主役、if命令の登場です。

if命令は条件分岐の命令です。

if命令はgotoと同じようにラベル名を引き連れますが、

gotoとは異なり、2つのラベルを引き連れます。

たとえば、ラベル:label1とラベル:label2がある場合は、

if:label1:label2のように書きます。

例によって、コロン:の前後には、半角スペースなどの区切りを入れてはいけません。

 

if命令の処理を説明します。

まず、スタックの最上部から値をひとつポップします。

(つまり、値がひとつ消えます。

そして、

  • その値が0でないなら、1つめのラベルにジャンプします。
  • その値が0なら、2つめのラベルにジャンプします。

 

また、if命令が引き連れるラベルは、片方のみなら省略可能です。

この場合、省略されたラベルはif命令の直後にあるものとみなされます。

 

以上を踏まえると、3行めのif命令はif::endとなっているので、

スタック最上部の値をポップしたときに、

  • その値が0でないなら、1つめのラベルにジャンプします。
    • ただし、1つめのラベルは省略されているので、if命令の直後にラベルがあるとみなされます。
    • したがって、この場合に次に実行される命令は、4行めのdup命令です。
  • その値が0なら、2つめのラベル:endにジャンプします。
    • したがって、この場合に次に実行される命令は、8行めのend命令です。3

いずれのラベルにジャンプしたとしても、ジャンプ後のスタックは次の状態になっています。

f:id:y-mos:20210818213544p:plain
 

先ほど条件分岐の判定に使ったピンク色のイテレータiが消え、

スタックには一つだけイテレータが残されています。

3行めの最初にdup命令でイテレータiをコピーしておいたのは、

if命令の実行によって、イテレータiの値が完全に失われるのを防ぐためです。

 

以上からわかるように、この部分では、

イテレータiが0かそうでないかが、ループ脱出の成否を分けることとなります。

となると、今回作りたいカウンタ付きループを実装するには、

ループを通るたびにイテレータiから1ずつ引いていけば、

いずれi=0となるからループが終了するだろう

ということが想像できます。

このような1を引く操作を「デクリメント」と言ったりします。

(解説の続き)デクリメント処理

このデクリメント処理は5行めで実行しています。

ここではまず4行めの説明は飛ばします。

飛ばしはしますが、実は4行めの有無にかかわらず、

5行めの先頭のスタックは、先ほどのif命令実行直後と同じ状態になっています。

f:id:y-mos:20210818213544p:plain
 

 

5行めでは、まず1をプッシュしています。

f:id:y-mos:20210818220033p:plain
 

次にsub命令を実行します。

sub命令は、減算命令、平たく言えば引き算です。

つまり、スタック最上部の2つの値をポップして、最上部にあった値、その下にあった値から引いて、

結果をスタックにプッシュします。

 

順番に気をつけてください。絵で説明すると、スタックが次の状態のとき:

f:id:y-mos:20210818220328p:plain
 

このときにsub命令を実行すると、計算結果はa-bです。

f:id:y-mos:20210818220402p:plain
 

なお、また個別に説明しますが、四則演算のなかには引き算のように

順番に気をつけなければならない演算があります。

そのような演算は、全て引き算と同じ順序で計算されます。

(たとえば、a/ba~\textrm{mod}~bなど)

 

これを踏まえて、プログラムに戻ります。いまスタックは最上部から、

1iの順に格納されているので、計算結果はi-1となります。

f:id:y-mos:20210818220737p:plain
 

これで、スタックにi-1が単独で保存された状態になりました。

このi-1を更新されたイテレータiとみなします。

f:id:y-mos:20210818220942p:plain
 

これでデクリメントの完了です。

(解説の続き)ループ先頭へ

先ほどのスタックの状態は、2行めのラベル:loopの位置におけるスタックの状態

f:id:y-mos:20210818213544p:plain
 

と同一です。したがって、このまま2行めのラベル:loopに戻れば、

イテレータiの値が1だけ減った状態から、再びループ内の処理を繰り返すことになります。

 

これを繰り返せば、イテレータiの値は、いつか0になります。

そうすると、ループから抜けて、end命令により、処理終了となります。

(解説の続き)終了処理('21/8/21追記)

さて、イテレータiの値が0となると、ループから抜けてラベル:endに到達します。

このとき、スタックの状態は次のようになっています。

f:id:y-mos:20210821164126p:plain
 

if命令実行前にイテレータをコピーしておいたため、

コピーの原本のみが残った状態になっているのです。

 

このままプログラムを終了しても機能的には全く問題はないのですが、

スタックに積み残しをしたままというのも少々気にかかります。

また、将来的には、Pietでプログラムを組む際に、

スタックの状態を正しく把握することが必要になります。

このような積み残しを残すことがバグにつながる可能性もあるので、

積み残しは消す習慣をつけておくとよいと思います。

そこで今回は、スタックに残った0も消すことにします。

 

このような時はpop命令を使います。

pop命令は冒頭で説明した「ポップ」操作を行う命令です。

つまり、スタック最上部の値を1つだけ取り出します。

取り出すだけです。

 

取り出された値に対して何の操作もされませんし、

その値によって処理に変化も生じません。

そして、取り出された値は二度と戻ってきませんので、注意してください。

 

pop命令により、スタックは空の状態になり、

気兼ねなくプログラムを終了することができます。

(解説の続き)end命令

・・・まあ、わかるよね(笑)

end命令を実行すると、そこで処理終了です。

以上!

(解説の続き)4行めの処理

では最後に、飛ばしていた4行めの処理ですが、

ここでは、現在のイテレータiの値を出力しています。

 

4行めの先頭では、スタックは次の状態になっています。

f:id:y-mos:20210818213544p:plain
 

まずdup命令で、イテレータiを複製します。

f:id:y-mos:20210818213819p:plain
 

次にoutn命令を実行します。

outn命令は、スタック最上部の値をポップし、ポップした値を数値として出力します。

したがって、コンソール画面の出力には、数値iが出力されます。

 

outn命令は実行時に値をひとつポップするので、

実行後のスタックはつぎの状態になります。

f:id:y-mos:20210818213544p:plain
 

4行めの先頭でdup命令を実施したのも、

イテレータの値iが失われないようにするためです。

 

なお、今回は何も考えずに続けて出力を行うため、

処理終了時には、「987654321」のように、数値が繋がった状態で出力されます。

 

以上で解説は終了です。

最後に

わかった、わかったよ。

このくらいの規模のプログラム解説でも、

書くのに相当時間がかかるってことだ。(約2時間)

ちょっと甘くみてましたね。。。

新しい命令の解説が減るとまだマシなのかもしれませんが。

 

とはいえ、せっかくじっくり説明する機会なので、

今後も(少なくとも全ての命令を書き尽くすまでは)

続けていくつもりでござります。

 

次回は、今回初出の命令について、まとめます。

では。


  1. #以下をラベル名の一部と解釈することもできません。#はラベル名に使えないからです。

  2. 処理が複雑になるにつれ、応用パターンも出てきますが、それは追い追い。

  3. 「このルートに、はまってしまえばループ終了」というのが、なんとなく予想つきますね。