この記事の詳しい内容には毎日新聞の「くり返しの中にくり返し」ページから有料記事に進むことで読めます。
目次
概要
かめのロボットを動かして線を引きます。ロボットには次の4つの命令を出すことができます。
命令:
- 進む(n) nマス分進む
- 左回り() その場で左を向く
- 右回り() その場で右を向く
- m回繰り返す 囲んだ命令をm回繰り返す
さてここで問題です。次のフローチャートの命令を出すと、ロボットはどんな線を引くでしょう?
論理を考える
繰り返しの繰り返し
フローチャートを日本語の疑似コードで表すと、
2回繰り返す // 外側の繰り返し
進む(2)
4回繰り返す // 内側の繰り返し
進む(1)
左回り()
繰り返しここまで
繰り返しここまで
となります。
繰り返しの繰り返しには、外側の繰り返しと内側の繰り返しがあり、外から見ると2重の繰り返しに見えます。繰り返しの繰り返しをばらすと、次のように羅列できます。内側の繰り返しは外側の繰り返し分だけ繰り返されるので、当然実行回数が多くなります。
進む(2) // 外側の繰り返しの1回め
進む(1) // 内側の繰り返しの1回め
左回り()
進む(1) // 内側の繰り返しの2回め
左回り()
進む(1) // 内側の繰り返しの3回め
左回り()
進む(1) // 内側の繰り返しの4回め
左回り()
進む(2) // 外側の繰り返しの2回め
進む(1) // 内側の繰り返しの1回め(通算で5回め)
左回り()
進む(1) // 内側の繰り返しの2回め(通算で6回め)
左回り()
進む(1) // 内側の繰り返しの3回め(通算で7回め)
左回り()
進む(1) // 内側の繰り返しの4回め(通算で8回め)
左回り()
繰り返しの繰り返しはJavaScriptではforループやwhileループで表現できます。たとえば次のコードでは、2重のforループから九九の表が作成できます。
function setup() {
createCanvas(400, 400);
textSize(20);
textAlign(CENTER);
rectMode(CENTER);
background(220);
// 九九の表
for (let i = 1; i < 10; i++) {
for (let j = 1; j < 10; j++) {
rect(40 * i, 40 * j - 5, 40, 40);
text(i * j, 40 * i, 40 * j);
}
}
}
かめのロボットの移動には、sketch.jsとKameクラスを使用します。
かめのロボットを動かして線を引く
では、2重のforループを使ってかめのロボットに線を引かせましょう。プログラムのコードは、「13:かめを動かそう」のものを使用します。
const rows = 5; // 横向きの行数
const columns = 5; // 縦向きの列数
const gutter = 0; // 矩形間の空き
const w = 50; // 矩形の幅
const h = 50; // 矩形の高さ
// 矩形の開始位置をずらす量 => (offsetX,offsetY)から始まる
const offsetX = 60;
const offsetY = 60;
let kame, kameImages;
function preload() {
// かめの右向き、下向き、左向き、上向きの画像を読み込み、
// そのイメージを右、下、左、上の順に配列に入れる
const right = loadImage('images/right.png');
const down = loadImage('images/down.png');
const left = loadImage('images/left.png');
const up = loadImage('images/up.png');
kameImages = [right, down, left, up];
}
function setup() {
createCanvas(400, 400);
// イメージの基点をイメージセンターにするモード
imageMode(CENTER);
// Kameインスタンスを作成
kame = new Kame(60, 210, kameImages);
}
function mousePressed() {
for (let i = 0; i < 2; i++) {
kame.goForward(2);
for (let j = 0; j < 4; j++) {
kame.goForward(1);
kame.turnToLeft();
}
}
}
function draw() {
background(220);
// グリッドの線の太さ
strokeWeight(1);
// 矩形を格子状に並べて描く
for (let c = 0; c < columns; c++) {
for (let r = 0; r < rows; r++)
// 行数(rows) x 列数(columns)に矩形を描く
rect(offsetX + c * (gutter + w), offsetY + r * (gutter + h), w, h);
}
// カメが引く線の太さ
strokeWeight(7);
kame.drawLine() // カメが線を引く
kame.display(); // カメを描画
}
// かめのロボットのクラス
class Kame {
constructor(x, y, imgs) {
this.x = x;
this.y = y;
// 向いている向きの配列 [右、下、左、上]の順番
this.directions = ['right', 'down', 'left', 'up'];
// 現在の方向を数値で表す
this.currentDirection = 0;
// 現在の方向を文字列で表す
this.direction = this.directions[this.currentDirection];
// カメのイメージ
this.images = imgs;
// 向きによってイメージを変える
this.image = this.images[this.currentDirection];
// カメの1歩は50ピクセル = グリッドの正方形の一辺の長さと同じ
this.step = 50;
// 移動する座標を保持する配列
this.prevousXs = [];
this.prevousYs = [];
// 最初の位置を追加する
this.prevousXs.push(this.x);
this.prevousYs.push(this.y);
}
// カメのイメージを描画する
display() {
image(this.image, this.x, this.y);
}
// 右を向く
turnToRight() {
this.currentDirection++;
// 循環させる
if (this.currentDirection >= 4) {
this.currentDirection = 0;
}
// カメの新しい向き
this.direction = this.directions[this.currentDirection];
// カメの新しいイメージ
this.image = this.images[this.currentDirection];
}
// 左を向く
turnToLeft() {
this.currentDirection--;
if (this.currentDirection <= -1) {
this.currentDirection = 3;
}
this.direction = this.directions[this.currentDirection];
this.image = this.images[this.currentDirection];
}
// stepsマス分だけ前進
goForward(steps) {
// 前進と言っても、今の向きによって方向は変わる
// 今の向きが右なら
if (this.direction === 'right') {
// 右にstepsマス分(50 * steps ピクセル)移動
this.x += this.step * steps;
}
else if (this.direction === 'down') {
this.y += this.step * steps;
}
else if (this.direction === 'left') {
this.x -= this.step * steps;
}
else if (this.direction === 'up') {
this.y -= this.step * steps;
}
// 新しいxy位置を配列に追加
this.prevousXs.push(this.x);
this.prevousYs.push(this.y);
}
// 通ってきた位置を結ぶ線を引く
drawLine() {
for (let i = 0; i < this.prevousXs.length; i++) {
line(this.prevousXs[i], this.prevousYs[i], this.prevousXs[i - 1], this.prevousYs[i - 1]);
}
}
}
画面のマウスプレスすると、かめのロボットが線を引きます。下図はその結果で、問題の答えです。
画面のマウスプレスで呼び出されるp5.jsのmousePressed()関数では、次の2重のforループを使用しています。
// 2回繰り返す
for (let i = 0; i < 2; i++) {
kame.goForward(2);
// 4回繰り返す
for (let j = 0; j < 4; j++) {
kame.goForward(1);
kame.turnToLeft();
}
}
この2重のforループは次のコードの実行と同じです。
kame.goForward(2);
kame.goForward(1);
kame.turnToLeft();
kame.goForward(1);
kame.turnToLeft();
kame.goForward(1);
kame.turnToLeft();
kame.goForward(1);
kame.turnToLeft();
kame.goForward(2);
kame.goForward(1);
kame.turnToLeft();
kame.goForward(1);
kame.turnToLeft();
kame.goForward(1);
kame.turnToLeft();
kame.goForward(1);
kame.turnToLeft();
ただし線の描画は一瞬で終わり、かめが線を引いているようには見えません。
指定時間だけ待つ
かめの移動と線の描画が一瞬で終わるのは、2重のforループ(または上記のコードの羅列)が、まさに目にも止まらぬ速さで実行されるからです。したがって、実行コードの間に、間を作るコードが挿入できれば解決できることになります。
steTimeout()の入れ子
解決策の1つとして、 window.setTimeout()関数の入れ子があります。setTimeout()を使って指定時間の経過後に1つコードを実行し、そのとき次のsetTimeout()を使ってまた指定時間の経過後に次のコードを実行するという方法です。
この方法は次のような関数で実現できます。
function kameAction(num) {
let timerID = window.setTimeout(() => {
kame.goForward(2);
window.clearTimeout(timerID);
timerID = window.setTimeout(() => {
kame.goForward(1);
kame.turnToLeft();
window.clearTimeout(timerID);
timerID = window.setTimeout(() => {
kame.goForward(1);
kame.turnToLeft();
window.clearTimeout(timerID);
timerID = window.setTimeout(() => {
kame.goForward(1);
kame.turnToLeft();
window.clearTimeout(timerID);
timerID = window.setTimeout(() => {
kame.goForward(1);
kame.turnToLeft();
window.clearTimeout(timerID);
if (num === 1) {
kameAction(2);
}
}, ms);
}, ms);
}, ms);
}, ms);
}, ms);
}
このkameAction()関数は、繰り返し全体の半分を実行します。kameAction()関数を呼び出すときには、引数に1を指定します。すると、setTimeout()の連鎖の最後でkameAction(2)が呼び出され、残りの半分が実行されます。
setTimeout()の入れ子(setTimeout()のコールバック関数でさらにsetTimeout()を呼び出すこと)は、ご覧のように、決して読みやすいコートではありません。修正したくなった場合、どこに手を入れてよいかすぐには分かりません。
Promiseの使用
setTimeout()の入れ子は、その読みにくさからコールバック地獄などと呼ばれます。この地獄を解消する方法にPromiseオブジェクトの使用があります。
Promiseは非同期処理に使用される、比較的新しいオブジェクトです。
次の関数を定義します。この関数は「引数に指定されたmsミリ秒たったタイミングで解決する」Promiseオブジェクトを返します。
function waitUntill(ms) {
const promise = new Promise(executor);
// Promiseのコンストラクタに渡すexecutor()関数
function executor(resolve, reject) {
if (isNaN(ms)) {
reject('数値でない');
}
else {
// ms秒まったら解決
window.setTimeout(() => {
resolve('成功');
}, ms);
}
}
// というPromiseオブジェクトを返す
return promise;
}
そして次のkameAction()関数を定義します。この関数は、async functionで宣言された、非同期で動作する非同期関数で、頭にawaitのついたawait waitUntill(1000)がPromiseオブジェクトを返すのを待ちます(次の行の実行に移りません)。これにより、1000ミリ秒待つことができます。
// かめを動かす
async function kameAction() {
// 2回繰り返す
for (let i = 0; i < 2; i++) {
kame.goForward(2);
await waitUntill(1000); // 1000ミリ秒待つ
// 4回繰り返す
for (let j = 0; j < 4; j++) {
kame.goForward(1); // かめを2マス前進
await waitUntill(1000); // 1000ミリ秒待つ
kame.turnToLeft(); // かめを左に回転
await waitUntill(1000); // 1000ミリ秒待つ
}
}
}
kameAction()関数はマウスプレス時に呼び出します。
function mousePressed() {
kameAction();
}
下はこの実行画面です。画面のマウスプレスでかめのロボットが移動し線を引きます。