クリップボード活用のススメ

クリップボードはアプリケーション間でデータのやり取りを行うときに非常に重宝します。

クリップボードの中には様々なデータが格納されます。AppleScript に対応していないアプリケーションでもコピー & ペーストは使えることがほとんどなので、

  1. 対象アプリケーションでデータをコピー
  2. AppleScript でデータを加工
  3. クリップボードに加工したデータを格納
  4. 対象アプリケーションでペースト

と、このようにクリップボードを介して AppleScript を利用することがあります。その昔、Script Editor が AppleScript に対応していなかったとき、よく使っていました。また、TextEdit は AppleScript に対応していますが、選択範囲を取得できないのでクリップボードに選択範囲をコピーし、クリップボード内のデータを加工し、TextEdit でペーストするということもよく行います。

使いようによっては便利なクリップボード。そのクリップボードの中身を調べるとなかなか興味深いものがあります。AppleScript でクリップボードを扱う命令は、StandardAdditions.osax が提供していて、以下の命令があります。

  • clipboard info(クリップボードの情報を取得)
  • the clipboard(クリップボードのデータを取り出す)
  • set the clipboard to(クリップボードにデータを格納する)

AppleScript は文字列をバイトで数えるのではなく、多バイト文字でも一文字は一文字として数えます。そうではなく、単純にバイト数を知りたいときにクリップボードは使えます。

Script Editor で開く

set theText to "文字列"
set the clipboard to (theText as text)
clipboard info
--> {{string, 6}}

string クラスで 6 バイトになります。どうしてリストの中にリストがあるのかというと、クリップボードは複数のデータを保管するからです。例えば、修飾された文字列をコピーしたなら、修飾された文字列のデータと修飾されていない文字列のデータ、また、ユニコード文字列のデータ、ユニコードでも UTF-8 や UTF-16 などの異なったエンコードの文字列データ...これらがクリップボードに一緒に保管されます。複数のデータが入っているので対応アプリケーションでペーストしたときに適切なデータが取り出されて貼り付けられます。

クリップボードは様々なデータが扱えます。それらのデータの中に目的のデータがあるかどうかを調べるには clipboard info 命令の for オプションを使います。for オプションの引数に目的のデータ型を指定します。

Script Editor で開く

clipboard info for string
--> {{string, 61}}

目的のデータがあれば、このような結果になります。ない場合は空のリストが返ってきます。

Script Editor で開く

on dataIsInClipboard for dataType
    return (clipboard info for dataType) is not {}
end dataIsInClipboard

このようなハンドラにしておけば、目的のデータが存在するかどうかを調べることができます。目的のデータを取り出すには、the clipboard 命令を使います。しかし、このままでは目的のデータを取り出すことはできないので、as を使って取り出します。

Script Editor で開く

set str to the clipboard as Unicode text
class of str
--> Unicode text

set str to the clipboard as string
class of str
--> string

set str to the clipboard as «class RTF »
class of str
--> «class RTF »

Script Editor 上で編集中のドキュメントの文字列をコピーして実行してみました。このように as で指定することでクリップボードに格納されている複数のデータから目的のものを取り出すことができます。これも、以下のようなハンドラにしておきます。

Script Editor で開く

on dataFromClipboard for dataType
    return the clipboard as dataType
end dataFromClipboard

さて。ここからが、応用。いろんなアプリケーションでいろんなデータをコピーして clipboard info 命令で返ってくる結果を見てみると、«» という括弧で囲まれたデータ型が返ってくることがあります。これらは、AppleScript で定義されていないデータ型です。以下、その一部。

  • «class ut16»
  • «class utf8»
  • «class RTF »
  • «class rtfd»
  • «class hfs »
  • «class furl»
  • «class urln»
  • «class PDF »
  • «class icns»
  • «class moov»

パッと見て、どんなデータか概ね想像がつくと思います。これらのデータ型は AppleScript で扱える(加工できる)ものもあり、そうでないものもあります。文字列ならそのまま扱える場合もあります。画像に関するデータ型もありますが、これらは as として型を変換することはできません。

ほとんどのデータ型は AppleScript で加工できないのですが、ファイルに書き出すことはできます。例えば、QuickTime Player で動画の一部分をコピーするとクリップボードには «class moov» というデータが格納されます。このデータを write 命令で書き出すと QuickTime の mov ファイルを作ることができます。

Script Editor で開く

on run
    set theFile to (path to desktop as Unicode text) & "tmp.mov"

    if (dataIsInClipboard for «class moov») then
        set theData to dataFromClipboard for «class moov»
        set errInfo to {|number|:0, |error message|:""}
        set errRef to a reference to errInfo
        if not (writeFile(theFile, theData, errRef)) then
            return errInfo
        end if
    end if
end run

on dataIsInClipboard for dataType
    return (clipboard info for dataType) is not {}
end dataIsInClipboard

on dataFromClipboard for dataType
    return the clipboard as dataType
end dataFromClipboard

on writeFile(theFile, theData, errRef)
    try
        set fh to open for access file theFile with write permission
        set eof fh to 0
        write theData to fh starting at eof
        close access fh
        return true
    on error errMsg number errNum
        try
            close access fh
        end try
        set contents of errRef to {|number|:errNum, |error message|:errMsg}
        log {errNum, errMsg}
        return false
    end try
end writeFile

AppleScript の write 命令で書き出したファイルは TextEdit で開くように関連づけられている(「iTunes のアートワークをファイルに書き出す」参照)ので、クリエータータイプとファイルタイプを変更する必要があります。

ちなみに、«class RTF » を拡張子 rtf のファイルに書き出すと RTF 書類として保存できます。iCal のイベントをコピーするとこのクラスが返ってきます。ファイルに書き出すことでイベントの一覧が簡単にファイルに出力できます。Mail のメッセージ(件名や差出人が表示されている部分)をコピーすると «class rtfd» が返ってきます。RTFD のデータなのですが、RTFD はパッケージ形式のファイルなのでそのまま書き出しても開くことはできないファイルになります。RTFD ファイルとして開けるようにするには面倒なので、省略。

このようにアプリケーションで選択した項目がどのようなデータ型でクリップボードに入っているかが分かると RTF やテキストファイル、その他のファイルとして保存することが可能です。

Preview で開いている PDF ファイルは選択範囲を指定してコピーすることができます。このとき、クリップボードには «class PDF » のデータが格納されます。これを write 命令で PDF ファイルで書き出せば、選択範囲をファイルに保存することができます。

Finder で項目をコピーします。すると、クリップボードにはファイルの名前やパスといった情報とともにアイコンデータ(«class icns»)も格納されます(複数項目のコピーではアイコンデータは格納されません)。もちろん、このデータもファイルに書き出すことでアイコンファイルとして保存できます。また、Preview.app で表示することも可能です。

Script Editor で開く

on run
    pictureFromClipboardAtPreview()
end run

on pictureFromClipboardAtPreview()
    if not (dataIsInClipboard for «class icns») then return

    tell application "Preview" to activate

    tell application "System Events"
        tell process "preview"
            try
                keystroke "n" using command down
            end try
        end tell
    end tell
end pictureFromClipboardAtPreview

on dataIsInClipboard for dataType
    return (clipboard info for dataType) is not {}
end dataIsInClipboard

Finder の項目をコピーしたとき、アイコンのデータと一緒に PICT データもクリップボードに格納されるのですが、こちらは 32 x 32 のサイズの画像が格納されるのでアイコンを抜き出すという目的では使えません。

このクリップボードの内容を Preview で表示するというのは iTunes のアートワークでも使えます。

Script Editor で開く

tell application "iTunes"
    activate
    set thisTrack to current track

    if (exists artworks of thisTrack) then
        repeat with thisArtwork in artworks of thisTrack
            set pictData to data of thisArtwork
            set the clipboard to pictData

            my pictureFromClipboardAtPreview()
        end repeat

        beep 2
    end if
end tell

on pictureFromClipboardAtPreview()
    if not (dataIsInClipboard for picture) then return

    tell application "Preview" to activate

    tell application "System Events"
        tell process "preview"
            try
                keystroke "n" using command down
            end try
        end tell
    end tell
end pictureFromClipboardAtPreview

on dataIsInClipboard for dataType
    return (clipboard info for dataType) is not {}
end dataIsInClipboard

アプリケーションが返す picture データをそのまま set the clipboard to 命令でクリップボードに格納するだけです。

read 命令でファイルを画像データとして書き出し、それを set the clipboard to 命令でクリップボードに格納し、アプリケーションでペーストするということもできます。

Script Editor で開く

set theData to read (choose file without invisibles) as TIFF picture
set the clipboard to theData

ただし、これは TIFF か PICT だけに限られます。アプリケーション間での画像データのやり取りは、どうもこれらのファイル形式に限られているようで(どんな形式の画像でもクリップボードの中では PICT、または、PICT と TIFF に変換されて保持される)。

