関数を作って長いプログラムを整理する

将来、長いプログラムを書いていくのに向けて、関数をうまく使ってプログラムを整理整頓する方法を学びます。

戻り値・返り値 (return value) と副作用 (side effect)

既に何度となく使ってきた関数ですが、その使い方は2種類に分けることができます。戻り値と副作用です。 次に示す例では両方の使い方が登場します。 12個の円を丸く円周上に描くプログラムで、三角関数を利用して円の位置を決めているものです。

function setup(){
createCanvas(200, 200);
background(192);
for(let i = 0; i < 12; i++){
const theta = TWO_PI * i / 12; // TWO_PI は円周率の2倍
const x = 100 + cos(theta) * 50; // 関数 cos の戻り値を使用
const y = 100 + sin(theta) * 50; // 関数 sin の戻り値を使用
ellipse(x, y, 10); // 関数 ellipse の副作用で円が描画される
}
}
実行結果

関数 setup() を実行中だったのが、関数 cos() に移り、その実行が終わったら setup() の続きを実行するために戻ってくるのです。 持って帰ってきた値のことを 戻り値 あるいは 返り値 と呼びます。 戻り値は変数に代入したり、計算に使ったりすることができます。

多くの数学関数が同様の使い方になります。他にこれまで出てきた関数の中で戻り値を使っていたものをいくつか挙げます:

7行目の関数 ellipse() の呼び出しでも同じように処理が移りますが、戻り値はありません。 ellipse() に行って帰ってくるまでの間に画面に円が描画されるだけです。 このような、計算結果以外に関数が行う処理のことを 副作用 と呼びます。 p5.js では描画関数のほとんどがこちらのパターンになります。 ほとんどがこちらなのに「副」扱いするのはおかしな感じがするかもしれませんが、あくまで関数の主役は計算というところから来ています。

関数を作る(定義する)

続いて、オリジナルの関数を作る方法を見ていきます。厳密には変数と同様に「関数を定義する」と言います。

既に setup()draw() といった特別な関数、 mousePressed()keyTyped() といったイベント関数など、特定の役割を持つ関数を定義してきました。 ここでは自分で定義した関数を使ってプログラムを整理整頓する方法を学びます。

(1) オリジナルの描画関数を作る

まずは、用意されている描画関数を組み合わせて、少し複雑な図形を描画する関数を作ります。 描画する位置や大きさ等を変えられると便利なので引数としてデータを渡せるようにすることにします。 引数のある関数は次のように書きます。

function 名前(引数1, 引数2, ...){
// 引数を使って、中身を書く
}

自分で定義した関数を使っている例を示します。

function setup(){
createCanvas(200, 200);
crossmark(50, 50, 150, 150); // 作った関数を呼び出す
}
function crossmark(x1, y1, x2, y2){
line(x1, y1, x2, y2);
line(x2, y1, x1, y2);
}
実行結果

関数の中で関数を定義することもできたりしますが、慣れないうちはスコープなどがややこしくなるので、今のところは外で(setup や draw などと同列に)定義するものだと思っておきましょう。

このプログラムの実行の様子は以下のようになります。

  1. まず function setup() の実行が開始される
  2. 関数呼び出しによって function crossmark() の実行が開始される。呼び出し元で与えた値が順番通り x1 = 50, y1 = 50, x2 = 150, y2 = 150 と代入された状態で始まる。
  3. function crossmark() の実行が終了すると、呼び出し元の位置に戻り、続きから setup() の実行が行われる。

ブラウザのデバッグ機能 を使って動きを観察してみるとより様子がつかみやすいと思います。

描画関数を作るコツ1: push と pop

それではさっそくオリジナルの描画関数を作ってみましょう、と行きたいところですが、その前に使いやすい描画関数を作るコツを二つ紹介したいと思います。

一つ目のコツは「関数の中で塗りつぶしや線のスタイル(色や太さなど)を変えたらその関数内で元に戻しておく」ことです。 描画関数の中で fill(), stroke(), strokeWeight() などを使って描画スタイルを変えると、 その後に呼び出される描画関数にも影響が及んでしまいます。

