LoginSignup
0

More than 1 year has passed since last update.

テキストを行ごとにキー,値,キー,値という辞書(連想配列)にするコードをチームみんなで書いた

Last updated at Posted at 2021-12-18

:rage1:鬼上司「このテキストを一行ごとに、キー・値・キー・値…として辞書にしろ。行数はかならず偶数になっているものとする。」

:man:部下「」

という業務課題があり、やってもらったもののコードレビューのときにちょっとイマイチ…となったので、チームみんなでやってみました。言語は Python です。

なお、この課題は実際に業務で出てきたものにかなり近いものですが、この記事内のコードは一部手直しを入れて書いています。

課題

source = """key1
value1
key2
value2
key3
value3
key4
value4"""

このように、キー、値、キー、値… と交互に改行区切り出てくるテキストを、辞書にしなさい。ただし、行数は必ず偶数であるものとします。また、改行文字は \n とします。

最初のコード

def solution1(source):
    items = source.split('\n')
    d = {}
    for index, item in enumerate(items):
        if index % 2 == 1:
            d[items[index - 1]] = items[index]
    return d

結果

print(solution1(source))
{'key1': 'value1', 'key2': 'value2', 'key3': 'value3', 'key4': 'value4'}

うん。まあ問題なく動いている。ただ、なんかもっと…こう…あるでしょ! って感じがします。ループの半分がスカるのがちょっとなーって感じました。とはいえ、明快なコードの最終形は、まだ見えていません。

なんか zip 使うとうまくいきそうじゃない? という話になり、書いてみたらこんなコードになりました。

def solution2(source):
    items = source.split('\n')
    keys = []
    values = []
    for i, v in enumerate(items):
        (values if i % 2 else keys).append(v)
    return dict(zip(keys, values))
print(solution2(source))
{'key1': 'value1', 'key2': 'value2', 'key3': 'value3', 'key4': 'value4'}

なるほど。なかなかおもしろい。ただし、空リストを作って突っ込んでく感じがあまり python っぽくない気がします。

どうせなら、チームみんなで考えてみようってことになり、コードを書いてもらいました。

PHP の場合

その前に。PHPを書かれる人は気づかれたと思いますが、PHP では、array_chunk という、配列を特定サイズごとに切り分ける関数が最初から用意されており、これを使うとさっぱり書けます。ただし Python には無い。itertools に入ってそうですけど、無いんです。

配列やジェネレータを特定個数ごとに切り分ける、いわゆるチャンク処理は webアプリ開発ではけっこう出てくるコードで、例えば UI でグリッド表示する時に1行の要素数で全要素を切り分けたり (ラベルシール印刷時のレイアウトとか)、 SQL のバルクインサートでよく使います。

そのため、プロジェクトによってはチャンク関数が既に用意されていることも多いと思います、それを使えば簡単に書けます。

例えば、リストをチャンクする場合であれば、全体の長さ(len)が最初からわかっているので

def chunk(source_list: list, n: int):
    for i in range(0, len(source_list), n):
        yield source_list[i:i + n]

こんな感じで書けますので、もしこのようなチャンク関数が既にあれば

def solution3(source):
    return dict(chunk(source.splitlines(), 2))

という形で、一発で書けます。

ちなみに、イテレータに対するチャンクは、下記コメントにかなり良いコードがありました。

みんなのコード

チームのメンバーが書いてくれたコードを掲載します。ついでに1万回ループで実行させた時の経過時間も書いておきます。(単位のusはマイクロ秒の意味です)

なお、コードを書く時は速度を重視する要件は無かったことをお伝えします。また、上記のサンプルコードも見ていない状態で書いてもらいました。

deque を使う

import collections

def solution4(source):
    a = collections.deque(source.splitlines())
    d = {}
    while a:
        k = a.popleft()
        d[k] = a.popleft()
    return d

1万回実行 7642us

deque は、スレッドセーフな双方向キューで、頭からもお尻からも push, pop ができるいかしたやつです。頭からキーと値を1つづつ pop するパターンです。

引数つき pop を使う

def solution5(source):
    a = source.splitlines()
    d = {}
    while a:
        d[a.pop(0)] = a.pop(1)
    return d

1万回実行 6991us

普通の list でも、pop(0) で頭から pop ができます。シングルスレッドではこれで十分でしょう。

イテレータにして next(), next() する

def solution6(source):
    def gen():
        a = (l for l in source.splitlines())
        while True:
            try:
                yield next(a), next(a)
            except StopIteration:
                return

    return dict(gen())

1万回実行 11288us
この方式は、少し遅かったようです。try を使ってるからかもしれません。

range で2ステップづつ回す

def solution7(source):
    a = source.splitlines()
    return {a[i]: a[i + 1] for i in range(0, len(a), 2)}

1万回実行 6907us

2ステップのスライスを、1つずらして作る

def solution8(source):
    a = source.splitlines()
    return dict(zip(a[0::2], a[1::2]))

1万回実行 5369us

リスト内包表記の if を使う

def solution9(source):
    splited = source.split('\n')
    two_each_list = [
        splited[i:i + 2] for i, _ in enumerate(splited) if i % 2 == 0]
    return {l[0]: l[1] for l in two_each_list}

1万回実行 12033us
若干オーバーヘッドがあるようです。

半分にしたインデックスを回す

def solution10(source):
    source_list = source.split()
    return {source_list[2 * i]: source_list[2 * i + 1]
            for i in range(len(source_list) // 2)}

1万回実行 7557us

一つのイテレータを二箇所で進める

def solution11(source):
    return dict(zip(*[iter(source.split())] * 2))

……え:disappointed_relieved:

読めない……

このノードを書いてくれた方は、丁寧に説明も書いてくれました。

it = iter(source.split())  # <list_iterator object at 0x1067df070>
z = zip(it, it)  # it がコールされるたび 0x1067df070 の __next__ が次の要素を返すので交互に振り分けられる
result2 = { k: v for k, v in z }

print(result2)
{'key1': 'value1', 'key2': 'value2', 'key3': 'value3', 'key4': 'value4'}

まじだ。すごい。アイディアが良い。

計測したところ、速度もこのコードが一番出てました。

1万回実行 4646us

正規表現で2行づつ取得する

def solution12(source):
    return {m.group().split()[0]: m.group().split()[1] for m in
            re.finditer(r'.+\n.+\n*', source)}

これも面白いですね。思いつかない方法でした。速度は

1万回実行 19113us

と、速度的な優位は無いようです。

正規表現のコンパイルをあらかじめ行っておき、結果取得の箇所も少し手直しすると

_r = re.compile(r'(.+)\n(.+)\n?')

# これをループで計測
def solution12_2(source):
    return {m.group(1): m.group(2) for m in
            _r.finditer(source)}

1万回実行 12253us

と、早くなりましたが素直にスライスをしたりする方法と比べると速さでの優位性は無いです。
ただ、そういう発想もあるのか、と感心させられたコードでした。

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
0