概要
※続きの記事を書きました:読後にどうぞ
【SAM】ゲーマー諸君、コントローラを棄ててアクションを起こせ!MVCのCが消えて、Actionが残った理由 - ゲーム脳でもわかるMVC 本論
この記事では、多様な意味を持つMVC=Model View Controllerという仕組みについて、ゲーム脳の人にわかりやすい形式で説明をします。
なお、MVCと単に記述した場合は、以下のようなものが歴史的な経緯によって混在しています:
- Smalltalkで実装された初期のMVC
- Web向けに構成されたModel2と呼ばれるもの
- さらにその他のもの
ここではModel2と呼ばれるものに近いニュアンスの説明をします。
多分、この図に言いたいことが凝縮されています。
MVCの目的(雑)
保守性が少しでも高いコードを書きたい…流儀に従って読めば、迷子にならずに処理内容を把握できるプログラムを作りたい
ざっくりですが、究極的な目的はこれにつきます。これをUIを有するプログラムで実現するにあたって、
プログラムで行う処理の内容に応じてM/V/Cという3つの対象に分類して、分業・組み合わせ的な書き方を強制する
というのが、MVC的な書き方という事になります。
登場人物の説明
身近な対象から説明するため、CとVから説明していきます。
C:Controller
コントローラです。ゲーム機のコントローラを想像してください。WiiならWiiリモコン、64なら3Dスティック付きのアレです。
古いか。
V:View
ビューです。「表示」「見えるもの」というようなニュアンスですが、要するに画面です。ゲーム画面が表示されているテレビを想像してください。
ここまで、ゲームをする時に触るのはコントローラで、見るのは画面ですね。
コントローラ…入力をつかさどる
ビュー…画面表示をつかさどる(出力とは言っていない)
さて、ゲームをするためには、一体何が足りないでしょうか?
これで外部パーツ的なものは揃っているように見えますが、肝心の、ゲームの処理をするものが実装されていないですね。それがMです。
M:Model
モデルです。ゲームのコア処理を担当します。
コア処理てなんやねん、と思いますよね。入力受付と画面処理を除くと、ざっくり内部データの処理というものが残ります。
例えば…
- 大乱闘スマッシュブラザーズで、マリオがカービィのストーンを食らった時、どの方向にどれだけ吹っ飛ぶかを計算する処理
- オセロで、指定された場所に駒を配置して、他の駒をひっくり返す処理
- 迷路ゲームで、"指定された場所"に移動して、ゴールしたかどうかを判定する処理
このような、ゲームが内部で持っているであろうデータを用いて、ユーザーの入力とともに計算処理を行うようなものであって、画面表示に依存しないもののことを、便宜上コア処理と呼ぶことにします。
今の画面がどこを表示していても、同じようにマリオがカービィからストーンを食らったら、同じように吹っ飛ぶはずですよね!だから一見Viewっぽくても、この計算の本質はModelです。
イメージ画像
MVCの分業を迷路ゲームで体感する
では、実際にこの考え方に則って迷路ゲームを作ってみます。
これから説明する迷路ゲームでは、ユーザーはゲームのコントローラを模した「←↓↑→」に相当するhjklの情報しか送ることができません。入力をコントローラで受け取って、内部の処理では現在位置から動かしたりしますが、現在位置はコントローラでは受け取らず、差分のアクション(を示すデータ)しか受け取らない、というところが重要です。
class MazeController:
cursor_dict = {
'h': (-1, 0),
'j': (0, 1),
'k': (0, -1),
'l': (1, 0),
}
model = None
view = None
def process_input(self, input_str):
if input_str in self.cursor_dict.keys():
self.model.process(self.cursor_dict[input_str])
return self.view.render(self.model)
return self.view.render(
self.model, message='h,j,k,lのいずれかを入力してください。')
class NormalMazeModel:
world = list(list())
current_x = 1
current_y = 1
message = ''
def __init__(self):
self.world = [
[1, 0, 1, 1, 1],
[1, 0, 1, 0, 0],
[1, 0, 1, 0, 1],
[1, 0, 0, 0, 1],
[0, 0, 1, 1, 1],
]
self.world[self.current_x][self.current_y] = -1
def _get_next_place(self, diff):
return self.current_x + diff[0], self.current_y + diff[1]
def process(self, diff):
new_x, new_y = self._get_next_place(diff)
# 配列をそのまま使っているのでx,yに注意する
if 0 <= new_y < len(self.world) and \
0 <= new_x < len(self.world[new_y]) and \
self.world[new_y][new_x] == 0:
self.world[self.current_y][self.current_x] = 0
self.world[new_y][new_x] = -1
self.current_x, self.current_y = new_x, new_y
self.message = 'GOGO!'
else:
self.message = '* おおっと *'
class NormalMazeView:
world_map_dict = {
-1: '@',
0: '.',
1: '#',
}
def _render_map(self, world):
return '\n'.join([
''.join([self.world_map_dict[w] for w in world_row])
for world_row in world])
def render(self, model, message=None):
if message is None:
message = model.message
return message + '\n' + self._render_map(model.world)
if __name__ == '__main__':
controller = MazeController()
controller.model = NormalMazeModel()
controller.view = NormalMazeView()
print(controller.process_input(''))
while True:
print(controller.process_input(input('')))
この例が、他のいくつかのMVCの例と比べて"わかりやすい"ところがあるとすれば、コントローラで受け取る入力は、プログラムの内情を反映したものではないというところかと思います。
一般的なウェブアプリの多くの画面では、データベースのテーブルとほぼ同じ形のフォームからデータを受け取り、モデルに処理をさせてビューに表示するという事があります。しかし、そのような一般的なウェブアプリの画面の形ではモデルの本質がわかりにくいです。
でも、このように差分だけを受け取るような事例では、わかりやすいです。
もう少しだけMVCのメリットを感じる
ただ、MVCがそれぞれ一つずつしか存在しないのでは、本当にこのような形をしているメリットがあるのか?という事がわかりにくいかもしれません。
以下で、ViewとModelを増やした例を載せてみます。
具体的には、迷路の形を「ドーナツ型」に変更した場合のモデルクラスと、表示領域を狭くしたビュークラスです。
class TorusMazeModel(NormalMazeModel):
def _get_next_place(self, diff):
y = (self.current_y + diff[1]) % len(self.world)
x = (self.current_x + diff[0]) % len(self.world)
return x, y
class ShadowedMazeView(NormalMazeView):
def render(self, model, message=None):
if message is None:
message = model.message
# 画面の描画
x, y, world = model.current_x, model.current_y, model.world
viewable_world = [[
world[i][j] for j in range(len(world[i])) if x - 1 <= j <= x + 1
] for i in range(len(world)) if y - 1 <= i <= y + 1]
return message + '\n' + self._render_map(viewable_world)
if __name__ == '__main__':
controller = MazeController()
controller.model = TorusMazeModel()
controller.view = ShadowedMazeView()
print(controller.process_input(''))
while True:
print(controller.process_input(input('')))
この断片は差分であることに注意してください。
View/Modelを少しずつ変更してみましたが、これらは自由な組み合わせで正常に動くことを確認できます。
いまはcontroller.modelやcontroller.viewに手動でModel/Viewをセットしていますが、例えばこれがWebアプリの場合には、リクエストの内容によってモデル・ビューを動的に変更するというような挙動に繋がります。
一般に、決まった形式でModel/Viewを作るようにしておけば、処理を読みやすく・書きやすくなります。
とはいえ、MVCのいま
ただ、ここまで述べてきたような有効な効果がありながらも、WebアプリとMVCに関しては以下のような言及もあります。
fluxのstoreはMVCのモデルではない
https://ledsun.hatenablog.com/entry/2018/05/16/105131
※タイトルやfluxという文字列は気にせずに、MVCの歴史などを見ると、いまの時点ではMVCが否定されています。
私がMVCフレームワークをもはや使わない理由
https://www.infoq.com/jp/articles/no-more-mvc-frameworks
個人的な認識では、それらは概ね正しく、つまり厳密にMVCを守るという事にはあまり重要性は無いかなと思っています。
ただ、MVCの根幹にある(はずの)、
保守性が少しでも高いコードを書きたい…流儀に従って読めば、迷子にならずに処理内容を把握できるプログラムを作りたい
という思いを実現する上では、このような考え方もある、という事を学ぶことに意味があるのかなと思います…いまも沢山のMVC風味のWebアプリが動いているはずなので。
おしまい。
…と思っていました。さっきまでは。
ところが、コントローラを棄てよ棄てよと内なる声が…!?
次回、
【SAM】ゲーマー諸君、コントローラを棄ててアクションを起こせ!MVCのCが消えて、Actionが残った理由 - ゲーム脳でもわかるMVC 本論
に続く。
注意事項
この説明は、あくまでも概要を理解する為のもので、実際にはMVCと一言で言っても意味の「ゆらぎ」があったり、フレームワークの実装がそもそも厳密な意味でのMVCに従っていなかったりします。この記事はスタート地点、またはスタート地点に立つための筋トレの方法を示した記事とご理解ください。(つまり、いずれにしてもスタート以前)
また、例に用いた題材が、必ずしもMVCでプログラミングされているという事を保証するものでもありません。
Controllerの中でViewを呼ぶことについては賛否あり、時としてファットなControllerの原因とも言われます。ここでは、ModelとViewの結合を疎にしたかった、ということでご理解ください。
DBの更新(永続化)に関する記述は…本題から若干ずれる部分があるので、今回は外しました。
型ヒントを使ってないとか、メンバ変数にそのまま代入して行儀が悪いとか、そのあたりも本筋から離れるので今回は雑に書きました。
Controller/Viewを作る際に継承するのはどうなのか?というのもありますが、コード量増えると読みにくいので継承してしまいました。NormalじゃなくてBasicとかにしといたほうがよかったかも。
renderなのに文字列返してprintしてないじゃん、というのは、jinja2やflaskを念頭に置いて書いてしまった為です。実は、Flaskにつなげるという構想もありました(量が増えるので削りました…)
(言い訳集みたいになった)