t-hom’s diary

主にVBAネタを扱っているブログ…とも言えなくなってきたこの頃。

VBA経験者向け、Python入門用 GUIサンプル

今回は、ちょっとPythonを触ってみたいVBA経験者に向けて、簡単なGUIサンプルを紹介する。
Excel VBAではマクロの実行結果がシートに反映されるので、ある意味それ自体がGUIプログラミングと言えるが、殆どの言語では黒い画面に"Hello, World!"から入門するのが通例で、GUIの話は入門書の後半に差し掛かるあたりでちょろっと紹介される程度だ。

一方でGUIについて詳しく書かれた専門書は、黒画面での修行が終わったことを前提にしているので難しい。

黒画面 → 華がないのでやる気がでない。
GUI → 難しいのでやる気がでない。

つまり、ちょっとPythonを触ってみて、GUIが使えることを実感して、モチベーションをあげてから本格的に入門したいという方に向けた体験用コードで、しかもVBA経験者向けというのがなかなか無いので、今回書いてみた次第。

作るもの

サンプルなのでシンプルに。
ボタンを押すとラベルのテキストが変わるだけのプログラム。

コード

私にしては珍しく、逐一コメントを書いてみた。
後程、この#1~#8のフェーズごとに解説していく。

#1 tkinterモジュールをtkという名前でインポート
import tkinter as tk

#2 ウインドウの生成と変数への格納
root_window = tk.Tk()
root_window.geometry("200x100")

#3 可変文字列の生成と変数への格納
label_text = tk.StringVar()

#4 可変文字列の初期化
label_text.set("Please click the button.")

#5 ボタンが押された時用の関数を予め定義しておく。
def button_clicked():
    label_text.set("Button clicked.")

#6 GUIパーツの生成と変数への格納
lbl = tk.Label(
    master=root_window,
    textvariable=label_text)

btn = tk.Button(
    master=root_window,
    text="Button",
    command=button_clicked
    )

#7 GUIパーツの配置
lbl.place(x=5,y=5)
btn.place(x=5,y=30)

#8 ウインドウ表示
root_window.mainloop()

解説

#1 tkinterモジュールをtkという名前でインポート

import tkinter as tk

まずはこちら、VBAでいうところの「参照設定」に該当する。
あちらはメニューからGUI操作でモジュール名にチェックを入れるのに対し、こちらはコード自体に記述する方式。

tkinterというのはpythonの標準GUIモジュールである。長いのでここでtkという名前をつけて利用しやすくしている。
慣例的にtkという名前にしているが、ただの識別子なので別名でも動作する。
別名にした場合、コード各部のtkはその名前に置き換える必要がある。

なお、自分で書いた「ナントカ.py」も同じようにimport文で取り込むことができる。その場合拡張子「.py」は記述しない。

また、pythonではpipという同梱プログラムで外部モジュールをインターネットからお取り寄せできる。
tkinterはインストールした覚えがないので最初から入ってたと思うが、無いモジュールは「pip install モジュール名」というコマンドでインストール可能。
※pipが使えない場合、pip.exeの入ったパスを環境変数Pathに登録が必要

#2 ウインドウの生成と変数への格納

root_window = tk.Tk()
root_window.geometry("200x100")

Pythonではクラス名(VBAでいうクラスモジュール名に相当)を関数のように()を付けて呼び出すことで、インスタンスが生成される。
先ほどインポートしたtkinterにTkというウインドウを生成するクラスが定義されているので、tk.Tk()でインスタンスを生成して、root_window変数に代入している。
VBAとちがって、オブジェクトの代入にSetを使うなどの特殊な方法はない。普通にイコール記号だけで代入される。
また、VBAのDimのような変数宣言専用の書式は存在しないので、識別子に対していきなり代入する。Pythonの場合は作法的にもこれで合ってる。

geometryメソッドはウインドウのサイズを変更する命令である。
文字列で横x縦のサイズを引き渡すとそのサイズになる。
掛け算記号がアスタリスクではなく小文字のエックスなのが面白い。

さて、ここではウインドウは内部的に生成されただけで、まだ表示はされておらず、#8のmainloopメソッドで表示される。

#3 可変文字列の生成と変数への格納

label_text = tk.StringVar()

Pythonでは文字列は基本的に固定で、ラベル等に直接文字列を書いてしまうとプログラムで動的に変更ができなくなる。
そこで、tkモジュールのStringVarクラスから可変文字列のインスタンスを生成し、label_textという変数に格納している。
label_textはただの変数名なので、この時点でラベルにセットされたわけではないことに注意。

#4 可変文字列の初期化

label_text.set("Please click the button.")

先ほど生成した可変文字列に対し、具体的な文字列をsetメソッドでセット。

#5 ボタンが押された時用の関数を予め定義しておく。

def button_clicked():
    label_text.set("Button clicked.")

これはボタンがクリックされた時用に予め用意した関数。
さきほど作った可変長文字列label_textに新しい文字列をセットするコードだ。

関数と書いてるが、戻り値を持たないのでVBAでいうとSubプロシージャである。
Pythonでは呼び分けはせず、戻り値を持たなくても関数と呼ぶ。
defが「Sub」や「Function」に相当する宣言文で、button_clickedが関数名。

VBAだとEnd SubやEnd Functionでプロシージャブロックの終わりを表すが、Pythonではインデントでブロックを表す。これについてはいろんなところで解説があるのでここでは省略する。

ちなみになぜボタンもまだ作ってないのに、押された後のことを先に書くかというと、Pythonコードは呼び出し先コードは呼び出し元よりも前に定義しおく必要があるから。

たとえばこういうコードを書くと、

hoge()

def hoge():
    print("Hi")

こういうエラーが出る。

だから、呼ぶ前に教えてあげる必要がある。

def hoge():
    print("Hi")

hoge()

VBAだとモジュール内でプロシージャの順番を気にしなくても一旦全部探してくれるけど、Pythonだと順番を意識する必要がある。

#6 GUIパーツの生成と変数への格納

lbl = tk.Label(
    master=root_window,
    textvariable=label_text)

btn = tk.Button(
    master=root_window,
    text="Button",
    command=button_clicked
    )

