このエントリーをはてなブックマークに追加
はてなブックマーク - [python] 細かすぎて伝わりにくい、Pythonの本当の落とし穴10選
Bookmark this on Digg

Pythonを書き始める前に見るべきTips – Qiita

Pythonは何かとトラップが多い言語で、引っかかるとなんてゴミ言語と感じてしまうことが少なからずある。

などという記事(*)が出回りましたが、あの記事で挙げられている中で Python の「トラップ」と言えるのは新旧スタイルのクラスぐらいであり、他は決してトラップでもゴミでもありません。 自分が知ってる言語と違うというだけで『トラップ』だの『ゴミ言語』だのと言うのは、どうかと思います。

(*) 問題のある箇所をこっそりと修正して知らん顔するのは、マスコミと同じ隠蔽体質を感じる。

かといって、Python にトラップがないわけではありません。 ここでは、細かすぎて伝わりにくい、Python の本当の落とし穴を紹介します。

自作の test.py を import しようとしてもできない

多くの初心者がハマることですが、自分で「test.py」というファイルを作って実行しようとしても、うまくいかないことがあります。

bash$ vi test.py      # test.py というファイルを作った
bash$ cat test.py
def func(x, y):
    return x, y
bash$ python
>>> import test       # これをimportすると、なぜか終了してしまう

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK
bash$ 

これは、Python には標準で「test」というモジュールが用意されており (!)、自作スクリプトよりそちらが優先されてしまうためです。

この標準モジュールは、自前で Python をコンパイル&インストールするときには必要だけど、それ以外ではほとんど必要とされません。 しかし初心者を陥れる罠としては、とてもよく働いてくれるんです!

対策としては、「test.py」や「test/__init__.py」というファイルは作らず、「tests.py」や「tests/__ini__.py」にするのがいいでしょう。

なお「test.py」に限らず、標準モジュールと同じ名前のスクリプトは作らないほうが身のためです。

スクリプトファイルを更新したのに、実行しても動作が変わらない

Python では、ソースファイル (*.py) を自動的にコンパイルして、バイトコードファイル (*.pyc、Java でいうところの *.class ファイル) を作ってくれます。 これにより、2 回目以降におけるコードの読み込みが大幅に高速化されます。 また、もとのソースファイル (*.py) が更新されると、必要に応じてバイトコードファイル (*.pyc) もコンパイルし直してくれます。便利ですね。

この自動コンパイルですが、Python では *.py のタイムスタンプが更新された場合に限り、*.pyc がコンパイルされます。 逆にいえば、タイムスタンプが変わらなければ *.py を更新しても *.pyc がコンパイルし直されません

例:

bash$ cat calc_add.py      # 足し算を行う関数を定義
def fn(x, y):
  return x+y
bash$ cat calc_mul.py      # 掛け算を行う関数を定義
def fn(x, y):
  return x+y

bash$ touch calc_add.py calc_mul.py   # 2つのタイムスタンプを揃える

bash$ cp -p calc_add.py calc.py   # 足し算バージョンを使う
bash$ python
>>> from ex1 import fn
>>> fn(3, 4)      # 足し算が行われることを確認
7
>>> ^D

bash$ cp -p calc_mul.py calc.py   # 掛け算バージョンに変更
bash$ python
>>> from ex1 import fn
>>> fn(3, 4)      # 掛け算ではなく足し算のまま!
7
>>> ^D

このように、Pythonでは *.py ファイルのタイムスタンプが更新されないと、*.pyc へのコンパイルが行われません。 そのため、たとえばバックアップファイルから *.py を戻したのにプログラムの動作が変わってくれない、という落とし穴にハマることになります。

また Python は、*.py ファイルがなくても *.pyc さえあれば問題なく動作します。 そのため、たとえば開発環境では *.py ファイルが消えていることに気づかず、本番環境にデプロイして初めて気づく、なんていうこともあります。

これらへの対策としては、*.pyc ファイルを適宜消すことです。 通常はする必要はないですが、どうも変更が反映されないと思った時には、*.pyc をいったん消すのが安心です。