では、最後。Finder では選択項目をコピーして別の場所にペーストすることで複製を作ることができます。このときクリップボードには、ファイルのパスが格納されます。«class hfs » と «class furl» がそうです。前者のパスは AppleScript で作ることはできませんが、«class furl» は以下のように作ることが可能です。

Script Editor で開く

set theFile to POSIX path of (choose file without invisibles)
set theFile to theFile as POSIX file
set the clipboard to theFile

選択したファイルを POSIX file に変換します。そして、クリップボードに格納します。この後 Finder でどこかの場所にペーストすれば、そのファイルが複製されます。

ただし、この方法では Finder のように複数の項目をコピーすることはできません。もし、複数の項目をコピーしたいなら、以下のような方法があります。

Script Editor で開く

set fileList to choose file with multiple selections allowed without invisibles
set tmp to {}
repeat with thisItem in fileList
    set end of tmp to POSIX path of thisItem
end repeat

tell application "Automator"
    set preferred type of pasteboard "general" to "file names"
    set contents of pasteboard "general" to tmp
end tell

いちいち Automator を起動するのが嫌というときは Xcode でダミーのスクリプタブルアプリケーションを作ってもいいでしょう。実際、ダミーのスクリプタブルアプリケーションって使い道が多々あって重宝します。最近、hetimaの日記 - Xcode の AppleScript で call method でも触れられていましたが、なんといっても call method が使えるようになります。

一応、ダミーのスクリプタブルアプリケーションの作り方を書いておくと、

  1. Xcode で AppleScript Application の新規プロジェクトを作る
  2. Info.plist を開き NSBGOnly = 1 を追加する
  3. ビルドする
  4. 完成

となります。バックグラウンドで動作するので邪魔になりません。で、先のコードを以下のように修正。

Script Editor で開く

set fileList to choose file with multiple selections allowed without invisibles
set tmp to {}
repeat with thisItem in fileList
    set end of tmp to POSIX path of thisItem
end repeat

tell application "Dummy"
    set preferred type of pasteboard "general" to "file names"
    set contents of pasteboard "general" to tmp
    quit
end tell

hetimaの日記 - Xcode の AppleScript で call method では、AppleScriptKit.framework をプロジェクトにリンクし、NSAppleScriptEnabled = YES を Info.plist に追加していますが、AppleScript Application はどちらも最初から設定されています。もちろん、その他のプロジェクトで作成するアプリケーションに AppleScript の機能を追加するというのであれば、hetima さんのように行う必要があります。このダミーのアプリケーションは非常に便利なので AppleScript 使いの少数の人間だけで共有されていたトップシークレット(嘘)だったのですが、hetima さんの記事によって広まってしまいますね。

Automator を使って楽をする?

いまいち盛り上がりに欠ける気がしますね、Automator。実際、Automator 関連の記事を書いたにもかかわらず、あまり使うことがありません。その目的、コンセプトを見ていると「なるほど、使えると便利なんだな」と思うのですが...。

「Automator を使ったら自動化ができて便利だよ」
「へー、そうなんだ」
「...」
「で、Automator って何ができるの?」

そう、Automator って何ができるか分からない。自動化って何?

「たとえば、『ブログに掲載する画像をリサイズする』ということをどうやっている?」
「Photoshop Elements で画像を開いて大きさを変更して、Web 用に保存してそれをブログにアップロードして...」
「結構、面倒だね」
「うん、面倒」
「そういう面倒な処理を自動でやってくれるのが Automator なんだ」
「へー。やってみて」
「画像リサイズのアクションを置いて、ファイルをアップロード...あれ?アクションがない?」
「できねーじゃん!」

できないことも多いのです。

「ここにたくさんのファイルがあるんだけど...このファイルの名前を変えたいの。でも、数が多いから手でやるのは面倒で...なんかいい方法ない?」
「ああ、こういうときこそ Automator」
「Automator でできるの?」
「もちろん」
「じゃ、お願い」
「『クリスマスパーティーaxcsw051224.jpg』『ハイキングxsdJKf061023.jpg』『ハイキングxsQecdL061023.jpg』『ハイキングweFvbSg061023.jpg』(以下続く)...何この奇妙奇天烈なファイルの名前?」
「うん、そのファイルの名前からアルファベット小文字だけを取り除きたいの」
「...」
「正規表現も使えないのかよ!」

(「シェルスクリプトを実行」、「AppleScript を実行」アクションを使えばできますが)使えません。

「Automator でできるっていつもいうけど、ほとんどできないじゃない!」 「いや、もっと普遍的で一般的な処理なら...」 「なによ!その一般的な処理って!私がしたいのは、私の携帯のカレンダーとアドレスを Mac の iCal とアドレスブックと同じものにしたいってことだけじゃない!」

そう。自動化したい処理って同じ処理でも微妙に要求が違います。使う人の数だけあるといってもいいでしょう。いまのところ Automator では(もしかしたら最も望まれていることかもしれない)微妙な要求に応えてくれることはありません。

「アクションの数が多くてどれがどれだか...」
「そういうときは、ほら、ウィンドウの上のところにあるテキストフィールドに検索したい言葉を入力すれば、アクションを探すことができるよ」
「そうなの?えーと、『画像』と」
「...」
「アクションがないわね」
「『画像』じゃなく『イメージ』で検索するんだよ...」
「なんで?一緒じゃない?私は、日本人よ!馬鹿にするな!」

Automator の検索は賢くありません。

「『Finder 項目に Spotlight コメントを追加』って便利ね。Spotlight と組み合わせれば、簡単にファイルが見つかっちゃう」
「(よかったと思いつつ)Automator も捨てたもんじゃないでしょ?」
「でも、コメントに何を書いたか忘れちゃうのよね。書いたコメントを一覧表示してくれるアクションってないの?」
「...」
「あまり、期待していなかったわ。じゃ、コメントを追加するのを勝手にやってくれるアクションってないの?コメントいちいち追加するのが面倒で...」
「...」
...頬を撫でる一陣の風。今日も冷え込みは厳しい。

ワークフローのご利用は計画的に。

AppleScript とデザインパターン (10)

今回は、『オブジェクト指向における再利用のためのデザインパターン』の中から State パターンを取り上げます。以前に Strategy パターンを取り扱ったことがありますが、State パターンは Strategy パターンと同じクラス図、同じ実装方法になるデザインパターンです。

状態に応じて処理を変更する...例えば、iTunes の再生/一時停止のボタン。これは、一つのボタンですが、再生中に押すと一時停止になり、一時停止中に押すと再生になります。一つのボタンで複数の状態が表現されています。このようなボタンをプログラムで書くとなると...まずは、以下のようなコードを思いつくと思います。

property playing : false

if playing then
    stop()
    set playing to false
else
    play()
    set playing to true
end if

条件分岐による状態の変更ですね。再生と一時停止だけなのでそれほど複雑になるわけではないですが、時間によって異なる挨拶を返すようなプログラムでは、「おはよう」、「こんにちは」、「こんばんは」の 3 つが必要になります。返す挨拶が増えると分岐も増えます。

State パターンでは状態をオブジェクトで表現し、状態オブジェクトを交換することで異なった処理を行います。具体的には if 文で分岐しているそれぞれの処理を状態オブジェクトととして分割します。

script Morning
    on showMessage()
        return "おはよう"
    end showMessage
end script

script Daytime
    on showMessage()
        return "こんにちは"
    end showMessage
end script

script Night
    on showMessage()
        return "こんばんは"
    end showMessage
end script

朝、昼、晩の挨拶をオブジェクトにします。これらを交換することで、それぞれの状態に対応します。

Script Editor で開く

script Greeting
    property currentState : Morning

    on setState(theObject)
        set currentState of me to theObject
    end setState

    on showMessage()
        currentState's showMessage()
    end showMessage
end script

on run
    tell Greeting
        showMessage()
        --> "おはよう"
        setState(Daytime)
        showMessage()
        --> "こんにちは"
        setState(Night)
        showMessage()
        --> "こんばんは"
    end tell
end run

このような感じです。...あれ?サンプルのコードが変わっただけで Strategy パターンと同じでは...?

全くその通りで...どうしましょう。えーっと『Strategy パターンはアルゴリズムの交換を目的とし、State パターンは状態を動的に変更したいというときに使うもので、Strategy パターンとは概念が違います』。...え、違いってそれだけ?

これで、State パターンが終わったら何とも釈然としない。ちゃんと、Strategy パターンとの際を明確にしましょう。まず、State パターンは、状態を表すオブジェクトが存在します。これ(ら)を State オブジェクトといいます。そして、State オブジェクトを保持し、現在の状態を表すとともに状態の動作を呼ぶ出す役目を持つ Context オブジェクトが存在します。利用者はこの Context オブジェクトにアクセスします。

