Update 2023.11.17 2017.05.05

Python デザインパターン サンプルコード Template Method
Mark Summerfield『実践 Python 3』デザインパターンのサンプルコード
Python3(3.11)で動くソースコード(.pyファイル .ipynbファイル)あります
「anaconda3」on .py「PyCharm」.ipynb「Jupyter Notebook」

著作権の問題があるので,本に書いてないことだけを解説します。つまり,視点を変えて解説します。

◆◆Template Method パターンの使われる場面◆◆

テンプレートとは型板のことだが転じて定型書式のことを指すことが多い。似たような作業例えば「肉を料理する,魚を料理する」「テキスト文書を処理する,ワード文書を処理する」「ディスプレイに出力する,プリンタに出力する」などということを実装するには個々に実装してもかまわないが,それでは似た名称のメソッドがたくさんできるし,メソッドを使う側も迷う。また,人によってさまざまな実装になってしまう。

これを改善するのがテンプレートである。GoFの23のデザインパターンにも何回も出てくるように基本中の基本である。

(2023-11-17)Python3.11で動作確認済み

【重要な注意】本ソースコードファイルを起動するには第2引数が必要です。ターミナルからPythonを起動するとき,普通はプロンプト「'>'」の後に次のようにタイプします。

>python wordcount1.py pythonpattern01.html(ケースバイケースで)

ターミナルによっては'python'は'python3'になります。ソースコードファイル(スクリプトファイル)名称が第1引数です。第2引数は本プログラムに読み込むオプションです。

「PyCharm」では,次の枠に第2引数を設定します。
  メニュー→run→Edit Configurations...→左下矢印と右上矢印の枠
  ファイル名→Modify Run Configuration...→左下矢印と右上矢印の枠
  画面によっては,Interpreter options: という枠です(左下矢印と右上矢印の枠)

「Jupyter Notebook」では,自己テストの1行目に「sys.argv[1]=」を入れます。「PyCharm」では,この行はありません。「Jupyter Notebook」にこの行を入れるのは,ハードコードと言い,固有名詞や固有な数値をコマンドラインに埋め込むことは本来避けるべきですが,この場合はやむを得ないとしましょう。ちなみにこのコードでは第1引数のような扱いになっていますが,ターミナルのコマンドラインでは第2引数なので混乱しないように。

「sys.argv[1]=」の前かまたは冒頭に「import sys」を忘れないように。

.pyではターミナルから実行されたとき自己テストが実行され,他のファイルから呼ばれたときは自己テストは無視されることも忘れないように。「Jupyter Notebook」では呼ばれる側を上側に置き,下側に参照されるようにします(本記事に関係ないかも)。


◆◆Template Method パターンとは◆◆

GoFによれば,Template Method パターンの目的は, 「1つのオペレーションにアルゴリズムのスケルトンを定義しておき,その中のいくつかのステップについては,サブクラスでの定義に任せることにする。Template Method パターンでは,アルゴリズムの構造を変えずに,アルゴリズム中のあるステップをサブクラスで再定義する。」

GoFによれば,Template Method パターンは次のような場合に利用する。
・アルゴリズムの不変な部分をまず実装し,振る舞いが変わり得る部分の実装はサブクラスに残しておく場合。
・同じコードがいたるところに現れることがないように,サブクラス間で共通の振る舞いをする部分は抜き出して,これを共通のクラスに局所化する場合。これは,Opdyke と Johnson による“一般化のためのリファクタリング”[OJ93]の良い例である。まず,既存のコードにおける相違点を識別し,次にその相違点を新しいオペレーションに分離する。最後に,既存のコードを,その相違点については新しいオペレーションを呼び出すようにした template method で置き換える。
・サブクラスの拡張を制御する場合。特定の時点で“hook”operation(「結果」の節を参照)を呼び出すテンプレートメソッドを定義することができる。それにより,このポイントでのみ拡張が許されることになる。

GoFによれば,関連するパターンは次のようなものである。
Factory Method パターン: しばしば template method により呼び出される。「動機」の節の例では,factory method である DoCreateDocument オペレーションが,template method である OpenDocument オペレーションにより呼び出されている。
Strategy パターン: template method では,アルゴリズムの一部を変更するために継承を利用している。それに対して Strategy パターンでは,アルゴリズム全体を変更するために委譲を利用している。

◆◆Template Method パターンのサンプルのクラス構成◆◆

ソースコードは巻末にあり,ソースファイルはこのWebの左上隅にありダウンロードできます。
                  AbstractWordCounter
                 (can_count, count)
                  ↑              ↑
    PlainTextWordCounter       HtmlWordCounter
    (can_count, count)        (can_count, count)

サンプルは,プレーンテキストファイル(拡張子;txt)またはHTMLファイル(拡張子;html/htm)のワード数を数えるものです。