ここではtkモジュールのLabelクラスからインスタンスを生成している。
VBAと違って殆どのオブジェクト指向言語はインスタンス生成時に引数を渡すことができる。
master=とかtextvariable=といった表記は、名前付き引数である。VBAでいうところの「:=」 と同じ。
第一引数がmasterなので、他の解説ではmaster=は省略して単にウインドウを格納した変数を書くケースが多いが、tkinter初見だと意味が分からないと思うので明示的に書いた。

これはつまり、ラベルやボタンを作るときに、マスターを指定して親子関係の構造を作っているということ。

サンプルではルートウインドウを指定していることが多いが、GUIパーツを纏めるフレームを使用する場合などは、フレームに配置するパーツのマスターをフレームに指定したり、複雑な構造を作ることができる。

マスターの指定では論理的な構造を定義しているだけで、実際にパーツを配置しているわけではない。
マスター以外の引数は、読むとなんとなくわかると思うので省略。

今回ラベルの仮引数textvariableにlabel_textをセットしたので、label_textの変更があれば都度ラベルに反映される仕組みになっている。
なお、ラベルの値を変更する予定が無ければボタンと同じように、textvariableの代わりにtextにテキストを設定する。逆にボタンにtextvariableを指定することも可能である。

#7 GUIパーツの配置

lbl.place(x=5,y=5)
btn.place(x=5,y=30)

ここでは先ほど作ったGUIパーツを実際に配置している。
パーツの配置方法にはpack、grid、placeの3つのメソッドがあり、基本的に同じレイヤーで混在させることはできない。
(フレームを使うと、フレーム内を別メソッドでレイアウトすることが可能)

packは最も簡便であるが基本的に直列・並列に並べる用なので応用して自由なレイアウトをしたいと思うと難しい。
これを改造して何か作りたいと思ったときに難しいと思うので、ここでの紹介は割愛する。

gridは綺麗にレイアウトできてフレキシブルだけど、これも直感的にレイアウトするのはそれなりに難しい。
同じく、これを改造して何か作りたいと思ったときに難しいと思うので、ここでの紹介は割愛する。

placeは愚直にxyでの座標指定。面倒くさいけど、理解は簡単。改造も簡単なので今回はこれにした。

なお、lblとbtnは配置されたが、まだウインドウが表示されているわけではない。

#8 ウインドウ表示

root_window.mainloop()

mainloopメソッドを実行するとウインドウが表示される。

なんでloopという名前がついてるのかというと。。
基本的にプログラムって順次・反復・分岐で出てきているので、ウインドウにボタンが配置されてそれを押したらどうなるみたいなGUIプログラムも、「順次・反復・分岐」の応用でしかない。
つまりウインドウを表示するプログラムって、反復(つまりloop)の中で絶えず入力を監視して、入力(クリック・キー入力など)があったら分岐して処理し、さらに反復(入力待ち)にもどる。専門的にはメッセージループという。
メインループというのはそれを開始するって意味。

メインループについて知りたい場合、以下の本が参考になる。もはやPython関係ないけど。
「なか見"検索」でその辺りも読めるので興味があれば。

一見単純に見える何もないウインドウが裏で色々とやってるのが分かって面白い。

Python Tkinterで画面遷移

前回作成したPythonのルーチン読み上げツールだが、GUIメニューを作ってそこから起動するような形にしてみた。
thom.hateblo.jp

動機は、将来的にツールを増やしていく場合、Bashスクリプトのダブルクリックで起動するのが面倒だから。机に立てかけたタッチスクリーンでダブルタップするのって結構難しく、よくファイル名の変更になってしまったりドラッグになってしまったりする。

そこで、メニュー画面を常時起動させて、そこでアプリケーションの切り替え操作を完結したいというのが動機。

パク参考にしたのは以下のQA。
www.reddit.com

モジュール構成

画面遷移に使用するのはメニューモジュールが一つと、未登録用のダミーアプリケーション、前回作成したお掃除ルーチンアプリケーションの3つ。他のはcleanup.pyから呼ばれるplaysound.pyがある。前回紹介してなかったのでついでにコードを掲載しておく。

  • menu.py
  • dummy.py
  • cleanup.py
    • playsound.py

お掃除ルーチンはメニューに対応させるため単体起動できなくなっている。

画面遷移のイメージ

f:id:t-hom:20190928071133p:plain

コード

menu.py

# https://www.reddit.com/r/learnpython/comments/776kd9/tkinterhow_can_i_destroyhide_the_root_window/
import tkinter as tk
from cleanup import CleanUpRoutine
from dummy import DummyApp
class FrameBase(tk.Tk):
    def __init__(self):
        tk.Tk.__init__(self)
        self.geometry("800x480")
        self.frame = StartPageFrame(self)
        self.frame.pack(expand=True, fill="both")
        #self.attributes("-fullscreen", True)

    def change(self, frame):
        self.frame.pack_forget() # delete currrent frame
        self.frame = frame(self)
        self.frame.pack(expand=True, fill="both") # make new frame

    def backToStart(self):
        self.frame.pack_forget()
        self.frame = StartPageFrame(self)
        self.frame.pack(expand=True, fill="both")

class StartPageFrame(tk.Frame):
    def __init__(self, master=None, **kwargs):
        tk.Frame.__init__(self, master, **kwargs)
        master.title("Start Page")

        self.grid(column=0, row=0, sticky=tk.NSEW)

        self.Applist = [
                    [ [CleanUpRoutine, "Clean up routine"], [DummyApp, "Not Registerd"], [DummyApp, "Not Registerd"] ], 
                    [ [DummyApp, "Not Registerd"], [DummyApp, "Not Registerd"], [DummyApp, "Not Registerd"] ]
                    ]

        lbl = tk.Label(master=self, text ="Start Page", font=("Migu 1M",14))
        lbl.grid(column=0,row=0,sticky=tk.NW, padx=10)
        btn = tk.Button(
            master = self,
            text="Close",
            width = 5,
            bg = "#dc143c",
            fg = "#ffffff",
            command=self.master.destroy)
        btn.grid(column=2, row=0,sticky=tk.NE)

        for r in range(1,3):
            for c in range(3):
                btn = tk.Button(
                        master=self,
                        wraplength=150,
                        justify=tk.LEFT,
                        text=self.Applist[r-1][c][1],
                        font=("Migu 1M", 16),
                        bg="#e6e6fa",
                        command=self.gotoApp(r-1,c))
                btn.grid(column=c, row=r, padx=10, pady=10, sticky=tk.NSEW)

        self.columnconfigure(0, weight=1)
        self.columnconfigure(1, weight=1)
        self.columnconfigure(2, weight=1)

        self.rowconfigure(1, weight=1)
        self.rowconfigure(2, weight=1)

        self.master.columnconfigure(0, weight=1)
        self.master.rowconfigure(0, weight=1)

    def gotoApp(self,r,c):
        return lambda :self.master.change(self.Applist[r][c][0])