次の ProgTouch を見てください。禁止マークを描く関数を作っているのですが、 関数の中で描画スタイルを設定したままにしてしまったために、 関数呼び出しの後で描画した四角形の線の太さが変わってしまっています。

ステップ 1 / 9
四角に重ねる禁止マークを描くぞ。
function setup(){
createCanvas(200, 200);
noStroke();
fill(0, 0, 255);
rect(20, 20, 60, 60);
rect(120, 20, 60, 60);
}
実行結果

ngmark() を利用するときには「関数呼び出しによって禁止マークが描画されること」は想定しますが、「関数呼び出しによって線の太さが変わること」は想定しないでしょう。 こういうのは呼び出し側が想定していない悪い副作用がある関数の例になります。 自分で関数を作るときにはこういった悪い副作用が起きないように後片付けをしておくことが大切です。

p5.js には簡単に後片付けを行うことができる push(), pop() というセットで使う関数が用意されています。 関数の始めで push() とすることでその時点でのスタイルをまとめて覚えておき、 最後に pop() とすることで覚えておいたスタイルに戻しています。 こうすることで悪い副作用がない使いやすい描画関数を作ることができます。

描画関数を作るコツ2: beginShape / endShape

続いて、四角形や円を組み合わせるだけでは描きにくい複雑な形状を描画するコツです。 p5.js には、点を順番につないでいく子どもの遊び「点つなぎ」のように図形を描画する関数 beginShape()endShape() が用意されています。 2つの関数は次の例のようにセットで使います。星マークを描く関数を作っています。

beginShape(); で点つなぎを開始し、必要なだけ vertex(x, y) てつなぐ点を追加し、最後に endShape(CLOSE); で点つなぎを終わります。 この例では最後の点から最初の点をつないで図形を閉じています (CLOSE)。閉じたくない場合は単に endShape(); と書きます。 塗りつぶしを設定すると、線でつないだ図形の中が塗りつぶされます。

ステップ 1 / 7
点つなぎで星を描いてみよう。
function setup(){
createCanvas(200, 200);
background(192);
noFill();
stroke(0);
beginShape(); // 点つなぎを始める
// 間に点を追加していく
endShape(CLOSE); // 点つなぎを終わる
}
実行結果

繰り返しとあわせて使うことで真価を発揮するところもあります。以下は星を繰り返しで描画する関数 star(cx, cy, r) を作っている例です。 vertex(x, y) を5回しかしていないので上の例とは点つなぎの仕方も違います。どう違うか考えてみてください。 ヒント:描かれる線が異なり、下の例では交差しています。

function setup(){
createCanvas(200, 200);
background(192);
star(50, 50, 50); // 星マークを描く関数を呼び出す
noStroke();
star(150, 150, 50); // 点つなぎだけど線なしもOK
}
function star(cx, cy, r){
beginShape(); // 点つなぎを始める
for(let i = 0; i < 5; i++){
const theta = TWO_PI * i * 2 / 5 - HALF_PI;
const x = cx + cos(theta) * r;
const y = cy + sin(theta) * r;
vertex(x, y); // 次につなぐ点を1つ増やす
}
endShape(CLOSE); // 点つなぎを終わる
}
実行結果

(2) 結果を戻す関数を作る

結果を戻す関数を作るには、関数内の処理が終わるところに return; と書きます。 次に示す例は、西暦y年がうるう年かどうかを判定し、その結果を true か false で戻す function isLeapYear(y) を作り、それを使用しているところです。

function setup(){
for(let i = 2000; i <= 2100; i++){
if(isLeapYear(i)){
console.log(i + "年はうるう年です");
}
else{
console.log(i + "年はうるう年ではありません");
}
}
}
function isLeapYear(y){
return (y % 4 == 0) && (y % 100 != 0) || (y % 400 == 0);
}