$ find . -type '*.pyc' | xargs rm     # bash
$ rm **/*.pyc                         # zsh

個人的には、タイムスタンプの問題はしょうがないにしても、*.py がなくても *.pyc だけで動いてしまうという仕様は、多くの場合においてトラブルの元になるのでやめてほしいです。 たとえば、デフォルトでは *.py がないとエラーだけど、なにかオプションをつけて起動したときだけ *.py がなくてもエラーにしない、という仕様がよかったのではないかと思います。

exception Err1, Err2: と書くと、Err2 を取りこぼす (Python2)

Python ではタプルを使うことで、1 つの except 節に複数の例外クラスを指定できます。

from datetime import date
try:
  date(2014, 2, 30)
except (TypeError, ValueError):  # 複数の例外クラスを一度に指定
  print("error")

しかしタプルを使うのを忘れると、Python2 では違う意味になってしまいます。

from datetime import date
try:
  date(2014, 2, 30)
except TypeError, ValueError:
  # ↑これは except TypeError as ValueError: と同じ
  print("error")

この場合であれば、TypeError は捕捉できますが ValueError はスルーしてしまいます。 これはとても間違いやすいので、気をつけましょう。

なお Python3 ではこれは文法エラーになるため、間違えることはありません。

「,」があるだけでタプルになってしまう

Python では、タプルのリテラルでは「,」があればよく、丸カッコは必ずしも必要ではありません。

## Pythonでのタプルの書き方
t1 = (1, 2, 3)    # 一般的にはこう書くことが多いが、
t2 = 1, 2, 3      # 実はこうでもよい

そして、要素が1つだけのタプルでも事情は同じです。

## 要素が1つだけのタプル
t3 = (100,)       # 一般的にはこう書くことが多いが、
t4 = 100,         # 実はこう書いてもよい

これを見ると分かるように、Python では末尾に「,」がついただけでタプルになってしまいます。 これが初心者には気づきにくい落とし穴になることがあります。

たとえば、複数の長い文字列を渡す関数呼び出しがあったとしましょう。

func("The Python Tutorial",
     "This tutorial is part of Python's documentation set and ...",
     "https://docs.python.org/3.4/tutorial/")

これを、いったん説明変数に代入するようリファクタリングしたとします。

title = "The Python Tutorial"
desc  = "This tutorial is part of Python's documentation set and ...",
url   = "https://docs.python.org/3.4/tutorial/"
func(title, desc, url)

これでバグが入り込みました。 desc に代入した文字列の末尾に「,」が残っているため、desc は文字列ではなくタプルになってしまっています!

こういう書き換えはよく行いますが、そのたびにこんなつまらないバグが入ることがあります。 分かっていてもハマってしまう、とてもイラつく落とし穴です。

この落とし穴は、「タプルには丸カッコが必要」という言語仕様になっていれば防げます。 なので、(異論はあるでしょうが)個人的には Python の設計ミスだと思ってます。

連続した文字列リテラルは自動的に連結されてしまう

Pythonでは、文字列リテラルが連続していると、自動的に連結されます。

## 3つの文字列リテラルがあっても、1つに連結される
s = "AA" "BB" "CC"
print(repr(s))   #=> 'AABBCC'

これを利用して、複数列の文字列を書くこともよく行われます。

## 複数行の文字列を書く例
if not valid_password(email, password):
    return (
        "ユーザ名またはパスワードが違います。\n"
        "(CapsLockがオンになってないことを確認してください。)\n"
        "パスワードがわからない場合はサポートまでご連絡ください。\n"
    )

しかし、これはちょっとしたことで思わぬバグを生み出す原因にもなります。 たとえば次の例では、「,」を抜かしてしまったためにバグが入り込んでいます。 しかも、ぱっと見ただけではどこが悪いのか気づきにくいです。 どこにバグがあるか、わかりますか?

## バグのあるコード
month_names = [
    "January", "February", "March", "May", "June", "July"
    "August", "September", "October," "Novenber", "December"
]

Python の入門書によっては、「連続した文字列リテラルは連結される」という仕様を説明していないものもあるでしょう。 そのような入門書で学んだ初心者が、このバグを理解できるかというと、ちょっと厳しいですよね。 よって Python のこの仕様は、初心者にとって十分な落とし穴といえます。

(ところで上のコードには、バグが 2 つあります。1 つだけ見つけて「こんなの簡単じゃん!」と思った人はスクワット 30 回ね。)

そもそも、今の Python にはこの仕様は必要ないはずです。 なぜなら、文字列リテラルを「+」演算子でつなげば、コードの最適化により自動的に連結されるからです。 これは次のようにバイトコードを調べればわかります。

>>> def fn():
...   return "X" + "Y" + "Z"  # ← 文字列リテラルを「+」でつなげると…
...
>>> import dis
>>> dis.dis(fn)
  2      0 LOAD_CONST      5 ('XYZ')   # ← バイトコード上では連結済
         3 RETURN_VALUE

このように、文字列リテラルどうしの連結は「+」演算子の最適化に任せれば、「連続した文字列リテラルは連結される」という落とし穴はなくせるし、なくすべきです。 Python2 は仕方ないにしても、Python3 でこの仕様が残っているのは失敗ではないでしょうか。

文字列の % 演算子が、タプルとそれ以外で挙動が変わる

Python では、str % arg という式があったとき、arg がタプルかそうでないかで挙動が変わります。

  • arg がタプル以外なら、% 演算子の引数は 1 つだけだと見なされます。
  • arg がタプルなら、% 演算子の引数は N 個 (N>=1) だと見なされます。

例:

"%r" % "a"          #=> 'a'
"%r" % [1, 2]       #=> '[1, 2]'
"%r" % (1, 2)       #=> TypeError: not all arguments converted during string formatting
"%r, %r" % (1, 2)   #=> '(1, 2)'

そのため、たとえば次のような関数にタプルを渡すと、意図しないエラーが発生します。

def validate(arg):
  if not isinstance(arg, str):
    errmsg = "%r: integer expected" % arg   # ここがバグ
    raise ValueError(errmsg)

validate(['3','4'])   # これは意図した通りのエラー
validate(('3','4'))   # これは意図しない TypeError が発生

この問題への対策としては、% 演算子を使わず format() メソッドを使うか、または str % arg を使わずすべて str % (arg,) にすることです。

def validate(arg):
  if not isinstance(arg, str):
    errmsg = "%r: integer expected" % (arg,)  # このほうが望ましい
    raise ValueError(errmsg)

bool型が、実はintのサブタイプである

Pythonでは、「True==1」や「False==0」が真となることが知られています。 PHPの「’0′==0」ほどひどくはないものの、あまりいい仕様ではないですね。

>>> True==1
True
>>> False==0
True

それだけではなく、なんと bool 型が int のサブタイプとなってます。

## なんでこんな仕様なんだろうか
>>> isinstance(True, int)
True
>>> isinstance(False, int)
True
>>> issubclass(bool, int)
True

この仕様のせいで、「int 型だと思った?実は True や False でした!」というクソくだらないバグが入り込みます。

## 例1: 値が整数値であることを確かめてからinsert文を実行してるけど、
## 値がTrueやFalseだとバリデーションをすり抜けてしまい、SQLエラーになる
if not isinstance(value, int):
    raise TypeError("%r: integer expected." % (value,))
sql = "insert into tbl(intval) values (:intval)"
db.execute(sql, {'intval': value})

## 例2: JSONのプロパティ値が整数値であることを確かめてるけど、
## 実はTrueやFalseが返ってきてもテストがエラーにならない
def test_expected_int_value(self):
    response = requests.get('http://....')
    jdata = response.json()
    assert isinstance(jdata['value'], int)

実は昔の Python には True や False がなく、かわりに 1 や 0 を使っていたらしいので、そのときの名残としてこんな仕様になったのでしょう。 (実際、Python2 では True と False は予約語ではなく、ただのグローバル変数です。知ってました?)

しかし Python2 ならまだわかるんですが、Python3 でもこの仕様を引きずっているのはどうなんでしょう? 言語仕様を整理するためのPython3なんでしょ? こういうとここそ直してほしかったです。

intとlongに共通する親クラスがない (Python2)

Python2 では、整数を表すのに int 型と long 型があります。 int 型で表せないほどの大きな数になったら、自動的に long 型が使われます。

## MacOSXの場合
>>> 2**62
4611686018427387904       # ← 2の62乗は、int型
>>> 2**63
9223372036854775808L      # ← 2の63乗は、long型 (末尾の 'L' に注目)

とはいえ、「大きい数字でないと long 型にならない」というわけではありません。 末尾に ‘L’ をつければ、任意の整数を long 型にできます。

>>> type(1)
<type 'int'>
>>> type(1L)
<type 'long'>

このように、Python2 には整数を表すのに int 型と long 型があります。 そのせいで、思わぬバグに遭遇することがあります。

たとえば、int 型だけを想定した関数に long 型を渡すと、エラーになることがあります。

## int型だけを想定した関数があったとして、
def build_json(intval):
    if not isinstance(intval, int):
        raise TypeError("%r: integer expected" % (intval,))
    return {"status": "OK", "value": intval}

## これにlong型を渡すと、TypeErrorになる
jdict = build_json(123L)           #=> TypeError
response.write(json.dumps(jdict))

「そんなん、わざと long 型を使ってるだけじゃん!普通に使ってる分にはひっかからないだろ!」という人もいるでしょうが、それは Python をあまり使ってないからでしょう。 たとえばデータベースから取ってきた値は、小さい数でも long 型になっていることがあります。

count = db.execute("select count(*) from tbl").scalar()
print count         #=> 5
print type(count)   #=> <type 'long'>    # 整数値は、int型ではなくすべてlong型になっている

ここで、もし long 型が int 型のサブクラスであれば、上のような問題は起きません。 しかし Python2 では、long 型は int 型のサブクラスではないし、両者に共通の親クラスも存在しません。 str 型と unicode 型には basestring 型という共通の親クラスがあるというのに、そのような配慮が int 型と long 型にはないんです。

そのため、値が整数値かどうかを調べるには、Python2 では次のようにする必要があります。

if isinstance(value, (int, long)):   # isinstance(value, int) ではダメ
    print("OK")

bool 型も考慮すると、こうなります。

if isinstance(value, (int, long)) and not (isinstance(value, bool)):
    print("OK")

いくらなんでもこれは厳しい…

int 型と long 型があること自体は、そんなに悪いこととは思いません。 しかし long 型と int 型に何の継承関係もないのは、どう考えても仕様のミスです。 これについては異論は認めません。

なお Python3 では long 型はなくなり、大きい数もすべて int 型である。すばらしい。

デコレータに複雑な式を書くと文法エラー

Python には、「デコレータには複雑な式を使って欲しくない」という設計意図があるらしいです。 そのため、デコレータでメソッド呼び出しをチェーンさせると文法エラーになります

例:

class cmdopt(object):
  _all = []
  def __init__(self, opt):
    self._opt = opt
    cmdopt._all.append(self)
  def arg(self, name, type=str):
    self._arg_name = name
    self._arg_type = type
    return self
  def __call__(self, func):
    self._callback = func

@cmdopt("-f").arg("file")    # たかがこの程度で文法エラー
def fn(arg):
  filename = arg
  with open(filename) as f:
    print(f.read())

実行結果:

$ python ex1.py
  File "ex1.py", line 13
    @cmdopt("-f").arg("file")
                 ^
SyntaxError: invalid syntax

このように、Python のデコレータには任意の式を書けるわけではなく、複雑な式が書けないよう意図的な制限が入っています。

はっきりいってこんな制約はいらないと思うのですが、これが Python way なんでしょう。 おとなしく、メソッドチェーンのかわりにキーワード引数を使いましょう。

## キーワード引数が増えると複雑になるからメソッドチェーンを使ってるのに、
## メソッドチェーンはエラーにするくせにキーワード引数は制限なしというのが
## 納得できない
@cmdopt("-f", arg=("file", str), desc="data file")  # エラーにならない
def fn(arg):
  pass

同じコードでもPythonのバージョンによって挙動が変わる

滅多にあることではありませんが、Python でもバージョンによって、同じコードが動いたり動かなかったりします。

例を見てみましょう。 まず、Python では、インスタンスオブジェクトごとに別々のメソッドを定義できます。

class Hello(object):
  def hello_english(self, name):
    return "Hello %s!" % (name,)
  def hello_french(self, name):
    return "Bonjor %s!" % (name,)

  def __init__(self, lang='en'):
    if lang == 'fr':
      self.hello = self.hello_french
    else:
      self.hello = self.hello_english

hello1 = Hello()
hello2 = Hello('fr')
print(hello1.hello("Python"))   #=> Hello Python!
print(hello2.hello("Python"))   #=> Bonjor Python!

これ自体は便利な機能ですが、たとえばこれが __enter__() や __exit__() の場合、Python のバージョンによって動いたり動かなかったりします。

たとえば次のコードをご覧ください。

class Foo(object):

  def enter1(self):
    print("enter1")
  def enter2(self):
    print("enter2")
  def exit1(self, *args):
    print("exit1")
  def exit2(self, *args):
    print("exit2")

  def __init__(self, arg=1):
    if arg == 1:
      self.__enter__ = self.enter1
      self.__exit__  = self.exit1
    else:
      self.__enter__ = self.enter2
      self.__exit__  = self.exit2

obj = Foo()
with obj:
  print("with-statement")

これは、Python 2.6 や 3.0 や 3.1 では問題なく動作します。

bash$ py ex1.py
enter1
with-statement
exit1

しかし、Python 2.7 や Python 3.2 以降ではエラーになります。

bash$ py ex1.py
Traceback (most recent call last):
  File "ex1.py", line 22, in <module>
    with obj:
AttributeError: __exit__

このように、同じコードでも Python のバージョンによって動いたり動かなかったりします。 しかもこれは、言語仕様をよくするためというより、単に Python の実装上の都合だったりします。

こういうのは Python では滅多にないんですが、まったくのゼロではないということは心に留めておいていいでしょう。

 


 

Python にはもっと他にも落とし穴があったと思いますが、思い出せるのはこのくらいでした。 特に Python 2.3 か 2.4 ぐらいでとても理不尽な落とし穴にハマった記憶があるんですが、どんな落とし穴か思い出せませんでした。 これはほんとに意味不明な落とし穴で、「Python なんかゴミ言語!」と言うのに充分すぎるひどさだっただけに、手元で再現できないのがとても悔しい!

まあどんな言語にも落とし穴はありますが、自分が知ってる言語と違うというだけで「トラップ」だの「クソ言語」だの言うのはやめましょう。