if __name__=="__main__":
    app=FrameBase()
    app.mainloop()

dummy.py

import tkinter as tk
class DummyApp(tk.Frame):
    def __init__(self, master=None, **kwargs):
        tk.Frame.__init__(self, master, **kwargs)
        master.title("No application registered.")

        btn = tk.Button(master=self,
                text="Back",
                width=5,
                bg = "#00a4e4",
                fg = "#ffffff",
                command=self.master.backToStart)
        btn.pack(anchor=tk.NW)

        lbl = tk.Label(self, text="No application registered here.", height=5, font=("Migu 1M",20))
        lbl.pack()

cleanup.py

import tkinter as tk
import tkinter.font as font
import os
import playsound

class CleanUpRoutine(tk.Frame):
    def __init__(self, master=None, **kwargs):
        tk.Frame.__init__(self, master, **kwargs)
        self.load_resources()
        self.create_widgets()
        self.n = 0

    def load_resources(self):
        self.base = os.path.dirname(os.path.abspath(__file__))
        self.icon1 = tk.PhotoImage(file=self.base + '/next.png')
        self.icon2 = tk.PhotoImage(file=self.base + '/next_disabled.png')

    def create_widgets(self):
        #Root Setting
        self.master.title("Cleanup Routine")

        #Generate Parts
        frame1 = tk.Frame(master = self)

        quit_button = tk.Button(
            master = frame1,
            command = self.master.backToStart,
            width = 5,
            text = "Back",
            bg = "#00a4e4",
            fg = "#ffffff")

        next_button = tk.Canvas(
            master = self,
            width = 128,
            height = 128)
        self.imagearea = next_button.create_image(
                0, 0, image=self.icon1, anchor=tk.NW)
        next_button.bind("<Button-1>", self.next_clicked)

        self.var = tk.StringVar()
        my_font = font.Font(self,family="Migu 1M",size=20,weight="normal")
        lbl = tk.Label(self, textvariable=self.var, height=5, font=("Migu 1M",20))
        #Layout
        frame1.pack(fill="x")
        quit_button.pack(side="left")
        lbl.pack()
        next_button.pack()

        #Localization
        self.frame1 = frame1
        self.quit_button = quit_button
        self.next_button = next_button
        self.lbl = lbl

    def play(self, filenumber):
        txt = open(self.base + "/wav/CleanupRoutine-" + str(filenumber) + ".txt", 'r', encoding='utf-8')
        self.var.set(txt.read())
        self.master.update()
        txt.close
    
        filename=self.base + "/wav/CleanupRoutine-" + str(filenumber) + ".wav"
        playsound.playAudio(filename)

    def next_clicked(self, event):
        self.next_button.itemconfig(self.imagearea, image = self.icon2)
        self.master.update()
        n = self.n
        self.play(n)
        n = n + 1
        if n > 14:
            n = 0
        self.n = n
        self.next_button.itemconfig(self.imagearea, image = self.icon1)
        self.master.update()
playsound.py
import pyaudio
import wave

def playAudio(fname):
    CHUNK = 1024

    wf = wave.open(fname, 'rb')
    
    p = pyaudio.PyAudio()
    
    stream = p.open(format=p.get_format_from_width(wf.getsampwidth()),
                    channels=wf.getnchannels(),
                    rate=wf.getframerate(),
                    output=True)
    
    """ 
       format  : ストリームを読み書きするときのデータ型
       channels: ステレオかモノラルかの選択 1でモノラル 2でステレオ
       rate    : サンプル周波数
       output  : 出力モード
    
    """
    
    # 1024個読み取り
    data = wf.readframes(CHUNK)
    
    while data != b'':
        stream.write(data)          # ストリームへの書き込み(バイナリ)
        data = wf.readframes(CHUNK) # ファイルから1024個*2個の
    
    stream.stop_stream()
    stream.close()
    
    p.terminate()

ざっくりアプリケーション切り替えの仕組み

Tkinterでは下図のようにメイン画面に直接ボタンやラベル等のGUIパーツを配置してアプリケーションを作成できる。
f:id:t-hom:20190928065032p:plain

TkinterにはGUIパーツを纏めるフレームというものが用意されているので、今回は直接GUIを配置せずにアプリケーションのGUIは全てフレームに乗せている。こうすることで、フレームの乗せ換えによってアプリケーション画面の切り替えを実現することができる。
f:id:t-hom:20190928065237p:plain

具体的には前回の記事でcleanup.pyのクラスはtk.Tkを継承していたが、今回はtk.Frameから継承させて1枚のフレームとして構成している。

class CleanUpRoutine(tk.Frame):

実際のフレーム切り替えは、menu.pyのFrameBaseクラスのメソッド「change」で行われる。

    def change(self, frame):
        self.frame.pack_forget() # delete currrent frame
        self.frame = frame(self)
        self.frame.pack(expand=True, fill="both") # make new frame

インスタンス変数「self.frame」と、仮引数「frame」が同名なのでやや混乱するかもしれないが、これはもともと参考にしたサイトがそうだったたし、pythonのコーディングの流儀みたいなものはまだわかってないので下手にいじらないでおこうと思ったためそのままにした。PEP見ろって話だろうけど、まだ私のPythonコーディングはお試し期間みたいなものなので、そこまで本腰入れるのはもう少し後。

