Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
Help us understand the problem. What is going on with this article?

Stackless Pythonでは、プログラムがユーザをファンクションコールする!

More than 1 year has passed since last update.

タイトルはロシア的倒置法です。

環境

Stackless Python 3.6.8 Windows 32-bit

TL;DR

メジャーな言語処理系はすべて、ユーザからの入力待ちをすることができる。Pythonならinput()。これは、ユーザに値を渡して値を返してもらう、つまり「ユーザを関数として呼び出ししている」と言える。

ユーザが値の入力を終えるまでのあいだ、スレッドはイベント待ちをする。

似たような状況で、イベント待ちができない場合がある。Webだ。もしメモリ上にコールスタックを広げたままユーザの入力を延々と待ち続けると、すさまじいムダと面倒が発生する。イベント待ちをするかわりに、処理を続けるための情報(セッションデータ)をDBに入れて、プロセスを終了しなければならない。

「ではコールスタックをセッションデータにしてしまえ」というのが自然な発想ではあるまいか。というより、そうしないことには、コールバック地獄の2乗が待っている。少しでも複雑なフローを書くには、なんらかの方法(DSLなど)で同じことをする必要がある。

「なんらかの方法」のひとつが、Stackless Pythonである。

CPythonのinput()

例として、ユーザに生年月日を入力させて、星座占いの星座(サイン)を求めるプログラムを見てみる。なおpip install zodiac-signが必要。

cpython_input.py
from zodiac_sign import get_zodiac_sign
import locale
locale.setlocale(locale.LC_ALL, 'en_US')
from datetime import date
import calendar

def input_num_range(range, msg):
    while True:
        s = input(msg + f' [{range[0]} - {range[1]}]:')
        try:
            i = int(s)
        except ValueError:
            print("Your input wasn't integer. Try again.")
            continue
        if i < range[0]:
            print("Your input was too small number. Try again.")
            continue
        if i > range[1]:
            print("Your input was too big number. Try again.")
            continue
        return i

def main():
    print("This program decides your zodiac sign. Please input your date of birth.")
    today = date.today()
    year = input_num_range([1900, today.year], "The year of birth")
    month = input_num_range([1, 12], "The month of birth")
    max_day = calendar.monthrange(year, month)[1]
    day = input_num_range([1, max_day], "The day of birth")
    d = date(year, month, day)
    z = get_zodiac_sign(d)
    print(f'Your zodiac sign is {z}.')

if __name__ == '__main__':
    main()

ではこれを、

  • ユーザに入力を求めるたびにプロセスを終了して、次回起動時のコマンドライン引数で入力を受け取るプログラム

に書き換えるには、どうすればいいか。

main()をズタズタに切り裂く」というのが最悪の答えだ。そんな設計でメンテ可能なのは、せいぜいこの例くらいの規模までだろう。もしmain()からinput()までのコールスタックの深さが3つを超えれば、おそらくそのプログラムは、バグを1つ潰すごとに常に2つ以上のバグが発生するので、どう頑張ってもバグを減らすことができない。

Stackless Python版

stackless_python.py
from zodiac_sign import get_zodiac_sign
import locale
locale.setlocale(locale.LC_ALL, 'en_US')
from datetime import date
import calendar

import pickle, os, sys
import stackless

PICKLE_FN = 'stackless_python_tasklet.pickle'
CHANNEL = stackless.channel()

def input_stackless(msg):
    print(msg)
    while True:
        s = CHANNEL.receive()
        if s is not None: # CHANNEL.receive() returns None once when it was unpickled. Bug?
            return s

def input_num_range(range, msg):
    while True:
        s = input_stackless(msg + f' [{range[0]} - {range[1]}]:')
        try:
            i = int(s)
        except ValueError:
            print("Your input wasn't a integer. Try again.")
            continue
        if i < range[0]:
            print("Your input was too small number. Try again.")
            continue
        if i > range[1]:
            print("Your input was too big number. Try again.")
            continue
        return i

def main():
    print("This program decides your zodiac sign. Please input your date of birth.")
    today = date.today()
    year = input_num_range([1900, today.year], "The year of birth")
    month = input_num_range([1, 12], "The month of birth")
    max_day = calendar.monthrange(year, month)[1]
    day = input_num_range([1, max_day], "The day of birth")
    d = date(year, month, day)
    z = get_zodiac_sign(d)
    print(f'Your zodiac sign is {z}.')

def load_task():
    try:
        with open(PICKLE_FN, 'rb') as f:
            task = pickle.load(f)
            task.insert()
            return task
    except:
        rm_task_pkl()
        return None

def dump_task(task):
    with open(PICKLE_FN, 'wb') as f:
        pickle.dump(task, f)
        task.kill()
        task.remove()

def rm_task_pkl():
    if os.path.exists(PICKLE_FN):
        os.remove(PICKLE_FN)

def start_or_resume_main():
    task = load_task()
    if task is None:
        task = stackless.tasklet(main)()
        task.run()
        dump_task(task)
    else:
        if len(sys.argv) > 1:
            CHANNEL.send(sys.argv[1])
            if task.alive:
                dump_task(task)
            else:
                rm_task_pkl()
        else:
            print("Command-line argument is missing.")

if __name__ == '__main__':
    start_or_resume_main()

input_num_range()input()を差し替えただけ、main()は一文字も変えていない。

Stackless Python特有の仕掛けは2つある。スタックレスなコルーチンのstackless.taskletと、pipe風のコルーチン間通信ができるstackless.channelだ。

stackless.tasklet(main)()taskインスタンスが生成され、task.run()で頭から実行され、CHANNEL.receive()send()待ちに入る。これでtaskインスタンスは「ブロックされた」状態になり、他のコルーチンへと実行が切り替わる。この場合、task.run()したメインコルーチンしか他に存在しないので、task.run()の続きが実行される。

「ブロックされた」状態のコルーチンをpickle.dump(task, f)してtask = pickle.load(f)できる、という機能の持つ意味は深い。Stackless Pythonにおいては、コールスタックがファーストクラスオブジェクトなのだ。これには、関数がファーストクラスオブジェクトなのと同じくらいのインパクトがある。

ラムダ式がなかった時代のC++に感じたのと同じ苛立ちを、私は今、現在のほとんどの言語処理系に感じている。

補遺

おそらくStackless Pythonのバグだと思うが、pickle.load()した直後、そのインスタンスをブロックしているchannelsend()すると、最初にNonereceive()から返される。検証コード:

import stackless
import pickle

CH = stackless.channel()

def foo(msg):
    while True:
        s = CH.receive()
        cid = str(stackless.getcurrentid())
        print(f'thread_id: {cid}   {msg}: {s}')

t1 = stackless.tasklet(foo)('t1')
stackless.run()
print('before a')
CH.send('a')
print('after a')

pickled = pickle.dumps(t1)
t1.kill()
t1.remove()

t1_reloaded = pickle.loads(pickled)
t1_reloaded.insert()

print('before b')
CH.send('b')
print('after b')

print('before c')
CH.send('c')
print('after c')
before a
thread_id: 70396336   t1: a
after a
before b
thread_id: 70738224   t1: None
thread_id: 70738224   t1: b
after b
before c
thread_id: 70738224   t1: c
after c

コルーチンの永続化ができるらしい(ある程度メジャーな)言語処理系を、私の知るかぎり列挙してみる。

多分(当然?)できる:

  • Common Lisp、Scheme

できそうでできないらしい:

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away