class AbstractWordCounter は,テンプレートであり,抽象クラスです。
class PlainTextWordCounter は,プレーンテキスト(拡張子.txt)扱う具象クラスです。
class HtmlWordCounter は,HTMLコードファイル(拡張子.html)を扱う具象クラスです。

◆◆Template Method パターンの実装◆◆

◆◆Template Method パターンの構成

サンプルは wordcount1.py と wordcount2.py(以下,「1」「2」)の2つありますが,構成は同じです。

コマンドラインの1番目の引数にこのファイル(モジュール)が書かれたとき,つまり,このファイル(モジュール)が起動されたときは,必ず,「1」121行目,「2」125行目が実行されます。そして,ここにメインルーチンを書きます(これは決り文句です)。

次の行で,グローバル関数を呼んでいるので,インデントが0に書かれた3つの関数がメインルーチンとなる。3つに分かれているのは,まとまりの都合であり,本質的なものではない。

クラスは3つあり,1つはスーパークラスであって抽象クラスである。残りの2つのクラスはサブクラスであり,プレーンテキストファイルを扱うものとHTMLファイルを扱うものである。

◆◆コマンドラインの第2引数

コマンドラインの第2引数は,ワードを数える対象ファイルです。プレーンテキストファイル(拡張子;txt)またはHTMLファイル(拡張子;html/htm)を書きます。

サンプルとして,この本のサンプルコードのダウンロードサイトの"README.txt"とこのWebページそのもの"pythonpattern101.html"を対象として添付しました。

コマンドラインの第2引数は,例えば,開発環境のPyCharm では,
  メニュー→run→Edit Configurations...→左下矢印と右上矢印の枠
  ファイル名→Modify Run Configuration...→左下矢印と右上矢印の枠
  画面によっては,Interpreter options: という枠です(左下矢印と右上矢印の枠)
に書きます。もちろん,第1引数は,開発環境にロードしたソースのことであり,開発環境を使ったときは,わざわざ,書きません。

◆◆2つのサンプル wordcount1.py と wordcount2.py(以下,「1」「2」)の違い

「1」と「2」の違いは抽象クラスの書き方だけである。「1」の書き方は旧く,「2」の書き方は新しいです。これからは「2」の書き方を推奨します。

この抽象クラスは,Template Method の中でインターフェイスの役割を担います。

◆◆そもそもインターフェイスの役割とは

インターフェイスである抽象クラスは,この例では,2つある「ファイルのワード数を数える」ための方法の典型例を,具体例ではなく,メソッドを並べるだけでステップを示すだけにします。

実際のメソッドは,
  def can_count
  def count
の2つです。

サブクラスでスーパークラスと同名メソッドを書くことを,オーバーライドと言いますが,明示的にスーパークラスのメソッドを呼ばない限り,サブクラスのメソッドが呼ばれます。継承の原理から当然のことであります。

サブクラスのメソッドの実装がされていないとき,スーパークラスのメソッドが呼ばれるのも,継承の原理から当然のことであります。

スーパークラスのメソッドが呼ばれたとき,「2」のように,それが抽象クラスであれば,サブクラスの同名メソッドが実装されていないことのエラーが出ます。

◆◆wordcount2.py(以下,「2」)の抽象クラスの詳細

抽象クラスとは抽象メソッドがあるクラスのことです。

とは言うものの,Python には,言語仕様としての抽象クラスと抽象メソッドはありません。abcモジュールにAbstract Base Class (抽象基底クラス)が追加されて,それらをデコレータで呼ぶと抽象クラスと同じ働きをするのです。

「2」では,abcモジュールをインポートして(12行目),AbstractWordCounter の中でメタクラス ABCMeta を宣言します(42, 43行目,普通は2行に分けません)。ちなみに,abc は Abstract Base Class (抽象基底クラス)のことです。

抽象メソッドの宣言はデコレータによって行います(46, 52行目)。この抽象メソッドは pass により空にします。

このクラスのサブクラスを具象クラスとして,具象メソッドを実装します。この宣言の働きは,具象メソッドが実装されていないとエラーを出します。

ちなみに,インポート宣言を
  import abc
ではなく
  from abc import ABCMeta, abstractmethod
とすれば,
  metaclass=abc.ABCMeta, @abc.abstractmethod

  metaclass=ABCMeta, @abstractmethod
と書けます。

◆◆wordcount1.py(以下,「1」)の抽象クラスの詳細

一方,「1」では,抽象クラスや抽象メソッドの宣言は一切なく,抽象メソッドの役割をしているメソッドが例外を投げます。

抽象クラスと抽象メソッドが無かった時代の手法です。このサブクラスで具象メソッドが実装されていない場合にそれが呼ばれると,スーパークラスの同名メソッドが呼ばれるので,ここで例外を投げれば,抽象メソッドと同じ働きをします。