Pythonでルーチン読み上げツールを作ってみた

最近Pythonをかじりだした理由として、Rapsberry Piで使う目的で日常ルーチンを読み上げてくれるツールを作ったことがきっかけ。

※読み上げといってもテキスト合成ではなくて、単にwavファイルに入れておいた音声を再生するだけです。
 タイトルでがっかりさせちゃってたらすみません。

イメージとしてはこんな感じで起動してきて、再生ボタンをタップするたびに次のタスクを読み上げてくれるルーチンツール。
f:id:t-hom:20190916194425p:plain

掃除って単純なようで段取りを間違うと面倒なことになる。
そもそも普段から完璧に綺麗なら何も問題ないんだけど、物が散らかり始めると、Aを掃除中にBが気になり、Bに手を付けたら今度またAが気になりという風にデッドロックがかかる。

ので、ルーチンを設計して、それに命じられるままに掃除しようと。
コンピュータに使われる私。

まずはPythonをまじめに学習する前の私が書いたコード。思考錯誤して、なんとか動いたけど、謙遜抜きでまぁひどいコードである。
よく分からないままコピペした部分も少なからずあるので。

import os
import sys
import tkinter
import playsound
from tkinter import *
from tkinter import ttk
import tkinter.font as font

base = os.path.dirname(os.path.abspath(__file__))

def play(filenumber):
    global var
    txt = open(base + "/wav/CleanupRoutine-" + str(filenumber) + ".txt", 'r', encoding='utf-8')
    var.set(txt.read())
    root.update()
    txt.close

    filename=base + "/wav/CleanupRoutine-" + str(filenumber) + ".wav"
    playsound.playAudio(filename)

root = tkinter.Tk()
root.title("Tkinter test")
root.attributes("-fullscreen", True)
#root.geometry("800x480")

frame = tkinter.Frame(root)
frame.pack(fill="x")
button = tkinter.Button(frame, text="Quit",bg="#00a4e4",fg="#ffffff", width=5, command=root.quit)
button.pack(side="right")

n = 0
def button2_clicked(event):
    canvas.itemconfig(imagearea, image = icon2)
    root.update()
    global n
    play(n)
    n = n + 1
    if n > 14 :
        n = 0

    canvas.itemconfig(imagearea, image = icon1)
    root.update()

icon1 = PhotoImage(file=base + '/next.png')
icon2 = PhotoImage(file=base + '/next_disabled.png')

my_font = font.Font(root,family="fonts-vlgothic",size=20,weight="bold")
var = tkinter.StringVar()
lbl = tkinter.Label(root, textvariable=var, height=5, font=my_font)
lbl.pack()

canvas = tkinter.Canvas(root, width=128, height=128)
imagearea = canvas.create_image(0, 0, image=icon1, anchor=tkinter.NW)
canvas.bind("<Button-1>", button2_clicked)
canvas.pack()


root.mainloop()

色々インポートしてるけど、このうちplaysoundは私が別ファイルに書いたサウンドファイル再生用のモジュールで、それ以外は標準ライブラリ的なもの。(用語もまだあまり分かってない。)

こちらが、「やさしいPython」の「クラス」の学習まで終わってから修正したコード。

import tkinter as tk
import tkinter.font as font
import os
import playsound

class Application(tk.Frame):
    def __init__(self, master=None):
        super().__init__(master)
        self.master = master
        self.pack()
        self.load_resources()
        self.create_widgets()
        self.n = 0

    def load_resources(self):
        self.base = os.path.dirname(os.path.abspath(__file__))
        self.icon1 = tk.PhotoImage(file=self.base + '/next.png')
        self.icon2 = tk.PhotoImage(file=self.base + '/next_disabled.png')

    def create_widgets(self):
        #Root Setting
        self.master.title("Cleanup Routine")
        #self.master.attributes("-fullscreen", True)
        self.master.geometry("800x480")

        #Generate Parts
        frame1 = tk.Frame(master = self.master)

        quit_button = tk.Button(
            master = frame1,
            command = self.master.destroy,
            width = 5,
            text = "Quit",
            bg = "#00a4e4",
            fg = "#ffffff")

        next_button = tk.Canvas(
            master = self.master,
            width = 128,
            height = 128)
        self.imagearea = next_button.create_image(
                0, 0, image=self.icon1, anchor=tk.NW)
        next_button.bind("<Button-1>", self.next_clicked)

        self.var = tk.StringVar()
        my_font = font.Font(root,family="Migu 1M",size=20,weight="normal")
        lbl = tk.Label(root, textvariable=self.var, height=5, font=my_font)
        #Layout
        frame1.pack(fill="x")
        quit_button.pack(side="right")
        lbl.pack()
        next_button.pack()

        #Attributation
        self.frame1 = frame1
        self.quit_button = quit_button
        self.next_button = next_button
        self.lbl = lbl

    def play(self, filenumber):
        txt = open(self.base + "/wav/CleanupRoutine-" + str(filenumber) + ".txt", 'r', encoding='utf-8')
        self.var.set(txt.read())
        root.update()
        txt.close
    
        filename=self.base + "/wav/CleanupRoutine-" + str(filenumber) + ".wav"
        playsound.playAudio(filename)

    def next_clicked(self, event):
        self.next_button.itemconfig(self.imagearea, image = self.icon2)
        self.master.update()
        n = self.n
        self.play(n)
        n = n + 1
        if n > 14:
            n = 0
        self.n = n
        self.next_button.itemconfig(self.imagearea, image = self.icon1)
        self.master.update()


root = tk.Tk()
app = Application(master=root)
app.mainloop()

学習によって理解度も進んだので、最初のコードより幾分マシに見える。
インポート文の仕組みも分かったので余計なものをインポートするのもやめられた。

GUIは、Tkinterのサンプルコードをベースに必要なGuiパーツを肉付けしていくというやり方で作った。
実は最初のコードを書く段階ではTkinterのサンプルでクラスが使われているのでよく理解できず、クラスを使ってないサンプルを探し回って使ったんだけど、きちんと学習した後はクラスを使った方が合理的だと分かって書き直した。