上記の挨拶でいえば、Morning、Daytime、Night が State オブジェクトにあたり、Greeting が Context にあたります。利用者(run ハンドラ)は、Greeting の showMessage() を呼び出します。Greeting は、自分が保持している State オブジェクトによって適切な挨拶を返します。

State パターンは、このように状態によって振る舞いが変化し、状態の遷移(挨拶は時間が経つと変わる)を表現するときに利用するといいデザインパターンです。

iTunes は playpause という命令を持っています。再生中のときは一時停止、一時停止のときは再生になります。この命令を模してみます。再生中の状態と一時停止中の状態。まず、この 2 つの状態オブジェクトを作ります。

Script Editor で開く

on Playing()
    script
        on action(theObject)
            tell application "iTunes" to pause
            tell theObject to setState(pausedObject)
        end action
    end script
end Playing

on Paused()
    script
        on action(theObject)
            tell application "iTunes" to play
            tell theObject to setState(playingObject)
        end action
    end script
end Paused

それぞれ、action() ハンドラを持っていて、Playing の時は iTunes で一時停止を行います。Paused のときは、再生を行います。そして、次の状態として他方を登録するようにします。

状態を保持する Context オブジェクトの役割を担う Player というオブジェクトを追加します。

Script Editor で開く

on Player(stateObject)
    script
        property currentState : stateObject

        on setState(theObject)
            set currentState of me to theObject
        end setState

        on playpause()
            tell currentState of me to action(me)
        end playpause
    end script
end Player

状態を登録する setState() というハンドラと playpause() というハンドラがあります。playpause() ハンドラでは登録されている状態の action() ハンドラを自分を引数にして呼び出します。

これらを利用する側は以下のようなコードを記述します。

Script Editor で開く

property playingObject : missing value
property pausedObject : missing value
property playerObject : missing value

on run
    initialize()
    playerObject's playpause()
end run

on reopen
    playerObject's playpause()
end reopen

on idle
    tell application "iTunes"
        set theState to player state
        if theState is paused or theState is stopped then
            playerObject's setState(pausedObject)
        else if theState is playing then
            playerObject's setState(playingObject)
        end if
    end tell

    return 1
end idle

on initialize()
    set playingObject to Playing()
    set pausedObject to Paused()

    tell application "iTunes"
        set theState to player state
        if theState is paused or theState is stopped then
            set playerObject to my Player(pausedObject)
        else if theState is playing then
            set playerObject to my Player(playingObject)
        else
            set playerObject to my Player(pausedObject)
        end if
    end tell
end initialize

最初に iTunes の現在の状態を調べて Player オブジェクトを作っています。あとは、Player オブジェクトの playpause() ハンドラを呼び出せば、iTunes の playpause と同じ動作をします。idle ハンドラでは iTunes の状態を調べてオブジェクトを再登録しなおしています。

しかし、関係のないところでつまずきました。iTunes の player state 属性って Script Editor 上では as で文字列に変換できるのですが、アプリケーションで保存したら変換できないのですね。おかげで if 文のどこにも引っかかりませんでした。今まで文字列に変換して使っていて大丈夫だから気がつかなかった...。まったく...なんで、エラーになるのかなとさんざん悩みました。

このサンプルでは状態を状態自身が次の状態として変更していますが、状態はどこで変更しても構いません。実際、idle ハンドラの中で変更しています。さて、このサンプルで Strategy パターンとの違いを分かっていただけたでしょうか?あまり自信がありませんが...。

iTunes のアートワークをファイルに書き出す

個人的には人気アプリケーションは、

  1. iTunes
  2. Safari
  3. Finder

だと思います。人気アプリケーションといっても AppleScript の話題で触れられることの多いアプリケーションです。AppleScript で検索するとよく見る話題が上記のアプリケーション関連。このサイトでは触れることのないアプリケーションともいえます。既に書かれているのにわざわざ書くこともないよな...と思って触れることがなかったのでした。同様に知っていても書かない話題は多数あります。例えば、AppleScript の日本語問題とか。

が、ちょっと考えを改めました。気づいたというか。これだけ書かれているということは、要求があるんだということに。そこで、少しは、軟派な話題でも取り扱ってみるか、と(エラソーに...)。

で、iTunes。iTunes 7 になってからアートワークを自動でダウンロードして取得できるようになりました。取得したアートワークは ~/Music/iTunes フォルダ(デフォルト)の中にある Album Artwork フォルダの階層深いところに保管されているようです。

このダウンロードされたアートワークをファイルに書き出すのが目的です。まずは、iTunes で現在再生中の曲のアートワークを取得してみます。

Script Editor で開く

tell application "iTunes"
    try
        set thisTrack to current track
    on error errMsg number errNum
        -- 再生中でないと current track は取得できないので
        return
    end try

    tell thisTrack
        if exists artworks then
            return artworks -- アートワークの取得
        end if
    end tell
end tell

最初のエラートラップは、ちゃんと調べるのが面倒だから仕掛けているだけでたいした意味はありません。現在再生中の曲を取得して、その曲のアートワークを調べています。一つの曲に複数のアートワークを設定できるので複数形です。アートワークが設定されているかどうかは exists で調べることができます。

AppleScript から調べている限り、ダウンロードされたアートワークか、自分で設定したアートワークかといったことはあまり気にする必要がないようです。どちらのアートワークでも data 属性を取得できるようですし。つまり、アートワークが設定されているかどうかを調べればいいだけで、ダウンロードされて保管されている拡張子 itc のファイルにアクセスする必要はないのです。もちろん、itc から画像を取得したいとなると別ですが、その場合は、こちらの記事を参照するといいと思います。

アートワークのファイルタイプが JPEG や PNG でも data 属性で取得できるのは picture、つまり、PICT 形式です。だから、ファイルタイプはなにかといったことも気にする必要がなく、単純に write 命令でファイルに書き出せばいいようです。

Script Editor で開く

tell application "iTunes"
    try
        set thisTrack to current track
    on error errMsg number errNum
        -- 再生中でないと current track は取得できないので
        return
    end try

    tell thisTrack
        if exists artworks then
            set thisArtist to artist
            set thisAlbum to album
            set desktopFolder to path to desktop as Unicode text
            repeat with i from 1 to count artworks
                set fileName to thisArtist & "_" & thisAlbum & "_" & (i as string)
                set theFile to (desktopFolder & fileName & ".pict") as Unicode text
                set theResult to my saveAsPICT(theFile, data of artwork i)
                if theResult then
                    my openWithPreview(theFile)
                end if
            end repeat
        end if
    end tell
end tell

on saveAsPICT(theFile, theData)
    try
        set fh to open for access file theFile with write permission
        set eof of fh to 512
        write theData to fh starting at 513
        close access fh
        return true
    on error errMsg number errNum
        try
            close access fh
        end try
        log {errNum, errMsg}
        return false
    end try
end saveAsPICT

on openWithPreview(theFile)
    tell application "Finder"
        set theFile to document file theFile
        set file type of theFile to "    "
        set creator type of theFile to "    "
    end tell
end openWithPreview

ただ、書き出せばいいといってもいくつか注意点があります。AppleScript の write 命令で書き出されたファイルはクリエータータイプが ttxt、ファイルタイプが TEXT になります。これをそのまま開こうとすると TextEdit で開こうとします。今回は画像ファイルを書き出すのですから TextEdit で開こうとしても開けません。そこで、ファイルタイプとクリエータータイプを変更(具体的には両方とも半角スペース 4 つ)し、プレビューで開くようにします。

もう一点。artwork クラスの data 属性は picture(PICT)を返します。これをそのままファイルに書き出しても開くことができないファイルになります。AppleScript の write 命令で picture を書き出すには eof を 512 バイトに設定し、513 バイト目から書き出す必要があります。

PICT で書き出すことができたら、後は煮て食おうが焼いて食おうがお気に召すままに。

AppleScript とデザインパターン (9)

AppleScript でもデザインパターンを使ってみようというこの企画。『オブジェクト指向における再利用のためのデザインパターン』の中から今回は、Strategy パターンを取り上げます。

Strategy パターンは、アルゴリズムを汎用化し、プログラムの実行中にアルゴリズムを交換可能にするために利用されます。目的によって処理方法を変更したい...こういう状況はよくあることと思います。例えば、プログラムの開発中。肝心のデータ処理の部分は完成していないのでダミーのデータを用いる。

property DEBUG : true

if DEBUG then
    dummy()
else if not DEBUG then
    processor()
end if

...(コードが続く)

こんな感じですね。または、体重や身長、年齢などの比較。これらの属性を持った人間オブジェクトを作ったとします。

Script Editor で開く

on Human(theName, theAge, theHeight, theWeight)
    script Human
        property _name : theName
        property _age : theAge
        property _height : theHeight
        property _weight : theWeight
    end script
end Human