実は,これは,実装解除と言われる手法なのです。スーパークラスのメソッドをサブクラスで使いたくないとき,サブクラスでオーバーライドしないだけだと,スーパークラスのメソッドが呼ばれてしまいます。 そこで,スーパークラスのメソッドが呼ばれるのを妨げるために,サブクラスでオーバーライドするのだが,実装解除として例外を投げてしまうのです。

サンプルの「1」は,上のこととは逆に,スーパークラスのメソッドが実装解除しているので,間接的に,サブクラスで同名メソッドが実装されていないことの警告になっているわけです。

【重要な注意】どちらの方法でも debug が終われば,抽象クラスと抽象メソッドを削除してしまっても正常に動作します。抽象クラスに抽象メソッド以外が実装されていればそれを残します。

◆◆抽象メソッドとその具象メソッドがスタティックメソッドになっている訳

抽象メソッドとその具象メソッドの上にデコレータ@staticmethodが付いていてスタティックメソッドになっているが,これは,メインルーチンがこれらのメソッドを呼ぶときの都合によりそうなっているのであり,本質的なものではない。

インスタンスメソッドは,インスタンス化しないと呼ぶことができない。その代わり,インスタンス化するたびにオブジェクトが増えていく。

このサンプルの場合は,インスタンスオブジェクトは必要ないし,クラス名とメソッド名だけで呼ぶことができるスタティックメソッドの方が呼び易いというだけである。

◆◆ファイルの中のワード数を数える方法

拡張子が".txt"であるプレーンテキストファイルの場合,スペースが区切りになっていますので,そのアルゴリズムは難しくなく,サンプルコードを読めば判ると思います。

ワードの判断は,次のコードを使う。
  re.compile(r"\w+")
re は,正規表現のライブラリ(モジュール)である。compile は,ワードの判断を何回も実行されるので,インタープリターではなくコンパイルしてしまうのである。r"\w+" は,ワードであることの正規表現である。\w は,英数字とアンダースコアであり,+ は,それを1回以上繰り返すことである。これで,ワードであることが判断できる。

ダブルコートの前の r は,raw string と呼ばれ,Python のエスケープ文字の規則を使わないことの宣言であり,正規表現のバックスラッシュ \ の規則だけを使っているのです。

◆◆HTMLファイルの中のワードとは何か

HTMLファイルのワードとは,もちろん,ブラウザで読み取れる文章のワードのことです。HTMLファイルのソースコードからこのワードを拾い出すの簡単ではありません。

そこで,拡張子が".htm"または".html"であるHTMLファイルのワードを数えるために html.parser と言うライブラリを導入します。

HTMLファイルのソースコードの構造を簡単に説明します。

ソースの中で山カッコ"<"と">"で囲まれたものを HTML タグと言います。

HTMLファイルのソースの骨組みは次のようになっています。

  <html>
    <head>

    </head>
    <body>

    </body>
  </html>

<body>と</body>との間にブラウザから読み取れる文章を書きます。そして,その文章を修飾する HTML タグも文章を囲むように書きます。つまり,山カッコ"<"と">"で囲まれたものは HTML タグであり,その間の文章がブラウザで読み取れるのです。

少し,例外があります。HTMLファイルのソースでは,HTML言語の他に,スタイルシート(css;cascading style sheets)と JavaScript が使われています。この2つのコードは,HTMLタグである<style>または<script>の間に書かれます。ですので,この間は,ワードを数えません。

サブclass HtmlWordCounterの中にローカルclass __HtmlParserがあり,そのスーパークラスがhtml.parser.HTMLParserです。その使い方はググってみてください。HTMLファイルのワードの数えているので,天下り的に真似してもかまいません。

◆◆ソースコード◆◆

このWebの左上隅からダウンロードできます。

ソースファイルは2つです。
・wordcount1.py;Template Method パターンのPythonサンプル
・wordcount2.py;Template Method パターンのPythonサンプル

抽象クラスと抽象メソッドだけが違います。

コマンドラインの引数用に html ファイルと txt ファイルを添付します。word数を数えるだけなので何でもかまいません。

【wordcount1.py】
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
#!/usr/bin/env python3
# Copyright c 2012-13 Qtrac Ltd. All rights reserved.
# This program or module is free software: you can redistribute it and/or
# modify it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version. It is provided for educational
# purposes and is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.

import html.parser
import os
import re
import sys


def main():
    if len(sys.argv) == 1 or sys.argv[1] in {"-h", "--help"}:
        print("usage: {} <files>".format(os.path.basename(sys.argv[0])))
        sys.exit(1)
    count_words_in_files(sys.argv[1:])