ただ今度はクラスの肥大化とサウンド再生とかテキスト読み込み・出力とかがアプリケーションクラスにごちゃっと入ってるので、それはそれでメンテナンス性悪いのではという感じ。

selfの使い方も分かったけど、なんでもselfつけてとりあえずインスタンスの属性に放り込んでおけばいいや的な使い方をしてるので、「合ってるのかこの使い方?」という疑問がある。

次の課題はModel View Controllerの分離。
qiita.com

プログラミング言語はひとつマスターすれば他もできる?

プログラミングでは、ひとつの言語をマスターすれば、どんな言語でも使えると言われている。
この言説には賛否あるけど、ある意味正しくて、ある意味間違いだと思う。

より正確に言えば、新しく学ぶ言語と既にマスターしている言語に共通する概念についてはスムーズに移行できるということだ。

f:id:t-hom:20190915134705p:plain

たとえば変数・分岐・繰り返し・比較演算なんかは、大半の言語が備えている共通概念である。言語によって作法やスタイルが異なるだけで考え方は同じなので、新しく学習する言語でこれらを使いこなすのは難しくない。

仮にVBAを100%マスターしているなら、Pythonの学習範囲はPython特有の部分だけで済む。
f:id:t-hom:20190915140305p:plain

まあそうは言ってもなかなか一つの言語をマスターするのは難しい。
VBAの学習割合が少なければ、Pythonをマスターするための学習範囲はより広くなる。
f:id:t-hom:20190915141208p:plain

じゃあまずはVBAを極めよう!と考えるかもしれないがそれも早計である。
というのも、学習する概念は言語ごとに情報の充実度に差があるためだ。

たとえばVBAはクラスモジュールを使ったオブジェクト指向を一部サポートしているが、VBAでオブジェクト指向を学ぶのは難しい。最近ようやくクラスモジュールを扱う書籍が登場してきたが、まだまだごく一部の上級者向けの書籍にしか解説が無い。

Javaなら、どの入門書にもクラスからオブジェクトを作成する方法が書かれており、その概念も詳しく説明されている。
f:id:t-hom:20190915141856p:plain

ひとつの言語をマスターしようと思ったら、結局、複数の言語をつまみ食いするのが手っ取り早い。


さて、この三連休でPythonの学習を始めてみた。
実はこれまでもPythonを触る機会は何度かあったけど、本格的に入門するのはこれが初めて。

学習に使ってる書籍はこちら。

やさしいPython (「やさしい」シリーズ)

やさしいPython (「やさしい」シリーズ)

学習の際によく混乱するのは次の2点。
・VBAにあるのにPythonに無い概念→「あれ、あの機能は?」と探して時間を浪費する。
・VBAに無くてPythonにある概念→「ナニコレ?」となり理解に時間がかかる。

f:id:t-hom:20190915140824p:plain


ただまぁ、VBAにあるのにPythonに無い概念ってそうそう無くて、これまで同じサイズの円で説明してきたけど実際にはこんなイメージ。
f:id:t-hom:20190915143641p:plain

1998年から基本的な言語機能が進化してないVBAと、現在も新機能を取り込みながら進化を続ける言語を比べたら、当然後者の方が覚えることは多い。

とはいえ、VBAで獲得済みの共通概念だけでPythonコードを書くことはできるので、一応、ひとつプログラミング言語をマスターしたら他の言語でも書けるとは言える。

一方で、人のコードを読むとなると一気にハードルは上がる。ふつう、人は便利な言語機能があればそれを使うので、VBAに無い概念も遠慮なく使用されているハズで、ネットのコードをパクってきて弄ろうと思ってもまぁよく分からないと思う。

よって、その言語を使ってチームで仕事をするっていうレベルに到達するには、未獲得の概念をしっかり学習する必要がある。

つまり表題の件、趣味のレベルで良いならTrue、仕事にするならFalseというのが私の結論。

以上。

VBAで外部ツールを使わずに簡易テスト駆動開発をやってみる。

最近会社の会議でTDDが話題になった。
TDDとは何か?まずWikipediaを引用してみる。

テスト駆動開発 (てすとくどうかいはつ、test-driven development; TDD) とは、プログラム開発手法の一種で、プログラムに必要な各機能について、最初にテストを書き(これをテストファーストと言う)、そのテストが動作する必要最低限な実装をとりあえず行った後、コードを洗練させる、という短い工程を繰り返すスタイルである。

テスト駆動開発 - Wikipedia

話題になったのは、「プログラムできてないのにテストを書けるの?」ということ。
答えは「Yes」

イメージとしては成果物が満たすべき要件を、最初からテストの形式で定義する感じ。

ちょっとやってみよう。

例えば数値を与えるとExcelの列記号に変換するプログラムを考える。

成果物イメージ

以下は成果物をイメージしやすくするためのサンプルコード。
フェイクなので、1~26(A~Z)しか対応していない。

コード

Sub FakeNumToC()
    n = InputBox("数値を入力してください")
    MsgBox "列番号は" & Chr(Asc("A") - 1 + n) & "です。"
End Sub

実行結果

数値入力を求められ、
f:id:t-hom:20190811105258p:plain

入力すると列番号が表示される。
f:id:t-hom:20190811105315p:plain

ただしコードをテスト可能にするには、メインコードからロジックを分離して、ロジック部分を関数にしておく必要がある。
先ほどの成果物イメージで説明すると、以下のようなコードになる。

Sub Main()
    n = InputBox("数値を入力してください")
    MsgBox "列番号は" & FakeNumToC(n) & "です。"
End Sub

Function FakeNumToC(n) As String
    FakeNumToC = Chr(Asc("A") - 1 + n)
End Function

テスト駆動開発の準備

以下のコードがテスト駆動開発の準備。
先ほどのコードと類似しているが、一から作ったという体裁を想定している。
Fakeが付いてなかったり、戻り値が適当だったり、TestNumToCというプロシージャが追加されていたりする。

Sub Main()
    n = InputBox("数値を入力してください")
    MsgBox "列番号は" & NumToC(n) & "です。"
End Sub

Function NumToC(n) As String
    '適当な戻り値
    NumToC = "A"
