LoginSignup
1
2

More than 5 years have passed since last update.

PEP 380を使って戻り値をチェックする

Posted at

今回は PEP 380 を使って yield Nonereturn 相当にして、戻り値が非None値であることをチェックするデコレーターを紹介します。

PEP 380 というのは Python 3.3 で追加された新しい仕様で、ジェネレーターを多段にするための yield from 構文の追加が主な目的なのですが、今回は yield from 構文以外に追加されたいくつかの機能を使うことで yield Nonereturn 相当にするデコレーターを書いてみました。

ほとんどの方は

yield Nonereturn 相当にすることと、戻り値が非None値であることのチェックにどういう関係があるのか?

という疑問をお持ちだと思いますので、例として簡単な関数を一つ挙げてみます。

    def get_version(self, config_path=None):
        if config_path is None:
            config_path = self.get_default_config_path()
        config = self.parse_config(config_path)
        program_name = config.get("program_name")
        match = RE_VERSION.match(program_name)
        return match.group("version")

一部だけですので分かりにくいと思いますが、この get_version() は設定ファイルのパスを取得してからそのファイルをパースして "program_name" という項目からバージョン部分を正規表現で取り出して返す関数です。

私は正規表現に慣れているので特に遠慮せず使っていますが、この程度の処理に正規表現を使うと

そんなコードは Pythonic ではない!

とPythonistaの方々に怒られてしまうかもしれませんのでご注意ください。

さて、このコードはぱっと見で分かるように戻り値のチェックを全くしていません。

それはさすがに問題ですので、

  • エラー発生時には None を返す

というルールで必要な箇所で戻り値をチェックするように修正したのが下記のコードです。

    def get_version(self, config_path=None):
            if config_path is None:
                config_path = self.get_default_config_path()
            if config_path is None:
                return None
            config = self.parse_config(config_path)
            if config is None:
                return None
            program_name = config.get("program_name", None)
            if program_name is None:
                return None
            match = RE_PROGRAM_VERSION.match(program_name)
            if match is None:
                return None
            return match.group("version")

結果として、コードが if xxx is None: return None だらけになって、7行だった関数が15行になりました。

このようになってしまうことをどうにかしたいと前々から思っていたので、先に述べたように PEP 380 で追加された機能を使って書いたのが、yield_none_becomes_return() というデコレーターです。

最初のバージョンに、yield_none_becomes_return()を適用したコードが下記になります。

    @yield_none_becomes_return
    def get_version(self, config_path=None):
        if config_path is None
            config_path = yield self.get_default_config_path()
        config = yield self.parse_config(config_path)
        program_name = yield config.get("program_name")
        match = yield RE_VERSION.match(program_name)
        return match.group("version")

直前のバージョンで15行だったコードが8行になりました。

最初のバージョンとの違いはデコレーターと yield の追加だけで、他は一切変わっていません。

yield_none_becomes_return() の処理は単純です。例えば、

        config_path = yield self.get_default_config_path()

の場合、self.get_default_config_path() の戻り値が None だった場合は yield None が成立するので即 returnNone でなかった場合は、config_path に戻り値を代入してそのまま処理を継続させます。

デコレーターに引数がなければ return 相当になりますので None を返しますが、

    @yield_none_becomes_return("")

のように引数を与えることで、yield None が成立した場合に任意の値を返すようにすることも可能です。

ただし、callable() で評価して真になるオブジェクトを返したい場合は、

    @yield_none_becomes_return(value=function)

のように記述してください。これは諸々の事情による制限です。

他に注意が必要なのは、デコレートした関数の呼び出し元に StopIteration() が伝播しないことぐらいでしょうか。

yield_none_becomes_return() のソースは下記になりますので、他に何か問題を見つけた場合はぜひお知らせください。

ynbr.py
#!/usr/bin/env python3
# vim:fileencoding=utf-8

# Copyright (c) 2014 Masami HIRATA <msmhrt@gmail.com>
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
#     1. Redistributions of source code must retain the above copyright notice,
#        this list of conditions and the following disclaimer.
#
#     2. Redistributions in binary form must reproduce the above copyright
#        notice, this list of conditions and the following disclaimer in the
#        documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

import sys

if sys.version_info < (3, 3):  # pragma: no cover
    raise ImportError("Python >= 3.3 is required")

from functools import partial, wraps
from inspect import isgeneratorfunction

DEFAULT = object()

__all__ = ['yield_none_becomes_return']


def yield_none_becomes_return(function=DEFAULT, *, value=DEFAULT):
    """This decorator changes the yield statement to None checker for
       avoiding "if xxx is None: return" statements

    For example:
    # without this decorator:
    def get_version(self, config_path=None):
        if config_path is None:
            config_path = self.get_default_config_path()
        if config_path is None:
            return ""
        config = self.parse_config(config_path)
        if config is None:
            return ""
        program_name = config.get("program_name")
        if program_name is None:
            return ""
        match = RE_PROGRAM_VERSION.match(program_name)
        if match is None:
            return ""
        return match.group("version")

    # with this decorator:
    @yield_none_becomes_return("")
    def get_version(self, config_path=None):
        if config_path is None:
            config_path = yield self.get_default_config_path()
        config = yield self.parse_config(config_path)
        program_name = yield config.get("program_name")
        match = yield RE_VERSION.match(program_name)
        return match.group("version")
    """

    if not isgeneratorfunction(function):
        if function is DEFAULT:
            if value is DEFAULT:
                # @yield_none_becomes_return()  # CORRECT
                value = None
        else:
            if callable(function):
                raise TypeError("@yield_none_becomes_return is used only " +
                                "for generator functions")

            if value is not DEFAULT:
                # @yield_none_becomes_return("B", value="C")  # WRONG
                raise TypeError("yield_none_becomes_return() takes " +
                                "1 argument but 2 were given.")

            # @yield_none_becomes_return("A")  # CORRECT
            value = function
        return partial(yield_none_becomes_return, value=value)
    else:
        if value is DEFAULT:
            value = None

    @wraps(function)
    def _yield_none_becomes_return(*args, **kwargs):
        generator = function(*args, **kwargs)
        try:
            return_value = next(generator)
            while True:
                if return_value is not None:
                    return_value = generator.send(return_value)
                else:
                    return value
        except StopIteration as exception:
            return exception.value

    return _yield_none_becomes_return
1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2