はてなキーワードしりとりを改造・その6

先の記事で「例外処理をクラスにする意義がわかった」なんて書いてしまったのですが分かっていませんでした(^_^;)
それどころか大きな勘違いをしており先のコードには致命的な間違いがありました。
またクラス変数についても重大な勘違いをしているところがあったのでこの2つの点についてまず整理したいと思います。

except節について

Pythonのドキュメントに

except 節のクラスは、例外と同じクラスか基底クラスのときに互換 (compatible)となります。

http://www.python.jp/doc/release/tutorial/classes.html#tut-exceptionclasses

とあるようにexcept節は同じクラス以外にそのクラスから派生したクラスも捕捉します。前回のコードでmain関数に

except ShiritoriRuleError as e:
    print e
    continue
except CrtShiritoriError as e:
    print e
    break

と書いていましたがCrtShiritoriErrorはShiritoriRuleErrorの派生クラスなのでどちらの例外も

except ShiritoriRuleError as e:

の部分でキャッチされてしまい、「ん」のつく単語を入力してもしりとりが終了しない不具合となっていました。
上記の例ですとexcept節を逆にし、CrtShiritoriErrorを先に書けば良いようです。

クラス変数について

以前クラスの超基礎という記事を書いた際に

class Test(object):
    a = 5

class Test(object):
    def __init__(self):
        self.a = 5

のaというプロパティが全く同じ物であるかのように勘違いしており、そのように書いてしまったのですがこれは全く異なる物でした。
前者ではaはクラス変数であるのに対し、後者ではインスタンス変数です。

class Test(object):
    a = 5

class Test2(object):
    def __init__(self):
        self.a = 5
        
test = Test()

test2 = Test2()

#インスタンスからはまずインスタンス変数が参照され
#存在しなければクラス変数が参照される
print test.a # 5 (クラス変数)
print test2.a # 5 (インスタンス変数)

#クラス変数なので「クラス名.属性名」で参照可能
print Test.a # 5

#インスタンス変数は「クラス名.属性名」では参照できない
print Test2.a # AttributeError

クラス変数はクラス定義の際に一度だけ定義されるのに対し、インスタンス変数はインスタンス生成の都度定義される。そのため内容に引数による変化がないのであればクラス変数にするべき、のようです。

今回の改造点

  • except節の勘違いに由来する不具合を修正したのにあわせてこれまでShiritoriRuleErrorでまとめてキャッチし、内部でifを使って分類していた例外をすべて個別の派生クラスに分割しました。
  • しりとりの「次の文字」を作成する際に使用する辞書を従来はインスタンス変数だったものをクラス変数に変更し、インスタンスが作られるたびに再定義するムダを省きました。
  • 「次の文字」生成はtranslateメソッドを使うようにしました。
# coding: utf-8

import urllib
import urllib2
from xml.dom.minidom import parse

def input_reader(usedwords):
    """入力を受け取ってunicodeオブジェクトにして返す"""
    
    word = raw_input(u"次の単語を入力してください。次は"
                     + str(len(usedwords) + 1)
                     + u"語目です。")
    
    wordU = unicode(word, "utf-8")
    
    if wordU in usedwords:
        raise UniquenessError(usedwords[wordU])
    
    return wordU

class HatenaFuriganaDict(dict):
    """入力された単語とそのふりがなの組み合わせを辞書として管理する
       はてなキーワードに登録されていなかった単語の値はNone
       APIから正しいxmlが受信できない単語の値はFalseとする
    """
    @staticmethod
    def _ask_hatena(word):
        """__missing__から呼ばれてwordをはてなキーワードAPIに問い合わせる"""
        
        #URLエンコード
        encoded_word = urllib.quote(word.encode("utf-8"))
        
        #URLを作成
        requestURL = ("http://search.hatena.ne.jp/keyword?word="
                       + encoded_word
                       + "&mode=rss&ie=utf8&page=1")
        
        return urllib2.urlopen(requestURL, None)
    
    def __missing__(self, word):
        """この辞書に登録されていないwordが参照された場合に呼ばれ
           APIに問い合わせを行い返答する。さらにその結果を辞書に登録する
        """
        try:
            dom = parse(self._ask_hatena(word))
        except:
            self[word] = False
            return self[word]
        
        items = dom.getElementsByTagName("item")
        hatenaNS = "http://www.hatena.ne.jp/info/xmlns#" #XMLのNS
    
        if items and (items[0].getElementsByTagName("title"))[0].firstChild.data == word:
            self[word] = (items[0].getElementsByTagNameNS(hatenaNS, "furigana"))[0].firstChild.data
        else:
            self[word] = None
        
        return self[word]

class Furigana(unicode):
    """しりとりのルールに関連する処理を担う"""
    
    # 語尾置換用文字列
    rep_letters = {u"ゃ":u"や", u"ゅ":u"ゆ", u"ょ":u"よ", u"を":u"お", u"っ":u"つ"
                  , u"ぁ":u"あ", u"ぃ":u"い", u"ぅ":u"う", u"ぇ":u"え", u"ぉ":u"お"}
    
    to_replace = {}
    # Unicodeコードポイントに置き換え
    for k, v in rep_letters.iteritems():
        to_replace[ord(k)] = v
    
    def __init__(self, furigana):
        if furigana is None:
            raise DataNotFoundError()
        elif furigana is False:
            raise XMLError()
        else:
            unicode.__init__(self, furigana)
    
    def exam(self, initial):
        """最初の文字と最後の文字を調べて適格か否か判断"""
        if not self.startswith(initial):
            raise ILError(initial)
        
        if self.endswith(u"ん"):
            raise CrtShiritoriError()
    
    def creatIL(self):
        """次の単語用の「次の文字」を作成する"""
        if self.endswith(u"ー"):
            nextIL = self[-2:-1]
        else:
            nextIL = self[-1:]
        
        nextIL = nextIL.translate(Furigana.to_replace)
        
        return nextIL

class ShiritoriRuleError(Exception):
    """しりとりのルール基底クラス"""
    def __init__(self, value=None):
        self.value = value

class UniquenessError(ShiritoriRuleError):
    def __str__(self):
        return u"この単語は" + str(self.value) + u"番目に使いました。"

class DataNotFoundError(ShiritoriRuleError):
    def __str__(self):
        return u"この単語ははてなキーワードに登録されていませんでした。"

class XMLError(ShiritoriRuleError):
    def __str__(self):
        return u"xmlデータを正しく受信できませんでした。他の単語を試してください。"

class ILError(ShiritoriRuleError):
    def __str__(self):
        return u"次の文字は「" + str(self.value) + u"」です!"

class CrtShiritoriError(ShiritoriRuleError):
    """発生した場合ループを抜ける例外"""
    def __str__(self):
        return u"「ん」がついたよ"

def main():
    furiganadict = HatenaFuriganaDict() # API問い合わせた単語をキー読み仮名を値として管理する
    usedwords = {} # 正答の単語をキー、何番目に使ったかを値として管理する
    
    initial = u"は" #しりとりの最初の文字
    
    print (u"最初のもじは「" + initial + u"」です")
    
    while True:
        try:
            wordU = input_reader(usedwords)
            
            furigana = Furigana(furiganadict[wordU])
            
            furigana.exam(initial)
        
        except CrtShiritoriError as e:
            print e
            break
        except ShiritoriRuleError as e:
            print e
            continue
        
        print wordU
        
        initial = furigana.creatIL()
        
        usedwords[wordU] = len(usedwords) + 1
    
    print u"終了です"

if __name__ == "__main__":
    main()