End Function

Sub TestNumToC()
    'ここにテストを書いていく。
End Sub

関数のゴールをイメージする。

テスト駆動を始める前に、NumToCがどうなったら完成なのか?まずはそのゴールをイメージする。
言葉で表すと、「列番号を与えて、正しい列記号が返ってきたら完成」である。

次に正しい戻り値のケースを具体的に挙げてみる。
「1に対する"A"
2に対する"B"
3に対する"C"
4に対する"D"
5に対する"E"
…」
順番に挙げ始めるとキリがない。
だから普通は、以下のようにパターンが変化するタイミングをサンプリングする。

「1に対する"A"
2に対する"B"

26に対する"Z"
27に対する"AA"
28に対する"AB"

52に対する"AZ"
53に対する"BA"

702に対する"ZZ"
703に対する"AAA"
704に対する"AAB"

かなり泥臭い作業である。
ただこの泥臭さがテストの本質なのでそこは諦めるしかない。

テストを書く

先ほどのコードのうち、TestNumToCプロシージャにテストコードを書いて

Sub Main()
    n = InputBox("数値を入力してください")
    MsgBox "列番号は" & NumToC(n) & "です。"
End Sub

Function NumToC(n) As String
    '適当な戻り値
    NumToC = "A"
End Function

Sub TestNumToC()
    'ここにテストを書いていく。
    Debug.Assert NumToC(1) = "A"
    Debug.Assert NumToC(2) = "B"
    
    Debug.Assert NumToC(26) = "Z"
    Debug.Assert NumToC(27) = "AA"
    Debug.Assert NumToC(28) = "AB"
    
    Debug.Assert NumToC(52) = "AZ"
    Debug.Assert NumToC(53) = "BA"
    
    Debug.Assert NumToC(702) = "ZZ"
    Debug.Assert NumToC(703) = "AAA"
    Debug.Assert NumToC(704) = "AAB"
End Sub

Debug.Assert命令はFalseを与えると中断モードになる命令である。
ここではNumToC関数に引数を与えて、その戻り値と予想値(イコールの右辺)を比較している。
これで戻り値が異なった場合は、そこで停止してテスト失敗ということ。
書いてる内容は至極シンプル。ただ泥臭い作業である。

実行するとここでテスト失敗。
f:id:t-hom:20190811112659p:plain

NumToCは今のところ常に"A"を返すので、当然失敗する。
ここで失敗するというのはひとつの確認ポイントで、もし中断モードに入らなかったら何かが間違っている。
メインコードができていないのにテストにパスしたらそのテストコードがおかしいということになる。

これでテストファーストは完了。
メインのロジックであるNumToCは全然完成していない。
にもかかわらず、テストコードは書けている。

「プログラムできてないのにテストを書けるの?」
「Yes」

メインコードを書く

ここからは普通にNumToCの中身を書けば良いだけ。
ただし先にテストコードが作ってあるので、検証はすこぶる簡単。

まずは冒頭で作ったFakeNumToCのロジックを流用してみる。

Function NumToC(n) As String
    NumToC = Chr(Asc("A") - 1 + n)
End Function

テストコードを実行すると、以下で止まる。
f:id:t-hom:20190811113507p:plain


ああでもないこうでもないと弄り。。

Function NumToC(n) As String
    If n > 26 Then
        nn = n \ 26
        n = n Mod 26
        
        ret = Chr(Asc("A") - 1 + nn) & ret
    End If
    ret = Chr(Asc("A") - 1 + n) & ret
    NumToC = ret
End Function

やっぱりコケる。
f:id:t-hom:20190811114402p:plain


自前でロジック組むのを諦めてExcelのオブジェクトに頼る。

Function NumToC(n) As String
    NumToC = Split(Cells(1, n).Address, "$")(1)
End Function

これでTestNumToCを実行すると、何も起きなくなった。
実行できてるのか不安なのでテストプロシージャの最後にMsgBox "Test Finished"を入れるようにした。

テスト駆動のメリット

  • テストを先に書くことによってメインコードを書いているときに何度でもテストできるので、タイムリーに間違いを発見でき、手戻りが減らせる。
  • メインコードが書きあがってからもっとスマートにしたいと思ったときに、テストしながらできるのでロジックを壊さずに済む。
  • コードの安全性について説明可能になる。

テストケースの抽出方法

今回はテスト駆動開発の紹介がメインなので、テストケースはかなり大雑把。
専門的には同値分割・境界値分析といった技法があるので詳しく知りたい方は専門書をどうぞ。
※以下の記事で簡単には触れてます。
thom.hateblo.jp

ちなみに今回のケースであれば以下の記事にテストパターンが列挙されているのでオススメ。
www.excel-chunchun.com

以上

VBA 開発中マクロブックのバックアップを取るマクロ

開発中のマクロで、こまめに保存しておきたいケースがあったのでコードを書いてみた。
これは開発中のブックに埋め込んで利用する前提。私は標準モジュール「DevTools」に保存して使っている。

Sub BackupFile()
    'Microsoft Scripting Runtimeへの参照設定が必要
    Dim fso As FileSystemObject: Set fso = New FileSystemObject
    With ThisWorkbook
        Dim f As File: Set f = fso.GetFile(.FullName)
        Dim backupFolderPath As String: backupFolderPath _
            = .Path & "\backup_" & Left(f.Name, Len(f.Name) - Len(fso.GetExtensionName(f.Path)) - 1)
        If Not fso.FolderExists(backupFolderPath) Then fso.CreateFolder backupFolderPath
        .SaveCopyAs backupFolderPath & "\" & Format(Now, "yyyymmddhhMMss") & "_" & .Name
    End With
End Sub

実行すると、そのブックと同じパスにbackup_[ブック名(拡張子除く)]というフォルダーが作成され、編集中のブックのコピーが保存される。

ファイル名は秒単位なので1秒に複数回実行すると古いコピーが上書きされてしまうことに注意。
(そんな頻度で実行するシチュエーションは無いと思うが)

いつもなら少し大掛かりなマクロではGitHubを使ってバージョン管理するんだけど、今回はブック本体に公開できない情報を含むので、ファイルのコピーを残すという原始的なバックアップに頼ることにした。