どの属性も比較が行えますね。単純に以下のような比較を。

Script Editor で開く

on Human(theName, theAge, theHeight, theWeight)
    script Human
        property _name : theName
        property _age : theAge
        property _height : theHeight
        property _weight : theWeight
    end script
end Human

on run
    set nancy to Human("Nancy", 24, 154, 48)
    set bob to Human("Bob", 36, 172, 62)

    set theResult to choose from list {"Age", "Weight"} default items {"Age"}
    if theResult is false then return
    set theResult to theResult as Unicode text

    if theResult is "Age" then
        return ageCompare(nancy, bob)
    else if theResult is "Weight" then
        return weightCompare(nancy, bob)
    end if
end run

on ageCompare(human1, human2)
    if (human1's _age) is greater than (human2's _age) then
        return 1
    else if (human1's _age) is (human2's _age) then
        return 0
    else
        return -1
    end if
end ageCompare

on weightCompare(human1, human2)
    if (human1's _weight) is greater than (human2's _weight) then
        return 1
    else if (human1's _weight) is (human2's _weight) then
        return 0
    else
        return -1
    end if
end weightCompare

一見ごく当たり前のコードですが、if 文の部分をどうにかしたいと思うのではないでしょうか。身長や名前の比較も行おうとすると、if 文が長くなり、修正も煩雑になるからです。最初に提示したプログラム開発中のデバックでも同じことが言えます。

こういうときに Strategy パターンの適用を検討するといいかもしれません。Strategy パターンは実際の処理の部分(アルゴリズム)を切り分け、利用するときに置き換えるようにします。実際に適用してみます。

まず、アルゴリズムの部分を別のオブジェクトとして定義します。

Script Editor で開く

script WeightComparator
    on compare(human1, human2)
        if (human1's _weight) is greater than (human2's _weight) then
            return 1
        else if (human1's _weight) is (human2's _weight) then
            return 0
        else
            return -1
        end if
    end compare
end script

script AgeComparator
    on compare(human1, human2)
        if (human1's _age) is greater than (human2's _age) then
            return 1
        else if (human1's _age) is (human2's _age) then
            return 0
        else
            return -1
        end if
    end compare
end script

体重と年齢の比較ハンドラを compare() と同じ名前にしてスクリプトオブジェクト内で定義します。同じ名前にしておくのは呼び出すスクリプトオブジェクトが変わっても compare() を呼び出せば目的が達成できるようにするためです。AppleScript では、スクリプトオブジェクトの型やインターフェースの定義ができない代わりにこのようにしておきます。

そして、目的によって比較オブジェクトを交換可能にするためのスクリプトオブジェクトを追加します。

Script Editor で開く

on Comparator(theObject)
    script Comparator
        property comparatorObject : theObject

        on setComparator(theObject)
            set comparatorObject of me to theObject
        end setComparator

        on compare(human1, human2)
            tell comparatorObject of me to return compare(human1, human2)
        end compare
    end script
end Comparator

比較オブジェクトを受け取り、比較オブジェクトを自身の属性に設定します。実際の比較は compare() ハンドラが行います。compare() ハンドラでは属性に設定されている比較オブジェクトの compare() ハンドラを呼び出します(委譲ってやつですね)。以下のようにして利用します。

Script Editor で開く

on run
    set nancy to Human("Nancy", 24, 154, 48)
    set bob to Human("Bob", 36, 172, 62)

    -- 年齢で比較
    set theObject to Comparator(AgeComparator)
    tell theObject
        compare(bob, nancy)
        --> 1
        -- 比較を体重に変更
        setComparator(WeightComparator)
        compare(bob, nancy)
        --> 1
    end tell
end run

if 文がなくなりました。...気づいた人もいると思いますが、比較を変えるときに if 文を使うことになるのでは?その通りですね。どこで if 文を使うかが変わっただけです。実際には factory クラスを追加して比較オブジェクトの変更を行うようにします。

Script Editor で開く

script ComparatorFactory

    on createComparator(str)
        if str is "Age" then
            return Comparator(AgeComparator)
        else if str is "Weight" then
            return Comparator(WeightComparator)
        else
            return missing value
        end if
    end createComparator
end script

on Comparator(theObject)
    script Comparator
        property comparatorObject : theObject

        on compare(human1, human2)
            tell comparatorObject of me to return compare(human1, human2)
        end compare
    end script
end Comparator

Comparator() も少し変更。呼び出しは、以下のようになります。

Script Editor で開く

on run
    set nancy to Human("Nancy", 24, 154, 48)
    set bob to Human("Bob", 36, 172, 62)

    set theResult to choose from list {"Age", "Weight"} default items {"Age"}
    if theResult is false then return
    set theResult to theResult as Unicode text

    set theObject to ComparatorFactory's createComparator(theResult)
    tell theObject to compare(bob, nancy)
end run

すっきりしたのではないでしょうか?

このような感じで Strategy パターンは処理(アルゴリズム)の部分を別のオブジェクト(部品)として切り分けておきます。部品は状況によって適宜置き換えられ、呼び出されます。部品の再利用性を高め、呼び出し側の修正といった労力を極力減らし、コードの見通しを良くします。

Strategy パターンは、オブジェクトコンポジションという手法を用いています。継承によらない機能の拡張ということ Decorator パターンのときに書いたと思うのですが、オブジェクトコンポジションは属性にオブジェクトを設定し、そのオブジェクトの機能を呼び出す(委譲)ことです。この手法は頻繁に用いられます。こうすることでオブジェクト間の関係を緩くすることができます。疎結合ってやつですね。逆が密結合。疎結合でオブジェクト間の関係を築くと、一方のオブジェクトの修正が他方のオブジェクトに与える影響を小さくしてくれます。

これは AppleScript でも利用できる手法ですので何かと重宝します。コンポジションを語るときに忘れることができないのがインターフェース。今までにも何回かこの言葉を使ったことがあるのですが、AppleScript では使えないのでちゃんと説明せずに使ってきました。インターフェースは、メソッドの名前と引数だけを定義したものです。抽象クラスとはまた別で、インターフェースはそのメソッドを確実に実装してもらいたいときに使います。あるインターフェースを実装したクラスは、そのメソッドを確実に持っていることになります。ということは、呼び出す方がそのメソッドがあるかないかを気にする必要がないのです。あるのは確実だから。

また、抽象クラスも AppleScript では作ることができません。似たものを作ることはできますが、抽象メソッドを継承先で実装する必要があるといった義務化ができません。

インターフェースも抽象クラスも AppleScript では使うことができません。実装クラス、サブクラスで確実に実装するといった義務を課すことができないわけです。実装するもしないも作る方の好き勝手。コンパイラがその辺りの面倒を見てエラーを出すといったこともないわけです。こういったことは自分でルールを作ってそのルールに則ってオブジェクトを作らないといけないのです。例えば、同じ名前のハンドラで定義しておくようにするとか。

つまり、AppleScript でオブジェクト指向的な開発をする、ということはルール作りから始まるということです。ましてや複数の人間で行うとなると...考えただけでしんどくなります。

インターフェースや抽象クラスを使うことはできないのですが、それだけ AppleScript はフレキシブルなのです。粘土をこねるように自分の好き勝手にできます(だからこそ共有ライブラリもないのでしょうけど)。

最後は Strategy パターンとは関係のない話になってしまいましたが。では、また。

AppleScript とデザインパターン (8)

構造に関するデザインパターンを取り上げていませんでしたね。そんなわけで、Decorator パターン。

オブジェクト指向における再利用のためのデザインパターン』は、オブジェクトの「生成」に関するパターン、オブジェクトの「構造」に関するパターン、オブジェクトの「振る舞い」に関するパターンに分類されています。それぞれ、「オブジェクト生成の工夫」、「オブジェクトの組み合わせの工夫」、「オブジェクトの動作に関する工夫」と言い換えることができます。 「そんなことわざわざデザインパターンとして教えてもらわなくても前から実行しているよ」というようなものからなるほどというものまでさまざま。

Observer パターンは「振る舞い」、Factory Method パターンは「生成」、Iterator パターンは「振る舞い」、Template Method パターンは「振る舞い」、と。

Decorator パターンは、オブジェクトの構造に関するデザインパターンです。どんな工夫なのか?

例えば、既にあるオブジェクトの動作を拡張したい時。通常は、継承を用います。以下のような感じ。

Script Editor で開く

script Joe

    on getName()
        return name of me
    end getName
end script

script Agnes
    property parent : Joe

    on getName()
        return "My name is " & (continue getName())
    end getName
end script

tell Agnes to getName()
--> "My name is Agnes"

継承した方でハンドラを上書きして拡張を行い、そのままでいい部分は continue で親のハンドラを呼び出す。これでいいのですが、継承に問題がないわけではありません。例えば、スクリプト実行時に動的に親を変更できないとか。継承を使うと限定されてしまうのですね。ちょっとした問題だと言えばそうなのですが。