3行目の isLeapYear(i) が実行される様子は以下のようになります。

  1. 呼び出し元 setup() で変数 i の値を読みだす(たとえば最初は 2000)。
  2. 関数呼び出しによって isLeapYear(2000) の実行が開始される。
    引数として与えた 2000 が y に代入された状態で始まる。
  3. (y % 4 == 0) && (y % 100 != 0) || (y % 400 == 0) の計算が行われる(結果は true か false)。
    たとえば y が 2000 の場合、4 でも 100 でも 400 でも割り切れるので最終的に true になる。
  4. 3 の結果が return されて isLeapYear(2000) の実行が終了し、呼び出し元の位置に戻る。
  5. 呼び出し元では isLeapYear(i) の部分が結果(true または false)に置き換えられ、if 文の条件として評価される。

うるう年かを判定する条件の計算部分は3つの条件の組み合わせになっています。 「4で割り切れる年はうるう年」なんですがいくつか例外があります。 丸かっこは見やすくするために書いているもので y % 4 == 0 && y % 100 != 0 || y % 400 == 0 と書いてもOKです。

それでもやっぱり isLeapYear(i)isLeapYear(y) が出てきてわけわからん!という人向けの説明を試みます。

という2つの文章があるようなものです。2つは別の文なので違う文字を使っていても問題はないですよね?

日付関連の関数をもっと作ってみよう

今回の演習問題ではカレンダーの描画に挑戦します。カレンダーを正しく描画するにはある月は何日まであるのか、ある日付が何曜日なのかといった計算が必要になります。 そういった計算を関数にまとめておくとカレンダー描画のプログラムがすっきりします。

まずは y年m月が何日まであるかを返す関数 daysInMonth(y, m) を見てみましょう。 2月はうるう年かどうかで日数が変わるので isLeapYear(y) を使って条件分岐します。

function daysInMonth(y, m){
if(m == 2){
return isLeapYear(y) ? 29 : 28; // 「a ? b : c」と書く三項演算子を使っています
}
else if(m == 4 || m == 6 || m == 9 || m == 11){
return 30;
}
else{
return 31;
}
}

この daysInMonth(y, m) では途中に return が出てきます。return するとたとえ途中であってもその関数の実行は終了して呼び出し元に戻ります。

(補足説明)条件分岐をコンパクトに書くことができる「三項演算子」を使っています。 a ? b : c の結果は、a が true の場合には b、false の場合には c になります。 a, b, c がどれも短いときには if 文を使うよりもわかりやすくなります。

次は y年m月d日が一年のうちで何日目かを返す関数 dayOfYear(y, m, d) です。 今作ったばかりの daysInMonth(y, m) を使い、各月の日数を繰り返しで足し合わせていくことで日数を数えています。

function dayOfYear(y, m, d){
let count = 0;
for(let i = 1; i < m; i++){
count += daysInMonth(y, i);
}
return count + d;
}

このように、複雑なプログラムを作るときには処理を意味合い毎に関数としてまとめて書くことでプログラムをわかりやすくすることができます。

(3) 配列を受け取る関数を作る

関数は配列を引数として受け取ることもできます。 たとえば、配列の合計を求める計算を function sum(arr) としてまとめると以下のようになります。

function setup(){
let scores = [88, 80, 76];
let s = sum(scores); // 配列を引数として渡す
}
function sum(arr){ // 配列を引数として受け取って
let n = 0;
for(let i = 0; i < arr.length; i++){
n += arr[i];
}
return n; // 結果を戻す
}

合計の計算と同様に、平均・最大値・最小値なども関数にまとめることができて、プログラムをすっきりさせることができます。 プログラムを機能ごとに関数に分けていくと1つ1つの関数はシンプルになって理解しやすくなります。 ひとつの関数を概ね20行以内くらいに、どんなに長くなっても一画面以内に収めるように分けていくとよいでしょう。

これまでのプログラムで「ここは関数にまとめるとすっきりしそう」というところを見つけて、すっきりさせる練習をしてみてください。