このくらいのコードならTwitterに投稿しようとしたら文字数限界だったので記事にした次第。

Excel VBAでスクロール可能なフォームのアイデア

Excel VBAでフォームを作る際に、項目が多くてウインドウの高さに収まりきらないことがある。
そこで今回はスクロールによってフォーム全体をウインドウに収めるアイデアを紹介する。

言葉では何がやりたいのか伝わりにくいと思うのでまずは動作イメージから。

動作イメージ

f:id:t-hom:20190505103218g:plain

注意事項

あくまでアイデアなので、実用化にはまだ色々と工夫が必要になる。
今のままではデータの取り出しすらままならないのだが、本記事のコードを読める人なら自分で実装できると思われる。

コード

クラスモジュール「FieldLocator」

まずはクラスモジュールを挿入し、オブジェクト名をFieldLocatorとする。
このFieldLocatorはデータフィールド用のテキストボックスとそのラベルを生成し、座標を管理するオブジェクトだ。
最初のFieldLocatorから次のFieldLocatorへ、そこからまた次のFieldLocatorへと参照が張られ、とリスト構造(数珠状)でつながる仕組み。
1カラムのテキストボックス、2カラムのテキストボックスのみ対応している。

コードはこちら。

'DEBUG_MODEはラベルを配置するときに色が付いてないとサイズが分かりづらいので作った。
'Falseにすると色付け無しになる。
#Const DEBUG_MODE = False
Private top_ As Long
Public Left As Long
Public FieldName1 As String
Public FieldName2 As String
Public LabelWidth As Integer
Public LineCount As Integer
Public L1 As MSForms.Label, L2 As MSForms.Label
Public T1 As MSForms.TextBox, T2 As MSForms.TextBox

'すべてのFieldLocatorは最初のアイテムからNextItemを辿って数珠状につながる想定。
'これにより、最初のアイテムのTopプロパティーを変更すると、連鎖的に他のアイテムのTopプロパティーも変わる。
'つまり全てのコントロールが最初のアイテムに連動して動くので、スクロールバーで動かすのは最初のアイテムだけでOKになる。
Public NextItem As FieldLocator

Const FONT_SIZE = 12, FONT_HEIGHT = 15, FONT_MARGIN = 6

Private Sub Class_Initialize()
    Me.LineCount = 1
    Me.LabelWidth = 100
End Sub
Public Property Get Self() As FieldLocator
    Set Self = Me
End Property

Public Function CreateNext() As FieldLocator
    With New FieldLocator
        .Top = Me.Bottom + 10
        .Left = Me.Left
        .LabelWidth = Me.LabelWidth
        Set NextItem = .Self
        Set CreateNext = NextItem
    End With
End Function

Public Property Let Top(t_ As Long)
    top_ = t_
    If Not T1 Is Nothing Then
        CCnt(T1).Top = t_
        CCnt(L1).Top = CCnt(T1).Top + 2
        If Not T2 Is Nothing Then
            CCnt(T2).Top = CCnt(T1).Top
            CCnt(L2).Top = CCnt(L1).Top
        End If
    End If
    If Not NextItem Is Nothing Then
        NextItem.Top = Me.Bottom + 10
    End If
End Property
Public Property Get Top() As Long
    If T1 Is Nothing Then
        Top = top_
    Else
        Top = CCnt(T1).Top
    End If
End Property
Public Property Get Bottom() As Long
    Bottom = CCnt(T1).Top + CCnt(T1).Height
End Property

Sub AddInputBox(f As UserForm)
    Set L1 = f.Controls.Add("Forms.Label.1")
    L1.Font.Name = "Meiryo UI"
    L1.Font.Size = FONT_SIZE
    CCnt(L1).Height = FONT_HEIGHT + FONT_MARGIN
    CCnt(L1).Width = LabelWidth
    CCnt(L1).Left = Left
    L1.TextAlign = fmTextAlignRight
    L1.Caption = FieldName1
#If DEBUG_MODE Then
    L1.BackColor = rgbLightYellow
#End If
    
    Set T1 = f.Controls.Add("Forms.Textbox.1")
    T1.BorderStyle = fmBorderStyleSingle
    T1.Font.Name = "Meiryo UI"
    T1.Font.Size = FONT_SIZE
    T1.MultiLine = LineCount > 1
    T1.ScrollBars = fmScrollBarsVertical
    T1.EnterKeyBehavior = LineCount > 1
    CCnt(T1).Height = FONT_HEIGHT * LineCount + FONT_MARGIN
    CCnt(T1).Width = 450
    CCnt(T1).Left = CCnt(L1).Left + CCnt(L1).Width + 10
    CCnt(T1).Top = top_
    
    CCnt(L1).Top = CCnt(T1).Top + 2
End Sub

Sub AddTwinInputBox(f As UserForm)
    Set L1 = f.Controls.Add("Forms.Label.1")
    L1.Font.Name = "Meiryo UI"
    L1.Font.Size = FONT_SIZE
    CCnt(L1).Height = FONT_HEIGHT + FONT_MARGIN
    CCnt(L1).Width = LabelWidth
    CCnt(L1).Left = Left
    L1.TextAlign = fmTextAlignRight
    L1.Caption = FieldName1
#If DEBUG_MODE Then
    L1.BackColor = rgbLightYellow
#End If

    Set L2 = f.Controls.Add("Forms.Label.1")
    L2.Font.Name = "Meiryo UI"
    L2.Font.Size = FONT_SIZE
    CCnt(L2).Height = FONT_HEIGHT + FONT_MARGIN
    CCnt(L2).Width = LabelWidth
    L2.TextAlign = fmTextAlignRight
    L2.Caption = FieldName2
#If DEBUG_MODE Then
    L2.BackColor = rgbLightYellow