こういう制限がない方が望ましい状況があります。拡張した機能が汎用的で使い勝手のいいものだった場合。こういう時は特定の親を持つオブジェクトであるより、拡張対象を自由に切り替えられた方が便利です。

そこで、拡張機能だけを持たしたオブジェクトを作り、このオブジェクトの属性に拡張対象のオブジェクトを持たせるようにします。拡張したい部分を追加し、既存の機能を使いたいときは、属性で保持しているオブジェクトの機能を呼び出すようにします。このことを指して委譲と言うようです。AppleScript で委譲というと continue を使った親オブジェクトのハンドラ呼び出しが思い浮かびますが。Decorator パターンは、継承によらずに機能の拡張を行えるようにするという工夫なのです。

まずは、概念を理解するためのコード。Decorator パターンは、以下のようになります。

Script Editor で開く

on Component()
    script Component
        on getName()
            return name of me
        end getName
    end script
end Component

on Decorator(theObject)
    script Decorator
        property targetObject : theObject

        on getName()
            set str to "=="
            set str to str & getName() of targetObject
            set str to str & "=="

            return str
        end getName
    end script
end Decorator

on run
    set theComponent to Component()
    set theDecorator to Decorator(theComponent)
    tell theDecorator to getName()
    --> "==Component=="
end run

Component(= 部品)の getName() を拡張します(拡張対象)。そのために、同じハンドラを持ったオブジェクトを作ります(Decorator = 装飾者)。この Decorator の属性で Component を保持します。Decorator は、この属性を通して Component のオリジナルのハンドラを呼び出します。

このオリジナルのハンドラ呼び出しの前後に処理を追加することができます。ここでは文字列の前後に「==」という文字列を追加しています。さらに面白いのは、Decorator で Decorator を拡張できるということです。

Script Editor で開く

on Component()
    script Component
        on getName()
            return name of me
        end getName
    end script
end Component

on Decorator(theObject)
    script Decorator
        property targetObject : theObject

        on getName()
            set str to "=="
            set str to str & getName() of targetObject
            set str to str & "=="

            return str
        end getName
    end script
end Decorator

on run
    set theComponent to Component()
    set theDecorator to Decorator(theComponent)
    tell theDecorator to getName()
    --> "==Component=="
    set theDecorator2 to Decorator(theDecorator)
    tell theDecorator2 to getName()
    --> "====Component===="
end run

前後に付加する文字列が増えています。

このように Decorator は、一つのスクリプトオブジェクトのハンドラ呼び出しで付加する機能を動的に自由自在に変更することができます。継承ではこういうことができません。多分。また、Decorator は、拡張対象 Component と同じハンドラを持っています(これは、Component パターンが機能を拡張するという体裁を持つデザインパターンなので、こういう制限があります。この辺りは最後の方で少し触れます)。ということは、利用者はそれが Component か Decorator かを意識する必要がありません。

最初に基本的な機能を持つオブジェクトを作り、機能は後で追加していくことでオブジェクトが肥大化することを防ぐことができます。継承で拡張することを考えていると、親のオブジェクトにいろいろな機能を詰め込んでしまうことがあります。そうするとオブジェクトは肥大し、再利用が難しいものになってしまいます。拡張対象を属性で保持し、保持しているオブジェクトの機能を呼び出す...たったこれだけのことなのですが、メリットは大きいようです。

上記のサンプルはパターンの概念だけのものなので...もう少し AppleScript 的なサンプルを。まず、Finder で選択している項目を返すスクリプト。

Script Editor で開く

on FinderEx()
    script FinderEx
        on getSelection()
            tell application "Finder" to selection
        end getSelection
    end script
end FinderEx

これに Decorator を追加します。Finder の selection は、返り値の順番がまちまちです(並んでいるのですが直感的ではないので)。これを順番に並んだ項目で返すようにしましょう。Finder の sort 命令を使います。

Script Editor で開く

on SortDecorator(theObject)
    script SortDecorator
        property targetObject : theObject

        on getSelection()
            set theList to getSelection() of targetObject
            if theList is {} then return {}

            tell application "Finder"
                sort theList by name
            end tell
        end getSelection
    end script
end SortDecorator

on run
    set theComponent to FinderEx()
    set theSortDecorator to SortDecorator(theComponent)
    tell theSortDecorator to getSelection()
end run

これで選択項目は順番が名前順になったリストで返ってきます。Finder の selection は便利なのですが、返ってくる値は、Finder のオブジェクトです。他のアプリケーションに渡すには型をキャストしないといけません。そういう機能も追加してみましょう。

Script Editor で開く

on AliasDecorator(theObject)
    script AliasDecorator
        property targetObject : theObject

        on getSelection()
            set theList to getSelection() of targetObject
            if theList is {} then return {}
            tell application "Finder"
                set tmp to {}
                repeat with thisItem in theList
                    set end of tmp to thisItem as alias
                end repeat
                return tmp
            end tell
        end getSelection
    end script
end AliasDecorator

on run
    set theComponent to FinderEx()
    set theAliasDecorator to AliasDecorator(theComponent)
    tell theAliasDecorator to getSelection()
end run

では、名前の順番に並んだ項目を alias 参照で返して欲しいという時は?既に機能はあるのですから、後は組み合わせるだけです。

Script Editor で開く

on run
    set theComponent to FinderEx()
    set theSortDecorator to SortDecorator(theComponent)
    set theAliasDecorator to AliasDecorator(theSortDecorator)
    tell theAliasDecorator to getSelection()
end run

これで名前で並んだの alias 参照を取得できます(alias 参照を Finder の sort 命令で並び替えはできないので呼び出し順を間違えるとエラーになります)。特になんの手も加えたくないのであれば、FinderEx の getSelection() をそのまま使えばいいのです。これで、目的によって機能を追加したり、組み合わせたりといったことが可能になりますし、修正も楽になりますね。

最後に。先に書いた制限について。Decorator は拡張対象と同じハンドラしか持てない、という制限があります。AppleScript では関係ない話なのですが、微妙に関係する部分もあるので少し触れておきます。例えば、最初のサンプルを以下のように変えたとします。

Script Editor で開く

on Component()
    script Component
        on getName()
            return name of me
        end getName
    end script
end Component

on Decorator(theObject)
    script Decorator
        property targetObject : theObject
        property append : ""

        on setAppend(str)
            set append of me to str
        end setAppend

        on getName()
            set str to append
            set str to str & getName() of targetObject
            set str to str & append

            return str
        end getName
    end script
end Decorator

on run
    set theComponent to Component()
    set theDecorator to Decorator(theComponent)
    tell theDecorator
        setAppend("**")
        getName()
    end tell
end run

追加する文字列を設定できるようにしただけなのですが、利用するには setAppend() で文字列を設定しておく必要があります。こうなるとなにが困るか。Component と Deocrator のどちらであるかを使う人が意識しないといけなくなります。先にも書いたように、同じハンドラしか持っていないということは、両者の違いを意識しなくて済むのです。Component をそのまま使いたいときもあるし、Decorator で拡張した機能を使いたいときもある。けど、Decorator を使うときには必ず setAppend() を呼び出さないとなれば、コードの修正が必要になりますし、冗長です。

両者の違いを考えなくてもいいように同じハンドラしか持たない、という制限を加えているのです。それ以外の要素が入ってくるようなら Decorator で拡張したとはいえません。ここで制限が必要になるのですが、AppleScript にゃ関係のない話。AppleScript のスクリプトオブジェクトは、ハンドラも属性もどこからでもアクセスできる。でんでん。

GoF のデザインパターンは、オブジェクト(クラス)を作る人、それを使ってなんらかの処理をする人、それぞれの立場を考えたものになっています。ほとんどのデザインパターンは、使う人が楽に利用できるようになっています。そのために様々な工夫が施してあります。作る人、使う人の視点に立って見てみるとより納得できます。AppleScript では作る人も使う人も概ね同じ人ですが、将来の自分は赤の他人。作って楽をできるなら楽をしたいものですね。

AppleScript とデザインパターン (7)

掲示板。そう、掲示板です。なんだか、いたずらされていますね。対処をしたいとは思うのですが、この掲示板はプロバイダが提供しているものなので対処のしようがなかったり。まぁ、基本的にはほっておきます。あまりにも続くようなら、こういう輩を相手に時間を潰すのもなんですので、あっさりと掲示板を閉じます。手軽ではないですが、なにかあればメールの方でご連絡を。GMail の blackcharan さん宛に。今後、メールも GMail に統一させようと思っています。PHP の勉強がてらに掲示板を作るという手もあるな。そうすれば、嫌いな人...もとい、嫌がらせをする人のフィルタリングも思いのまま。

以上、業務連絡でした。