def count_words_in_files(files):
    total = 0
    for filename in files:
        count = count_words(filename)
        if count is not None:
            total += count
            print("{:9,} words in {}".format(count, filename))
    print("total: {:,} words".format(total))


def count_words(filename):
    for wordCounter in (PlainTextWordCounter, HtmlWordCounter):
        if wordCounter.can_count(filename):
            return wordCounter.count(filename)


class AbstractWordCounter:

    @staticmethod
    def can_count(filename):
        raise NotImplementedError()


    @staticmethod
    def count(filename):
        raise NotImplementedError()


class PlainTextWordCounter(AbstractWordCounter):

    @staticmethod
    def can_count(filename):
        return filename.lower().endswith(".txt")


    @staticmethod
    def count(filename):
        if not PlainTextWordCounter.can_count(filename):
            return 0
        regex = re.compile(r"\w+")
        total = 0
        with open(filename, encoding="utf-8") as file:
            for line in file:
                for _ in regex.finditer(line):
                    total += 1
        return total


class HtmlWordCounter(AbstractWordCounter):

    class __HtmlParser(html.parser.HTMLParser):

        def __init__(self):
            super().__init__()
            self.regex = re.compile(r"\w+")
            self.inText = True
            self.text = []
            self.count = 0


        def handle_starttag(self, tag, attrs):
            if tag in {"script", "style"}:
                self.inText = False


        def handle_endtag(self, tag):
            if tag in {"script", "style"}:
                self.inText = True
            else:
                for _ in self.regex.finditer(" ".join(self.text)):
                    self.count += 1
                self.text = []


        def handle_data(self, text):
            if self.inText:
                text = text.rstrip()
                if text:
                    self.text.append(text)


    @staticmethod
    def can_count(filename):
        return filename.lower().endswith((".htm", ".html"))


    @staticmethod
    def count(filename):
        if not HtmlWordCounter.can_count(filename):
            return 0
        parser = HtmlWordCounter.__HtmlParser()
        with open(filename, encoding="utf-8") as file:
            parser.feed(file.read())
        return parser.count


if __name__ == "__main__":
    main()

【wordcount2.py】
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
#!/usr/bin/env python3
# Copyright c 2012-13 Qtrac Ltd. All rights reserved.
# This program or module is free software: you can redistribute it and/or
# modify it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version. It is provided for educational
# purposes and is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.

import abc
import html.parser
import os
import re
import sys


def main():
    if len(sys.argv) == 1 or sys.argv[1] in {"-h", "--help"}:
        print("usage: {} <files>".format(os.path.basename(sys.argv[0])))
        sys.exit(1)
    count_words_in_files(sys.argv[1:])


def count_words_in_files(files):
    total = 0
    for filename in files:
        count = count_words(filename)
        if count is not None:
            total += count
            print("{:9,} words in {}".format(count, filename))
    print("total: {:,} words".format(total))


def count_words(filename):
    for wordCounter in (PlainTextWordCounter, HtmlWordCounter):
        if wordCounter.can_count(filename):
            return wordCounter.count(filename)


class AbstractWordCounter(
        metaclass=abc.ABCMeta):

    @staticmethod
    @abc.abstractmethod
    def can_count(filename):
        pass


    @staticmethod
    @abc.abstractmethod
    def count(filename):
        pass


class PlainTextWordCounter(AbstractWordCounter):

    @staticmethod
    def can_count(filename):
        return filename.lower().endswith(".txt")


    @staticmethod
    def count(filename):
        if not PlainTextWordCounter.can_count(filename):
            return 0
        regex = re.compile(r"\w+")
        total = 0
        with open(filename, encoding="utf-8") as file:
            for line in file:
                for _ in regex.finditer(line):
                    total += 1
        return total


class HtmlWordCounter(AbstractWordCounter):

    class __HtmlParser(html.parser.HTMLParser):

        def __init__(self):
            super().__init__()
            self.regex = re.compile(r"\w+")
            self.inText = True
            self.text = []
            self.count = 0


        def handle_starttag(self, tag, attrs):
            if tag in {"script", "style"}:
                self.inText = False


        def handle_endtag(self, tag):
            if tag in {"script", "style"}:
                self.inText = True
            else:
                for _ in self.regex.finditer(" ".join(self.text)):
                    self.count += 1
                self.text = []


        def handle_data(self, text):
            if self.inText:
                text = text.rstrip()
                if text:
                    self.text.append(text)


    @staticmethod
    def can_count(filename):
        return filename.lower().endswith((".htm", ".html"))


    @staticmethod
    def count(filename):
        if not HtmlWordCounter.can_count(filename):
            return 0
        parser = HtmlWordCounter.__HtmlParser()
        with open(filename, encoding="utf-8") as file:
            parser.feed(file.read())
        return parser.count


if __name__ == "__main__":
    main()

以上

トップページに戻る
inserted by FC2 system