#End If
    
    Set T1 = f.Controls.Add("Forms.Textbox.1")
    T1.BorderStyle = fmBorderStyleSingle
    T1.Font.Name = "Meiryo UI"
    T1.Font.Size = FONT_SIZE
    T1.MultiLine = LineCount > 1
    T1.ScrollBars = fmScrollBarsVertical
    T1.EnterKeyBehavior = LineCount > 1
    CCnt(T1).Height = FONT_HEIGHT * LineCount + FONT_MARGIN
    CCnt(T1).Width = (450 - 20 - LabelWidth) / 2
    CCnt(T1).Left = CCnt(L1).Left + CCnt(L1).Width + 10
    CCnt(T1).Top = top_
    
    CCnt(L1).Top = CCnt(T1).Top + 2
    CCnt(L2).Left = CCnt(T1).Left + CCnt(T1).Width + 10
    CCnt(L2).Top = CCnt(L1).Top
    
    Set T2 = f.Controls.Add("Forms.Textbox.1")
    T2.BorderStyle = fmBorderStyleSingle
    T2.Font.Name = "Meiryo UI"
    T2.Font.Size = FONT_SIZE
    T2.MultiLine = LineCount > 1
    T2.ScrollBars = fmScrollBarsVertical
    T2.EnterKeyBehavior = LineCount > 1
    CCnt(T2).Height = FONT_HEIGHT * LineCount + FONT_MARGIN
    CCnt(T2).Width = CCnt(T1).Width
    CCnt(T2).Left = CCnt(L2).Left + CCnt(L2).Width + 10
    CCnt(T2).Top = top_
End Sub

Private Function CCnt(o As Object) As MSForms.Control
    Set CCnt = o
End Function

フォームモジュール

ユーザーフォームを挿入し、次のコードを張り付ける。
フォームのオブジェクト名は任意で良い。

Dim FirstField As FieldLocator
Private WithEvents ScrollBar1 As MSForms.ScrollBar
Private Sub ScrollBar1_Change()
    'ScrollBarはマイナス値をMaxに設定すると、増分がマイナスになるので、
    '次のようにシンプルに書ける。
    FirstField.Top = ScrollBar1.Value
End Sub

Private Sub UserForm_Initialize()
    Set ScrollBar1 = Me.Controls.Add("Forms.ScrollBar.1")
    Const GOLDEN_RATIO = 1.618
    Me.Width = 800 '所有してるモニタの最低解像度の横幅が800(SVGA)なので。ちなみに最高は1920。
    Me.Height = Me.Width / GOLDEN_RATIO '黄金比を用いて収まりよく。
    ScrollBar1.Width = 20
    ScrollBar1.Left = Me.Width - 25
    ScrollBar1.Height = Me.Height - 22
    ScrollBar1.SmallChange = 5
    ScrollBar1.LargeChange = 20
    With New FieldLocator
        '最初の作成で設定する
        Set FirstField = .Self
        .Top = 30
        ScrollBar1.Min = .Top
        .Left = 100
        .LabelWidth = 80
        
        '以降の設定項目は共通
        .FieldName1 = "件名"
        .LineCount = 1 'LineCountは省略すると1になる。
        .AddInputBox Me 'これは1カラムの入力フィールドを追加するコマンド
        
    '以降はCreateNextのメソッドチェーンでFieldLocatorを数珠状に作っていく。
    With .CreateNext
        .FieldName1 = "概要"
        .LineCount = 2
        .AddInputBox Me
    With .CreateNext
        .FieldName1 = "詳細"
        .LineCount = 10
        .AddInputBox Me
    With .CreateNext
        '2カラムの入力フィールドの場合はこうする。
        .FieldName1 = "開始日"
        .FieldName2 = "有効期限"
        .AddTwinInputBox Me
    With .CreateNext
        .FieldName1 = "担当者"
        .AddInputBox Me
    With .CreateNext
        .FieldName1 = "連絡先"
        .AddInputBox Me
    With .CreateNext
        .FieldName1 = "備考欄"
        .LineCount = 10
        .AddInputBox Me
    With .CreateNext
        .FieldName1 = "その他"
        .AddInputBox Me
        
        '最後の要素のBottomを利用してScrollBarのMax値
        '(プロパティ名はMaxだが、実際にはマイナス値なので最小)を決定する。
        ScrollBar1.Max = Me.Height - .Bottom - 30
    
    'メソッドチェーンが終わると、残念ながら大量のEnd With文
    End With: End With: End With: End With: End With: End With: End With: End With
End Sub

フォーム自体を除き、パーツはすべてコードで生成する為、フォーム上には何も配置しなくて良い。
サイズもコードで指定するのでそのままで良い。
フォームを起動すると冒頭の動作イメージのとおり操作できる。

あとがき

Access Formを使えばこんなことは朝飯前なんだけど、Excelにちょっとしたフォームが欲しくなることもある。
標準のフォーム機能ってのもあった気がするけど、使い勝手がとても悪いので、自作を試みているところ。

ちょっとしたフォームにしては作り方が大掛かりに見えるかもしれないが、使いまわしができ、デザインの微調整が必要なく、単に項目を指定していくだけで完成する汎用的なフォーム作成が目的なので、この手間は仕方がない。

おまけ

以下はスクロールバーをMin 0% Max 100%のパーセンテージで管理しようともがいてる図。
f:id:t-hom:20190505105800p:plain
結局、Maxをマイナス値にするとマイナスに向かって進むことが判明し、コードをよりシンプルに出来るのでパーセンテージ管理は没になった。


以下はHeight Managerというオブジェクトでラベルとテキストボックスを管理し、双方向ポインタで結ぶことで先頭を動かすと全体が動く仕組みを作ろうとした図。
f:id:t-hom:20190505105828p:plain
このアイデアは別の形(FieldLocator)で実現した。実際には片方向ポインタで十分だった。
上下のボーダーを跨ぐとコントロールを非表示にするアイデアだったが実装に至っていない。

現在はスクロールによってフォームの上端・下端でコントロールが見えなくなるが、本当はZOrderをトップにしたラベルの下に潜り込ませて、完全に潜ったらHiddenにしようと考えている。ちょっと管理すべき項目が多くて混乱中。

当ブログは、amazon.co.jpを宣伝しリンクすることによってサイトが紹介料を獲得できる手段を提供することを目的に設定されたアフィリエイト宣伝プログラムである、 Amazonアソシエイト・プログラムの参加者です。