Database Events は終わって、デザインパターンに戻ります。デザインパターンの話って今年中に終わるのかしらん。今回は、Template Method を取り上げます。。デザインパターンの中では比較的単純なパターンだそうです。

Template Method は、『決まった処理手順をテンプレートにし、処理手順の一部だけを置き換えやすくする』デザインパターンです。既存のハンドラを組み合わせて定型的な処理を行う AppleScript にはある意味お似合いのデザインパターンかな。

例えば、Finder で選択している項目に対してなんらかの処理を施したいというスクリプト。画像ファイルを回転したい。画像ファイルを縮小したい。画像ファイルのフォーマットを変更したい...。以下のような感じでしょうか?

Script Editor で開く

property extensionList : {"jpg", "jpeg", "tif", "tiff", "png"}

tell application "Finder"
    set curSelection to selection
    if curSelection is {} then return

    set imageFiles to {}
    repeat with thisItem in curSelection
        if (class of thisItem) is document file and (name extension of thisItem) is in extensionList then
            set end of imageFiles to thisItem as alias
        end if
    end repeat

    my shrinkingImages(imageFiles, 0.8)
    beep 2
end tell

on shrinkingImages(imageFiles, percentage)
    tell application "Image Events"
        launch
        repeat with thisImage in imageFiles
            try
                set imageRef to open thisImage
                scale imageRef by factor percentage
                save imageRef
                close imageRef
            on error
                try
                    close imageRef
                end try
            end try
        end repeat
    end tell
end shrinkingImages

Finder で選択している画像に対して縮小、拡大を行います。上書き保存するので大事な画像に使ってはいけません。では、画像をトリミングするスクリプトを。

Script Editor で開く

property extensionList : {"jpg", "jpeg", "tif", "tiff", "png"}

tell application "Finder"
    set curSelection to selection
    if curSelection is {} then return

    set imageFiles to {}
    repeat with thisItem in curSelection
        if (class of thisItem) is document file and (name extension of thisItem) is in extensionList then
            set end of imageFiles to thisItem as alias
        end if
    end repeat

    my rotateImages(imageFiles, -90) -- *
    beep 2
end tell

on rotateImages(imageFiles, degree)
    tell application "Image Events"
        launch
        repeat with thisImage in imageFiles
            try
                set imageRef to open thisImage
                rotate imageRef to angle degree
                save imageRef
                close imageRef
            on error
                try
                    close imageRef
                end try
            end try
        end repeat
    end tell
end rotateImages

画像を処理するハンドラが変更されました。Finder で処理をしている部分は同じものを使い回しています。実際はもう少し使いやすいように処理した画像を別名で保存したりするのですが、今回はデザインパターンを理解するためのものなので手を抜いています。

AppleScript は、上記のような決まった手順の処理というのが多いです。処理内容は同じで対象アプリケーションが変わっただけとか。こういうときに Template Method パターンを使えばしあわせになれるかもしれません。Template Method パターンでは、まず処理手順のテンプレートを作ります。処理手順のテンプレートというのは、ハンドラ呼び出しの組み合わせです。テンプレートは抽象クラス(AbstractClass。AppleScript にはクラスの概念がないですが、抽象オブジェクトというのもなんなので...)で作り、処理手順の一部を定義します。処理手順の一部というのはハンドラですね。

Template Method では処理のテンプレートは、親オブジェクトで定義し、変更されません。子オブジェクトで変更するのは、個々の処理だけです。処理手順は全ての子オブジェクトで使い回されます。テンプレートたる所以ですね。

上記のスクリプトでは、ファイルの選別とファイルの加工という処理があり、選別、加工と処理手順の流れがあります。

Script Editor で開く

script FileProcessor
    property extensionList : {}

    on selectItems()
        tell application "Finder"
            set curSelection to selection
            if curSelection is {} then return

            set theList to {}
            repeat with thisItem in curSelection
                if (class of thisItem) is document file and (name extension of thisItem) is in extensionList then
                    set end of theList to thisItem as alias
                end if
            end repeat
            return theList
        end tell
    end selectItems

    on processItems(theseItems)
    end processItems

    on run
        set theseItems to my selectItems()
        my processItems(theseItems)
    end run
end script

このような抽象的なオブジェクトを作ります。run ハンドラを templateMethod にしています。templateMethod では、自身で定義されている個別の処理を順番に呼び出します。selectItems() と processItems() が個別の処理になります。前者がファイルの選別を行い、後者で加工を行います。これらの個別の処理はこのスクリプトオブジェクトを継承したスクリプトオブジェクトで実装します。そうすることによって処理の一部だけを変更することが可能になります。

selectItems() ハンドラは既に実装しています。こういう変わらない処理なら、抽象クラスの方で実装しておく方がより楽になります。気に入らなければ継承先で上書きすればいいだけなので。以下のようにこのスクリプトオブジェクトを継承して使います。

Script Editor で開く

script ShrinkingImagesProcessor
    property parent : FileProcessor
    property extensionList : {"jpg", "jpeg", "tif", "tiff", "png"}
    property percentage : 0.5

    on processItems(theseItems)
        if theseItems is {} then return

        tell application "Image Events"
            launch
            repeat with thisImage in theseItems
                try
                    set imageRef to open thisImage
                    scale imageRef by factor percentage of me
                    save imageRef
                    close imageRef
                on error
                    try
                        close imageRef
                    end try
                end try
            end repeat
        end tell
    end processItems

    on setPercentage(num)
        set percentage of me to num
    end setPercentage

    on getPercentage()
        return percentage of me
    end getPercentage
end script

tell ShrinkingImagesProcessor to run

画像の回転も同じように継承を行い変更したい処理手順を上書きするだけです。

Script Editor で開く

script RotateImagesProcessor
    property parent : FileProcessor
    property extensionList : {"jpg", "jpeg", "tif", "tiff", "png"}
    property degree : 90

    on processItems(theseItems)
        if theseItems is {} then return

        tell application "Image Events"
            launch
            repeat with thisImage in theseItems
                try
                    set imageRef to open thisImage
                    rotate imageRef to angle degree of me
                    save imageRef
                    close imageRef
                on error
                    try
                        close imageRef
                    end try
                end try
            end repeat
        end tell
    end processItems

    on setDegree(num)
        set degree of me to num
    end setDegree

    on getDegree()
        return degree of me
    end getDegree
end script

tell RotateImagesProcessor to run

Template Method パターンを用いれば、既にある処理は使い回すことができます。また、各手順は個別に作成しているので修正も容易になります。以前に『どうせなら』という記事で書いた Livedoor Reader のログインスクリプトなどに Template Method を適用すれば、mixi や Gmail などのログインスクリプトも差分だけを書くことで流用することができます。

もちろん、問題がないわけでもありません。Template Method の問題点は、継承の継承を行っていくことでどのオブジェクトのどのハンドラが呼び出されているかが分かりにくくなるということです。以下のような感じです。

Script Editor で開く

script FileProcessor
    property extensionList : {}

    on selectItems()
        tell application "Finder"
            set curSelection to selection
            if curSelection is {} then return

            set theList to {}
            repeat with thisItem in curSelection
                if (class of thisItem) is document file and (name extension of thisItem) is in extensionList of me then
                    set end of theList to thisItem as alias
                end if
            end repeat
            return theList
        end tell
    end selectItems

    on processItems(theseItems)
    end processItems

    on run
        set theseItems to my selectItems()
        my processItems(theseItems)
    end run
end script

script ScriptFileProcessor
    property parent : FileProcessor
    property extensionList : {"scpt", "scptd", "applescript"}

    on processItems(theseItems)
        repeat with thisItem in theseItems
            tell application "Finder" to open thisItem
        end repeat
    end processItems
end script

script JPEGFileProcessor
    property parent : FileProcessor
    property extensionList : {"jpg", "jpeg"}

    on processItems(theseItems)
        if theseItems is {} then return

        tell application "Image Events"
            launch
            repeat with thisImage in theseItems
                try
                    set imageRef to open thisImage
                    rotate imageRef to angle 90
                    save imageRef
                    close imageRef
                on error
                    try
                        close imageRef
                    end try
                end try
            end repeat
        end tell
    end processItems
end script

script TIFFFileProcessor
    property parent : JPEGFileProcessor
    property extensionList : {"tif", "tiff"}
end script

tell TIFFFileProcessor to run

最後の TIFFFileProcessor が FileProcessor を継承している JPEGFileProcessor を継承しています。ここでは個別の処理をそれぞれで定義していませんが、こういう継承を行っているとどのオブジェクトのどのハンドラを呼び出しているのかということが分かりにくくなってきます。バグが見つけにくい、と。あまり深い継承は行わない方がいいようです。

Template Method パターンは、Factory / Factory Method パターンと深い関係があるそうで。オブジェクトの生成処理に Template Method パターンを適用することができますし、Template Method パターンで作ったオブジェクトのどれを利用するかを Factory を使って動的に変更することが可能になります。

