ファイル分割の方法いろいろ
長いプログラムコードって基本的に見たくないですよね。
昔の自分が勢いで書いた1000行超えのソースコードを、数ヶ月後に読まなければいけなくなった時、あなたはどうしますか。
「読みたくねえええええ!」と心の中では大声でムンクの雄叫びが響き渡ることでしょう。
一度に読むソースコードの量は少ないに越したことはありません。
となると、ファイルを役割ごとに分割して、1ファイルあたりのコード行数を減らそうと考えるのは自然な発想でしょう。
コードを分割するときにまず思い浮かぶ方法は「長いソースコードを上から区切ってファイル分割する」という方法です。
長いソースコードから、「データを保管している部分」や「関数をまとめている部分」などでファイルを分割して、呼び出し元のhtmlファイルから分割後のファイルを指定して呼び出していきます。
例えばこんな画像のように!
この方法はJavaScriptでは昔から使われてきた方法です。htmlから複数のJavaScriptファイルを呼んでいますが、内部的には一つの大きなファイルとして処理されます。
一つのファイルをハサミ✂で切って分割したようなファイル分割法ですね。
このようなファイル分割の問題点として、他ファイルで定義した関数などが重複して、衝突してしまう「グローバル汚染」があります。
グローバル汚染とは、「あるファイルでグローバル領域に関数などを宣言すると、もう他のファイルで同名の関数を宣言できないよ!名前かぶっちゃうからね!」ということです。
例えば他のファイルでグローバル領域で宣言した関数・クラス・オブジェクト名は、もうほかのファイルで使うことができません。
巨大なプログラムを制作していくにつれ、「同じ関数名を使わないように名前付けを努力する行為」というのは、だんだん面倒くさくなっていくものです。
対策として別ファイルで定義した内容が被らないように、命名規則にルールをつけることが一般的です。具体的には各ファイルごとに決められた接頭辞をつけてdatastore_myFunc()みたいな書き方をしてきます。
ですが、そういう人力の工夫ではなくてソフト仕様レベルからグローバル汚染を回避したいものです。
気持ちの良いプログラミングをするにあたっては「他のファイルに何が書いてあろうとも、自分の書いているファイルだけ着目すればすべてがわかる」という状態が嬉しいです。
このような仕組みを実現するために「別ファイルの関数やクラス名を指定して、引っ張ってくる方法」を考えていきます。
import文 とexport文
幸いJavaScriptは2015年に制定されたES6という規格からimport、export文という機能が実装されて、外部ファイルの関数やクラス名を指定して引っ張ってくることができるようになりました。
今回はこのimport、export文を使用したファイル分割の考え方について説明します。
ここで、2点言葉の説明をさせてください。
・「ある目的をもった機能」のことをモジュールと呼びます。
・また、外部に書かれたモジュールを引っ張ってくることを「インポートする」と呼びます。
外部ファイルに書かれた、使用したいモジュールを必要に応じてインポートしていく、という使い方をします。
別ファイルに書いたモジュールを必要に応じて、メインプログラムがimportして使っていくイメージです。こんな感じで!
では、実際にどのように実装していくのか見ていきましょう。
次からはモジュールの作成方法について書いていきます。
※補足 (詳しい方向け、読まなくてもいいよ!)
Node.jsを導入している方はwebpackなどのビルドツールを用いてimport,exportを使用しているかと思うのですが、今回はNode.jsを使用せずにES6の規格に基づいてファイル分割する方法を説明していきます。
モジュールを作ってみる
現代のプログラム開発では、「ある機能を持ったモジュールを組み合わせて一つの大きなプログラムを作成する」と見通しのよいプログラムになると信じられています。
プログラム以外でもこの考え方はよく使われますね。
大きな問題に取り組む時に小さな問題に分割して、その小さな問題を一つずつ解決していくかと思います。この考え方をプログラムの世界でも適用していきます。
さて、JavaScriptではこのモジュールを関数とかクラスによって表現することができます。
モジュールを作成する上で大切なことは「名前を見たら、それがどんな機能であるか明確にわかること」です。この名前付けこそがプログラムで一番大切で大変な作業といえます。
関数でモジュールを作ってみる
悪い例を出します!
const func = (pos1, pos2) => {
return Math.sqrt((pos1.x - pos2.x) ** 2 + (pos1.y - pos2.y) ** 2);
}
この関数名を見て、どんな機能の関数かわかりますか。
わからないですよね!
funcと言われてもよくわかりません!
引数pos1,pos2を使って何か計算をしているようですが、これだとイメージがわきませんね。
(ちなみに、本ページでは関数定義をすべてアロー関数で書きます。JSは関数の書き方がたくさんあるのですが、使い分けるのも面倒なのでアロー関数で統一しています。)
わかりやすい関数モジュールを作ってみる
この関数をわかりやすくするために、以下のように手を加えます。
・関数名で機能の説明をする(動詞+名詞で書く)
・引数の型(数字?文字?どんなオブジェクトなのか)をコメントで明確にする
/**
* 2点の長さを返す
* @param {x:number,y:number} pos1 始点
* @param {x:number,y:number} pos2 終点
*/
const calcLength = (pos1, pos2) => {
return Math.sqrt((pos1.x - pos2.x) ** 2 + (pos1.y - pos2.y) ** 2);
}
ココまで書けば、「どう使えばよいかパッとみてわかるモジュール」になったかと思います。数ヶ月後に見返しても間違えて使うことはないでしょう。
引数のpos、実は二次元座標を入力するオブジェクトでした。これはパット見てわかりずらいので、コメントに {x:number,y:number} とposの型を書いていきます。
ちなみにC言語などの他のプログラム言語では、変数の型を定義することができます。これを型定義と呼びます。
しかしながら、JavaScriptには型定義を明確に表示する方法がありません。
そのためコメントアウトで型の説明をするという、ちょっと面倒な書き方になってしまうのが残念ですが、これは素のJavaScriptを扱う上では宿命なので、受け入れるしかないのです。
※補足
JavaScriptに型の概念を追加したTypeScriptという言語があります。この言語を使うと、引数の型を定義できるので非常に扱いやすいモジュールにすることができます。
ただ、Node.jsの話やwebpackの話をしなければならないのでここでは取り上げません。興味ある方はこちらからどうぞ。
関数モジュールは引数から一意の値を返す
モジュールとして関数を作る時に注意しなければいけない点は「引数から一意に返り値が決まるようにすべき」という点です。
引数以外の変動する値で返り値を決めてしまうと、モジュールの概念が崩れてわかりにくいコードになってしまいます。
悪い例を書きます。
let isMeasurement=true;
/**
* 計測中の時だけ2点の長さを返す、計測中でない時は0を返す
* @param {x:number,y:number} pos1 始点
* @param {x:number,y:number} pos2 終点
*/
const calcLength = (pos1, pos2) => {
if(isMeasurement){
return Math.sqrt((pos1.x - pos2.x) ** 2 + (pos1.y - pos2.y) ** 2);
}else{
return 0;
}
}
isMeasurement というbool型の変数の値によって、この関数は異なる値を返すようです。この関数はその分複雑になってしまい、何やっているのかわかりにくくなってしまいました。
計測中か否かを判断したかったら、この関数を利用する上位のモジュールで判断させた方が見通しがよくなります。
基本的に「外部の値を参照する関数」が現れたら、モジュール化に失敗したことを表します。あくまでモジュールとしての関数は「与えられた引数から一意の返り値を出すもの」として設計しましょう。
さて、ここで作った関数のモジュールを外部ファイルからインポートできるようにするためにはこのように書きます。
/**
* 2点の長さを返す
* @param {x:number,y:number} pos1 始点
* @param {x:number,y:number} pos2 終点
*/
export const calcLength = (pos1, pos2) => {
return Math.sqrt((pos1.x - pos2.x) ** 2 + (pos1.y - pos2.y) ** 2);
}
exportをつけるだけ!シンプルですね。
外部のファイルから使う時はこのようにします。
上記の関数を書いたファイルをmath2s.jsとつけた時、そのファイルからcalcLengthモジュールをインポートする時は以下のように書きます。
import { calcLength } from "./math2d.js";
const startPos = { x: 10, y: 20 };
const endPos = { x: 100, y: 200 };
// 2点の距離を求める
const length = calcLength(startPos, endPos);
非常にシンプルですね。
ちなみにこのimport,exportは自分のローカルPC内で実行するとエラーがでてしまいます。サーバーにアップロードしないと動作しないのです。
ここではローカルサーバーを立ち上げてプログラムの動作を実際に確認する方法を説明します。以下の方法を試してみてください。
関数モジュールを作るときに注意すること
・関数名は「動詞+名詞」で動作を説明すること
・必ず引数から一意の返り値を持つこと
・外部の変数を使用しないこと
関数の命名についてもっと補足しますと、JavaScriptにおいて変数・関数名は最初以外の単語の頭文字を大文字で書きます。
この書き方をlowerCamelCaseと呼びます。具体的に話すと、先頭単語のlowerのみ全部小文字で、あとのCamelとCaseは先頭大文字で書きます。
クラスでモジュールを作ってみる
さて、同様にクラスも作ってみましょう。クラスは、複数の機能を一つにまとめる時に使われることが多いです。
クラスは関数と違ってメンバ変数を内部にもつことができます。
メンバ変数を持つということは「状態を保持することができる」ということなので、クラスは状態によって挙動が変わる機能を表現することができます。
先程の関数の例で使ったisMeasurementという状態を持つモジュールを作る必要がでてきたら、クラスを用いて表現します。
複雑化の原因、副作用について
このように、内部の変数が変動するモジュールのことを「副作用を持つモジュール」と表現します。
副作用を持つクラスは関数と違って、状態によって挙動がかわります。
副作用をもつクラスは「何やってるのかよくわからないモジュール」になりやすいです。
変化する状態を管理するということは、それだけ複雑度があがってしまうからです。
後から見た時になにやっているのかわからないものになります。メンバ変数は必要最低限に留めるべきです。ある程度メンバ変数を持たなければならないものですが、なるべく状態を持たない工夫を頑張りましょう。
実際に作ってみる
さて、前置きはここまでにして、実際にクラスを作ってみましょう。
ここでは、「画面にブロック崩し用のパーツを描画するクラス」を考えてみます。
/**
* ブロック崩しのための画面描画を行うクラス
*/
export class BreakoutViewer {
constructor() {
// 画面描画の初期設定
}
/**
* ボールを指定した座標に描画する
* @param {x:number,y:number} pos 座標
*/
setBallPos(pos) {
// 処理を書く
}
/**
* バーを指定した座標に描画する
* @param {x:number,y:number} pos 座標
*/
setBarPos(pos) {
// 処理を書く
}
/**
* 複数ブロックを指定した座標に描画する
* @param {x:number,y:number}[] posAry ブロックの座標配列
*/
setBlockPos(posAry) {
// 処理を書く
}
}
画面描画メソッドの中身は、HTMLを書き換えるDOM操作を書いたり、はたまたPixi.Jsのような描画用のライブラリを使うなりで自由に書いていけばよいので、省略します。
上記は画面描画を行うだけなので、状態を管理する変数を持たないことにします。このクラスを使用する側が、ballの座標やらバーの座標などの変動する状態を管理して、適切に呼び出していくことになります。
状態を持たないので、パッとみて何やっているのかわかりやすいですね。
このように「状態を持たせずに、とにかく機能はシンプルにすること」が使いやすいモジュールを作るにあたって大切です。
変動する値は上位のモジュールが管理するべき
基本的には変動する値はモジュール内で持たない工夫をします。コツは、可能な限りそれを使用する上位のモジュールが持つようにすることです。
このようにして状態を管理する変数をどんどん、そのモジュールを実行する上位のモジュールに任せていきます。最終的に最上位のモジュールに状態を管理する変数が集まるようにできれば、いい感じです。
ちなみにクラス名は名詞にすることが一般的です。
クラスでモジュールを作るときに注意すること
・クラス名は名詞で機能を説明すること
・なるべくメンバ変数を持たないように務めよう
ちなみに命名の補足ですが、JavaScriptではクラス名を「単語の先頭を大文字で書く」ようにします。
この書き方をPascalCase やUpperCamelCaseと呼びます。Pascal、Calseのように単語の先頭を大文字にしているのですね。
まとめ
ソースコードのファイル分割方法として、関数やクラスによるモジュール化の方法と、そのインポート方法について説明しました。
わからない点がありましたら、コメント欄やtwitterで質問してもらえると嬉しいです。
ちなみに冒頭でも紹介しましたが、今回学んだモジュールの知識をシンプルに用いると、こんな感じで別ファイルに書いたモジュールを必要に応じてメインプログラムにインポートしていくことになると思います。
プログラムの規模が増えると、たくさんのモジュールを複数インポートして、このように読み込んでいくこともできます。
この構成でも全然プログラムは組めるのですが、大規模なプロジェクトになると、メインプログラムが肥大化していくことになるでしょう。
そこで、次回は全てをモジュール化していくことを考えていきます。
先人の方が考えたMVPパターンについて説明していきます。
実際にMVPパターンでブロック崩しを制作してみたのでお楽しみに!
(2021/04/14)MVPパターンの記事を書きました。