デザインパターンは一つだけでなく複数を組み合わせることでさらに威力を発揮するということですね。

Database Events (7)

Database Events (6) の続きです。前回、最初の方でスクリプトメニューから実行すると処理速度は速い、と書きました。これは、Database Events に限った話ではなく、どのアプリケーションのスクリプトでも速くなります(Carbon、Cocoa 両方ともかどうかまでは調べていませんが)。

ところで...System Events の用語説明には表示されない Hidden Suite という Suite をご存知でしょうか?

この Suite では、9 個の命令が定義されています。cancel、confirm、decrement、do action、do script、increment、key down、key up、pick の 9 個です。これらの使い方を詳らかにするのが目的ではないので端折りますが、多くは UI Element の操作を行うための命令です。do action と do script は(System Events 1.0 の頃から使っている人は知っていると思いますが)、フォルダアクションを実行する命令と OSA Script を実行する命令です。今回、取り上げるのは do script 命令。この命令は、run script 命令と同じように引数で指定したスクリプトファイルを実行するための命令です。

おそらく...スクリプトメニューは、この命令を使っているのではないかと思います(ただの推測なのですが)。スクリプトメニューは、Cocoa/Objective-C なのでしょうが、その中で NSAppleScript を使って System Events の do script を実行しているのではないかと思います。以下のスクリプトを実行すると System Events が返ってくることから考えてみても。

Script Editor で開く

on run
    tell application "System Events"
        set f to name of processes whose frontmost of it is true
    end tell

    display dialog (f as text)
end run

スクリプトメニューは、SystemUIServer が動かしているのですが。

つまり、スクリプトを do script 命令で動かせば処理速度は速くなる、ということが言えると思います。では、実験。

まず、実験した環境を。iMac G5(2 GHz Power PC G5)でメモリは 1 GB。HDD は、400 GB。OS は、最新。Mac OS X 10.4.8 です。AppleScript 1.10.7。Script Editor 2.1.1。

以下のスクリプトをコンパイル済みスクリプトとして保存します。

Script Editor で開く

on run
    set ex to ".dbev"
    set theName to "tmp"
    set dir to path to desktop as Unicode text
    set dbFile to dir & theName & ex

    tell application "Database Events"
        if exists database theName then
            set db to database theName
        else
            if exists database (POSIX path of dbFile) then
                set db to database (POSIX path of dbFile)
            else
                set db to make new database with properties {name:theName, location:dir}
                save db
            end if
        end if

        set cd to current date
        tell db
            repeat with i from 1 to 500
                make new record with properties {name:""}
            end repeat
            save
        end tell
        set num to (current date) - cd

        delete db
        quit
    end tell

    return num
end run

単純にデータベースに record を 500 個新規作成するだけのものです。これを Script Editor 上で実行すると、約 27 秒。次に、スクリプトメニューから実行します。このとき、約 10 秒。全く処理速度が違います。そして、do script 命令。

データベースに record を追加するスクリプトを do script 命令で 50 回呼び出して平均値をとってみました(データベスは、毎回削除して新規に作りながら)。結果、9.52 秒。スクリプトメニューの結果と同じです。

ついでにと思い、run script 命令も試してみます。これも 50 回呼び出して平均値をとってみました。結果は、6.12 秒。do script 命令に比べ、約 3 秒近く早くなっています。驚くべき事実。何がお前をそこまで変えてしまったんだ、と問わずにはいられません。

というか、AppleScript の処理速度って上がっていますよね。ただ、Script Editor で開発、デバックを行っていると気づかないし、むしろ、遅い。AppleScript Studio でアプリケーションにしたら速度が上がるなと思っていましたが、ようは Script Editor が悪かったんですね。これって既知のこと?うわっ...時代に取り残されていたよ。

これなら、積極的に Database Events を使ってみようという気になるもの。また、Script Editor でスクリプトを作っていて処理速度が遅いな、と思ったとき一度スクリプトメニューから実行してみるのもいいかも。ただ、デバックがより面倒になるよな...。

Database Events (6)

久しぶりに Database Events。Database Events は、一連の記事(Database Events (1)Database Events (2)Database Events (3)Database Events (4)Database Events (5))を書いて以来、いつか使えるといいな...といった感じで暇なときに時々いじっていたりしました。どうにもパフォーマンスが悪いし...きっと使い方が悪いのかもしれないと思い。

ところで...。iTunes 関連のスクリプトで有名な Doug's AppleScripts for iTunes に Most Played Artists というスクリプトがあります。これ、試してみたことあるでしょうか?

iTunes でのアーティストごとの再生回数をランキング形式でファイルに書き出してくれるものですが、Database Events を使っています。このスクリプトを開いてみると最初にコメントが書かれていますが、ここに 5154 曲の再生回数を集計するのに 6 分 18 秒とあります。Dual 1.8GHz G5 で。

Database Events は、どうにも遅かったような...と思い、はたと気づく。スクリプトをいったんアプリケーション形式で保存して実行してみます。2232 曲で 4 分。スクリプトのままスクリプトメニューから実行してみる。2232 曲で 1 分 50 秒...。これ、使えるじゃん。Script Editor で実行していたから遅かったのか。迂闊だった...。

試してみると、他のアプリケーションでも同じでした。スクリプトメニューから実行すると処理が早くなる。Script Editor から試すと遅い。開発環境になんて不向きなんでしょう。まさか、これって既知のこと?うわっ、時代に取り残されていたよ...。

そんなわけで急遽 Database Events に取り組む。Database Events が使えるなら、なんだか可能性が増える感じがするなー、と思う。根拠もアイデアもないけど。

DBMS を使うとき、最初にデータベースの構造を設計すると思います。後で変更できないからですね。が、Database Events では、変更し放題です。record には、field いくつでも作ることができます。また、最初の record と 次の record が同じ field を持たないといけないということもありません。record と field の name 属性は、同じものが複数あってもかまいません。database を必ずファイルに保存しないといけない、ということもありません。処理のために一時的なデータ置き場として使うことが可能です。使い捨てとして。Doug's AppleScripts for iTunes の Most Played Artists は、まさにそういった使い方をしています。

Database Events であるデータベースファイルを開くと、他のスクリプトからでもそのデータベースを使うことができ(共有される)、また、閉じることも可能です。たとえ、処理中であっても。これを防ぐ方法はありません。with transaction が解決法として思い浮かびますが、Database Events は対応していません。Database Events は、終了するときにすべてのデータベースを閉じます。これも、他のスクリプトで何らかの処理を行っていても、否応無しにデータベースは閉じられます。System Events に追加された一連のトランザクション関連の命令(abort transaction、begin transaction、end transaction)が解決方法を提供してくれるのかもしれませんが、使い方は Web 上では発見できず。同じ疑問を書いていた人はいたけど。これは、探し方が悪いのかもしれませんが。

database は、write 命令のファイル書き出しのようにデータベースファイルを開き、閉じることができます。対応するのは、open 命令と close 命令。が、close 命令は不安定で時々エラーになります。確実に閉じることができるのは delete 命令。delete 命令は、record と field の削除も行えます。open 命令でファイルをデータベースファイルを開くことができますが、POSIX path を指定する必要があります。make 命令で database を作るとき、location 属性に指定するのは、POSIX file の方です。location 属性以外は、すべて POSIX path で指定します。

exists 命令では開かれているデータベースが存在するかどうかを確認することができるとともに、POSIX path を指定することでデータベースファイルが存在するかどうかも確認することができます。

ある一つのデータベースを複数のスクリプトから変更すると、後から来た方の命令でデータは上書きされます。このとき、トランザクションを行っていなくてもデータの不整合がおこることはありません。どうも、内部的に適切に処理を行っているような感じがします。ただ、処理中に他のスクリプトからデータベースを閉じられる可能性がある、ということに留意しておく必要はあります。このときはもちろんエラーになり、データは正しく保存されません(もしくは、データベースが壊れる)。

open 命令は、既存のデータベースを開くことができますが、open するたびに Database Events で開かれているデータベースの数が増えていきます。これは、make 命令も同じです。

と、まあいろいろと書きましたが、実際のスクリプトで見ていきましょう。まず、デスクトップに PREF.dbev というデータベースがあります。open 命令で開くには、以下のようにします。

Script Editor で開く

set theFile to "PREF.dbev"
set dir to path to desktop as Unicode text
set dbPath to dir & theFile

tell application "Database Events"
    set db to open database (POSIX path of dbPath)
    databases
    --> {database "PREF" of application "Database Events", database "PREF" of application "Database Events"}
end tell

データベースファイルの POSIX path を指定し、かつ、database とクラスを指定します。これで開くことができるのですが、結果はなぜか同じものが二つ返ってきます。実のところ...というほどでもないのですが、データベースファイルを開くには以下のようにするだけでいいのでした。

Script Editor で開く

set theFile to "PREF.dbev"
set dir to path to desktop as Unicode text
set dbPath to dir & theFile

tell application "Database Events"
    set db to database (POSIX path of dbPath)
    databases
    --> {database "PREF" of application "Database Events"}
end tell

database (POSIX path) とするだけでデータベースが開かれます。その結果に open 命令を行っているので同じものが複数返ってくるのです。以下のような感じですね。

Script Editor で開く

set theFile to "PREF.dbev"
set dir to path to desktop as Unicode text
set dbPath to dir & theFile

tell application "Database Events"
    set db to database (POSIX path of dbPath)
    set dbCopy to open db
    databases
    --> {database "PREF" of application "Database Events", database "PREF" of application "Database Events"}
end tell

open 命令は、既に開いているデータベースを再度開き、同じものを結果として返します。複製を作っているような感じなのですが...何度も繰り返して open を行うと、使わないにもかかわらず開かれているデータベースの数は増えていきます。上記のスクリプトを何回か繰り返して実行してみると分かると思います。これは、make 命令も同じです。

Script Editor で開く

set theFile to "PREF.dbev"
set dir to path to desktop as Unicode text
set dbPath to dir & theFile

tell application "Database Events"
    make new database with properties {name:theFile, location:dbPath}
    databases
end tell

このスクリプトを何回か繰り返してもエラーにはなりません。ただ、開かれているデータベースの数が増えるだけです。不必要にも関わらず、同じデータベースが複数開かれているということもありえるので注意が必要です(open 命令ではなく、データベースファイルのパスを指定する方では、数は増えません)。

データベースを閉じるのが close 命令です。

Script Editor で開く

set theFile to "PREF.dbev"
set dir to path to desktop as Unicode text
set dbPath to dir & theFile

tell application "Database Events"
    set db to database (POSIX path of dbPath)
    set dbCopy to open db
    close db
    databases
    --> {database "PREF" of application "Database Events"}
end tell

また、delete 命令でも閉じることができます。

Script Editor で開く

set theFile to "PREF.dbev"
set dir to path to desktop as Unicode text
set dbPath to dir & theFile

tell application "Database Events"
    set db to database (POSIX path of dbPath)
    set dbCopy to open db
    close db
    delete dbCopy
    databases
    --> {}
end tell

ただ、close 命令は(何が原因か分かりませんが)エラーになることがあります。

exists で database(もしくは、record や field)の存在を確認することができます。

Script Editor で開く

set theFile to "PREF.dbev"
set dir to path to desktop as Unicode text
set dbPath to dir & theFile

tell application "Database Events"
    set db to database (POSIX path of dbPath)
    exists database "PREF"
    --> true
end tell

exists にパスを指定することでデータベースファイルが存在するかどうかの確認もできます。

Script Editor で開く

set theFile to "PREF.dbev"
set dir to path to desktop as Unicode text
set dbPath to dir & theFile

tell application "Database Events"
    exists database (POSIX path of dbPath)
    --> true
    databases
    --> {database "PREF" of application "Database Events"}
end tell

存在すれば真を返し、データベースは開かれます。存在しないと偽を返し、エラーにはなりません。データベースファイルがなければ作成し、あればそれを開くという処理は open 命令を使うよりも exists 命令を使う方がいいでしょう。

ただ、Database Events はどんなファイルであってもデータベースとして開いてしまうようで、例えば、テキストファイルを指定してもデータベースとして開いてしまいます。以下のスクリプトは、スクリプトファイルを指定した例です。

Script Editor で開く

set theFile to "Database.scpt"
set dir to path to desktop as Unicode text
set dbPath to dir & theFile

tell application "Database Events"
    exists database (POSIX path of dbPath)
    --> true
    databases
    --> {database "Database" of application "Database Events"}
end tell

開けるからといって使えるわけではありません。この辺りの注意はスクリプトを作る人の責任になる...のでしょうね。

Database Events は、起動しているあいだに開かれたデータベースをすべて知っています。

Script Editor で開く

set theFile to "PREF.dbev"
set dir to path to desktop as Unicode text
set dbPath to dir & theFile

tell application "Database Events"
    exists database (POSIX path of dbPath)
    databases
    --> {database "PREF" of application "Database Events"}
end tell

このスクリプトを実行します。その後、新規ドキュメントを作って以下のスクリプトを実行します。

Script Editor で開く

tell application "Database Events"
    delete databases
    databases
    --> {}
end tell

このように他のスクリプトから閉じることができます。もちろん、操作もできます。が、処理中のデータベースがあるかどうかを調べることはできません。Database Events は、終了時にすべてのデータベースを自動的に閉じます。そのために quit delay という属性で Database Events が起動してから何秒後に終了するかを指定することができます。ただ、データベースを修正したかどうか、セーブする必要があるかどうかといったことを調べる方法は Database Events には用意されていません。しかし、修正されていた場合、データベースを閉じるときに Database Events が保存を行います(データベースがファイルとして既に保存されているなら、です)。Database Events が終了するときではありませんので注意を。

他のスクリプトからでも開かれているデータベースにアクセスできますが、操作の競合やデータの不整合が起こることはないようです。処理は順番待ちで実行されます。小難しい処理は Database Events が責任を持って行っている、という感じでしょうか。データベースの操作にそれほど神経質にならなくてもいいのかもしれません(あくまで、主観。もしかしたら、全然違うかもしれません)。

最後に一つだけ書きたいことがあるのですが...さすがに、これ以上書くと長くなるので次回に続く...ということで。

頻出単語を数える

タグクラウドが流行のようで。

デザインパターンは、ちょっと離れて。そういえば、ダイナミック Objective-C今回からデザインパターンのお話しになっていますね。Objective-C というか、Cocoa のフレームワークではふんだんにデザインパターンが使われています。日本語訳されている Cocoa Fundamentals Guide では、Cocoa のデザインパターンについて書かれている部分があります。ダイナミック Objective-C の初回は、Singleton か...。AppleScript でできるのかな...。

そういえば...と、関係のない話が続きますが、iTunes。iTunes 7.0.1 を使っているのですが、iTunes 環境設定の「一般」で「ビデオを再生:」を別ウィンドウにしているとビデオを再生したときに別のウィンドウが開かれますね。このウィンドウを開いたままでライブラリの中の音楽を再生すると、アートワークが表示されるのですね。曲が変わるごとにウィンドウのタイトルが曲名になってアートワークが変わる...。こんな機能ありましたっけ?ちょっと便利。

Monzai って、日本語文字列の解析ができるのですね。品詞が取得できる。英語なんかだと単語の出現頻度を調べるのは簡単なのですが、単純に AppleScript で words ってすると日本語は助詞でもなんでも単語に見なされてしまう。それでも構わないのですが、タグクラウドを作ったときに「は」や「を」などがクローズアップされてしまう。それは、困る。かといって、面倒なことはしたくない。そこで、Monzai を使う。

以下のスクリプトは、要 Monzai です。とにかく、試してみたかっただけなので、コードは殴り書き。

Script Editor で開く

set str to the clipboard as Unicode text

tell application "monzai"
    set theResult to kaiseki controller 1 mode 1 moji str
end tell

set wordsList to {}
set wordsCount to {}
repeat with thisItem in theResult
    set tmp to original of thisItem as string
    if hinsicode of thisItem is 0 then
        if wordsList contains tmp then
            repeat with i from 1 to count wordsList
                if (item i of wordsList) is tmp then exit repeat
            end repeat

            set num to (item i of wordsCount) + 1
            set item i of wordsCount to num
        else
            set end of wordsList to tmp
            set end of wordsCount to 1
        end if
    end if
end repeat

set min to 1000000
set max to -1000000
repeat with thisItem in wordsCount
    if thisItem > max then set max to thisItem as integer
    if thisItem < min then set min to thisItem as integer
end repeat
{min, max}

set ratio to 18.0 / (max - min)
set cloud to {}

tell application "TextEdit"
    activate
    close documents saving no
    set theDocument to make new document
end tell

repeat with i from 1 to count wordsList
    set thisWord to item i of wordsList
    set num to item i of wordsCount
    set fs to (9 + (num * ratio)) as integer
    tell application "TextEdit"
        tell theDocument
            make new word at end of words of it with data "[ " with properties {size:fs, color:{0, 0, 0}}
            make new word at end of words of it with data thisWord with properties {size:fs, color:{0, 32896, 65535}}
            make new word at end of words of it with data " ] " with properties {size:fs, color:{0, 0, 0}}
        end tell
    end tell
end repeat

クリップボードの中にある文字列の中から名詞を抜き出し、TextEdit で複数回使われている名詞は大きく、そうでない名詞は小さく表示します。面白い、面白い。これで、星の数ほどあるブログを全部読まなくても要点が分かる...かな。