Julia でどうしても super().hoge みたいなことしたい人へ
TL;DR
- Julia には 多重ディスパッチ(+ Holyトレイト)があるので、部分型多相は不要!(代替手段がある!)
for other language users
- この記事は、クラスもないし継承という概念もない Julia における 部分型多相の代替手段(同等以上の機能)について書かれています。
- 他言語から Julia に移行もしくは移植する際の何らかの参考になれば幸いです。
はじめに
この記事は、 2024/03/16(土) にオンラインで実施された JuliaTokai 勉強会 #18 での私の発表内容『Julia でどうしても super().hoge みたいなことしたい人へ』を再構成して記事化したものです。
このスライドには「自己紹介」「Julia そのものの紹介(トップバッターの発表だったので)」「拙著出版1周年」などのトピックも含まれていますが、それらはすっ飛ばして本題のみを、発表時の補足説明なども踏まえて加筆・修正して記事化しています。
前提知識:Julia とポリモーフィズム
おさらい:ポリモーフィズムとは
『部分型多相』の話をする前に、まずはポリモーフィズムの話から始めなければなりません。
一般的に(プログラミングにおける)ポリモーフィズム とは、「共通のインターフェースで異なる対象を統一的に扱うこと」全般を指す言葉です。
拙著『実践Julia入門』(以下 JJN と略記しますw)では、「犬は『ワン』と鳴く。ネコは『ニャー』と鳴く。ミンミンゼミは『ミーンミーン』と鳴く。主体やその仕組みは違えど、いずれも『体のどこかを震わせて音を出すこと』を指して『鳴く』という同じ言葉で表せる」という比喩で説明しています(JJN p.308 第5章「5-3. ポリモーフィズム」)
また代表的なポリモーフィズムの分類として、以下の3つを挙げています。
- アドホック多相
- パラメータ多相
- 部分型多相
このうち、Julia が備えているのは アドホック多相 と パラメータ多相 である、というところまでは JJN で解説しています。
おさらい:部分型多相とは
よくある世に出回っている「クラスベースの言語」が備えているのは 部分型多相 です(他のポリモーフィズムも備えている言語もあります)。
これは「共通の基本型に対するインターフェースを提供すること」という言葉で説明されるものです。
例えば、Python での super().hoge() という記述は、基本クラス(親クラス)の hoge() メソッドを呼び出す記述です。これを利用するとサブクラス(子クラス)で hoge メソッドをオーバーライドしている場合でも親クラスの hoge メソッドを呼び出すことができます。
別の例で言うと、Java ではあるクラス C が他のクラス A を継承していて、C で A のメソッド hoge() をオーバーライドしているとしたとき、クラス C のオブジェクト(インスタンス)c に対して A a = c; a.hoge(); という記述で(クラス C のではなく)クラス A の hoge() メソッドを呼び出すことができます(アップキャストと言います)。
これらが部分型多相(のコア)となります。
Julia と部分型多相
Julia には、この『部分型多相』にあたる機能が(直接的には)ありません。
その理由を大雑把に説明すると、以下のような感じになります:
- Julia にはクラスがなく継承もない。
- Julia のオブジェクトは唯一の具象型に属し、他の具象型のインスタンスとはなり得ない(=キャストという概念が存在しない)。
- 公称型システム(Nominative Typing System)を備えており、基本型-派生型(俗に言う型の親子関係)はあるが、親の型に対して定義されたメソッドはあくまで「デフォルト実装」でしかなく、派生型で再定義する(=別の型で多重定義する=アドホック多相)ことはできるがそうすると親の型のメソッドが(そのインスタンスに対して)呼び出せなくなる。
JJN では「だから Julia には部分型多相がない」という説明をしています。
でも実は。これは少しだけウソです。
実際にはこの『部分型多相のような仕組み』は Julia にもあります。その仕組みは2種類あります。
-
@invoke(invoke()関数)を用いる方法 - Holyトレイト というテクニックを用いる方法
これらを具体例とともに紹介していきます。
Julia で部分型多相のエミュレーション
お題:時刻を扱う型とその文字列表現(出力仕様)
例えば以下の仕様を満たす型を考えます。
- 時刻を扱う型の、共通の基本型(抽象型)
AbstractTimeがある。 - その文字列表現(または出力仕様)は
"hh:mm:ss"という形式(すなわち「時(0埋め2桁)」「分(0埋め2桁)」「秒(0埋め2桁)」をコロン(':')区切りで並べる)とする。- この仕様はデフォルト仕様であり、派生型(サブタイプ)でこれを変更することができる。
-
AbstractTimeの派生型(サブタイプ)MyTimeがあり、これは以下の仕様とする:- フィールドとして
hour(時)、minute(分)、second(秒)を持つ。 - 上記の出力仕様を満たす。
- フィールドとして
-
AbstractTimeの派生型(サブタイプ)MyTime2があり、これは以下の出力仕様とする:- フィールドとして
seconds(0:00:00 からの秒数時)だけを持つ。 - 出力仕様は
"hh:mm:ss (XXXsecs.)"、つまり前半はデフォルト仕様のままで、その後に" (《0:00:00 からの秒数》secs.)"を付加する。
- フィールドとして
-
AbstractTimeの派生型(サブタイプ)MyTimeWithMSがあり、これは以下の出力仕様とする:- フィールドとして
hour(時)、minute(分)、second(秒)およびmillisecond(ミリ秒)を持つ。 - 出力仕様は
"hh:mm:ss.SSS"、つまり前半はデフォルト仕様のままで、その後に".《ミリ秒:0埋め3桁》"を付加する。
- フィールドとして
具体的な挙動をお見せした方が分かりやすいでしょう。なお以降のコード例中で、#> ~ というコメントは実行結果(式を評価した結果得られる値)、## ~ は標準出力への出力を示しています。
# spec of `MyTime`
MyTime <: AbstractTime
#> true
mytime1 = MyTime(14, 28, 57);
println(mytime1)
## 14:28:57
string(mytime1)
#> "14:28:57"
# spec of `MyTime2`
MyTime2 <: AbstractTime
#> true
mytime2 = MyTime2(10000); # 午前0時の10000秒後は 2時46分40秒
println(mytime2)
## 02:46:40 (10000sec(s).)
string(mytime2)
#> "02:46:40 (10000sec(s).)"
# spec of `MyTimeWithMS`
MyTimeWithMS <: AbstractTime
#> true
mytime3 = MyTimeWithMS(12, 34, 56, 789);
println(mytime3)
## 12:34:56.789
string(mytime3)
#> "12:34:56.789"
本題の「Julia での実装」の前に、例えば Python なら以下のようなコードで実装されます:
from abc import ABC, abstractmethod
class AbstractTime(ABC):
@property
@abstractmethod
def hour(self) -> int:
pass
@property
@abstractmethod
def minute(self) -> int:
pass
@property
@abstractmethod
def second(self) -> int:
pass
def __str__(self) -> str:
return "{:02d}:{:02d}:{:02d}".format(self.hour, self.minute, self.second)
class MyTime(AbstractTime):
def __init__(self, hour, minute, second) -> 'MyTime':
self._hour = hour
self._minute = minute
self._second = second
@property
def hour(self) -> int:
return self._hour
@property
def minute(self) -> int:
return self._minute
@property
def second(self) -> int:
return self._second
class MyTime2(AbstractTime):
def __init__(self, seconds) -> None:
self._seconds = seconds
@property
def hour(self) -> int:
return self._seconds // 3600
@property
def minute(self) -> int:
return self._seconds // 60 % 60
@property
def second(self) -> int:
return self._seconds % 60
@property
def seconds(self) -> int:
return self._seconds
# override
def __str__(self) -> str:
return super().__str__() + " ({}sec(s).)".format(self.seconds) # ←ココ!
class MyTimeWithMS(MyTime):
def __init__(self, h, m, s, ms) -> None:
super().__init__(h, m, s)
self._ms = ms
@property
def millisecond(self) -> int:
return self._ms
# override
def __str__(self) -> str:
return super().__str__() + ".{:03d}".format(self.millisecond) # ←ココ!
ポイントは MyTime2 および MyTimeWithMS の __str__() メソッドのオーバーライド部分(# ←ココ! というコメントを付加した行)に現れている super().__str__() というコード。ここが部分型多相の典型的な利用例です。基本クラス(親クラス)で既に定義されているメソッドを呼び出して、それに付加する形で新たなメソッドを定義しています。これにより無駄な「同じコードの繰り返し」の抑制(DRY原則)を実現しているわけです。
実行例も以下に示しておきます。
mytime1 = MyTime(14, 28, 57)
print(mytime1)
## 14:28:57
str(mytime1)
#> '14:28:57'
mytime2 = MyTime2(10000) # 午前0時の10000秒後は 2時46分40秒
print(mytime2)
## 02:46:40 (10000sec(s).)
str(mytime2)
#> '02:46:40 (10000sec(s).)'
mytime3 = MyTimeWithMS(12, 34, 56, 789)
print(mytime3)
## 12:34:56.789
str(mytime3)
#> '12:34:56.789'
これと同じようなことを Julia でやるにはどうすれば良いか? というのがこれから説明する本題になります。
実装例1:@invoke を利用する例
まずは @invoke を利用して実現したコード全体を先に提示します。
# AbstractTime
abstract type AbstractTime end # P1
function gethour end # P2
function getminute end # P2
function getsecond end # P2
function Base.show(io::IO, time::AbstractTime) # P3
print(io,
string(gethour(time), pad=2),
':',
string(getminute(time), pad=2),
':',
string(getsecond(time), pad=2))
end
# MyTime
struct MyTime <: AbstractTime # P4
hour::Int
minute::Int
second::Int
end
gethour(time::MyTime) = time.hour
getminute(time::MyTime) = time.minute
getsecond(time::MyTime) = time.second
# MyTime2
struct MyTime2 <: AbstractTime # P5
seconds::Int
end
gethour(time::MyTime2) = time.seconds ÷ 3600
getminute(time::MyTime2) = time.seconds ÷ 60 % 60
getsecond(time::MyTime2) = time.seconds % 60
getseconds(time::MyTime2) = time.seconds
function Base.show(io::IO, time::MyTime2) # P6
# `invoke(show, Tuple{IO, AbstractTime}, io, time)` と書いても同じ
@invoke show(io, time::AbstractTime) # ←ココ! # P7
print(io, " (", getseconds(time), "sec(s).)")
end
# MyTimeWithMS
struct MyTimeWithMS <: AbstractTime # P8
hms::MyTime
ms::Int
MyTimeWithMS(h, m, s, ms) = new(MyTime(h, m, s), ms)
end
gethour(time::MyTimeWithMS) = gethour(time.hms)
getminute(time::MyTimeWithMS) = getminute(time.hms)
getsecond(time::MyTimeWithMS) = getsecond(time.hms)
getmillisecond(time::MyTimeWithMS) = time.ms
function Base.show(io::IO, time::MyTimeWithMS) # P9
# `invoke(show, Tuple{IO, AbstractTime}, io, time)` と書いても同じ
@invoke show(io, time::AbstractTime) # ←ココ! # P7
print(io, '.', string(getmillisecond(time), pad=3))
end
コード中に埋め込んだコメント(# P1 など)も含め、全体的に解説していきます。
- (P1) Julia では
abstract type ~ endで抽象型(基本型)を定義します。- Julia の抽象型はフィールドを持てません!ついでに言うと具象型の派生型として別の具象型を作ることも許されていません!なので Julia には(構造の)継承という概念がありません!
- (P2) Julia ではこのように
function hoge endとだけ記述することで、関数の宣言のようなことができます。 - (P2) の他にも今回
getxxxx()という関数を定義していますが、Julia は関数の多重定義ができることにより、「まず関数を宣言してそれに機能的な意味を持たせる」「そこに渡す引数の種類(シグニチャ)によって実装を分ける」というような設計思想がフィットします。つまり「プロパティベース(構造主体)」ではなく「関数ベース(機能主体)」での設計が推奨されます。 - (P3) は
Base.show()という関数を多重定義(メソッド追加)しています。これによりその型のインスタンスをprint()などで出力したときの挙動(=出力仕様)を定義できるほか、文字列化の機構も担っています(string()関数による文字列化時にこのメソッドが利用される仕組みになっています)。- その実装中で 各
getxxxx()関数を呼び出して利用しています。これはtimeの実行時の型によって適切なメソッドが選択され実行される(=動的ディスパッチ)仕組みになっており、Julia の関数ベースの実装方法の典型的な書き方になっています(これ自体は部分型多相とは無関係です)。
- その実装中で 各
- (P4) 派生型
MyTimeの実装例です。- 必要なフィールドと、各
getxxxx()関数の実装を行っています。ここではフィールド名と関数名が合致している素直な実装になっています。 - あと後述の他の型と違い、
Base.show()は多重定義していません(=基本型に対する実装(=デフォルト実装)を利用することになります)。
- 必要なフィールドと、各
- (P5) 派生型
MyTime2の実装例です。- フィールドは
secondsのみ、各getxxxx()関数はそこから算出する実装となっています。
- フィールドは
- (P6) 派生型
MyTime2に対するBase.show()の実装例(多重定義=メソッド追加)です。-
(P7)
@invoke show(io, time::AbstractTime)という記述がポイントです。これはBase.show()の実装中でshow()関数を呼び出していますが、その際にtimeの実行時の型ではなく特定の型(AbstractTime)によって適切なメソッドを選択し実行する仕組みになっています。これが 部分型多相のエミュレーション になります(詳細後述)。
-
(P7)
- (P8) 派生型
MyTimeWithMSの実装例です。- フィールドは
hmsとmsの2つ、そのうちhmsはMyTime型としています。 -
gethour()/getminute()/getsecond()はhmsに対して呼び出しています。これは 委譲(Deligation) の仕組みであり、継承という機構を持たない Julia で「既存の他の型が基本的なフィールドを持っているからそれを利用したい」というシチュエーションに対する典型的な実現手段です。
- フィールドは
- (P9) 派生型
MyTimeWithに対するBase.show()の実装例(多重定義=メソッド追加)です。- (P7) 先ほどと同様なので説明省略します。
実行例も載せておきます。
mytime1 = MyTime(14, 28, 57);
println(mytime1)
## 14:28:57
string(mytime1)
#> '14:28:57'
mytime2 = MyTime2(10000); # 午前0時の10000秒後は 2時46分40秒
println(mytime2)
## 02:46:40 (10000sec(s).)
string(mytime2)
#> '02:46:40 (10000sec(s).)'
mytime3 = MyTimeWithMS(12, 34, 56, 789);
println(mytime3)
## 12:34:56.789
string(mytime3)
#> '12:34:56.789'
@invoke (invoke() 関数)
本小節における一番のポイントはもちろん、@invoke ~ という記述箇所です。これについて少し詳しく説明します。
@invoke fn(arg::T, ...) のように記述すると、「arg という引数の型が T であるものと見なして fn() と言う関数を呼び出す」というような意味になります。ただし以下の制約があります。
-
arg isa Tである、つまりargの実行時の型がTまたはその派生型であること。- サブタイピング関係にない型を指定すると実行時エラーになります(例えば
time2 = MyTime2(10000)に対してtime2::MyTimeとするとエラーになります)。 -
::Tは省略可能、省略するとargの実行時の型になります。
- サブタイピング関係にない型を指定すると実行時エラーになります(例えば
つまり、コード中の (P7) で示した @invoke show(io, time::AbstractTime) というコードは、「time という引数の型を AbstractTime であるものと見なして show() 呼び出す」という意味になります。すると (P3) で定義した Base.show() の定義(メソッド)が呼び出される、つまり "hh:mm:ss" と出力(文字列化)される、というわけです。
この仕組みの説明、見覚えありませんか?
Java での「一旦親クラス A の変数 a に代入してから a.hoge() を呼び出す」という アップキャストの仕組みと似ていますよね?
つまり @invoke を使うと「ある型の基本型(抽象型)に対するメソッドが(存在したらそれが)選択されて実行される」という 部分型多相の挙動がエミュレートできる と言うわけです。
補足:invoke() 関数
Julia では @ で始まるのは マクロ です。Julia のマクロは、コードの構文木(AST)を受け取ってそれを変換することができる機能です。
@invoke ~ もマクロ呼び出しの記述であり、コード例中にも示している通り @invoke show(io, time::AbstractTime) は invoke(show, Tuple{IO, AbstractTime}, io, time) という関数呼び出しと同等となります(本当は少し異なります、詳細後述)。
つまり Julia のこの機能の実体は invoke() という関数になります。これは以下のような仕様の関数です:
- 第1引数は、呼び出したい関数(総称関数)。
- 第2引数は、その関数の 型シグニチャ、すなわち第3引数以降の「呼び出したい関数に渡したい引数」の型情報をまとめたもの。
- 第3引数以降は、呼び出したい関数に渡したい引数。
- 戻り値は、実際に呼び出された関数(メソッド)の戻り値。
例えば、呼び出したい関数を fn() そこに渡したい引数列を arg1, arg2 とするとき、@invoke fn(arg1, arg2) は invoke(fn, Tuple{typeof(arg1), typeof(arg2)}, arg1, arg2) と同等になります(typeof(a) は引数 a の 実行時の型 を返す関数です)。ただしさらにこの場合は普通に fn(arg1, arg2) と書いたのと同等になります。なぜならその意味するところは「関数 fn() の、型シグニチャ Tuple{typeof(arg1), typeof(arg2)}(=渡したい引数 arg1およびarg2 の実行時の型情報)に合致するメソッドを選択して、実行する」という意味になるからです。
これを、型シグニチャを適当に調整して、例えば invoke(fn, Tuple{T1, T2}, arg1, arg2)(@invoke fn(arg1::T1, arg2::T2) と同等) と書くことで、arg1 と arg2 の型が T1 および T2 である(これらは実行時の型とは限らない)ものと見なして fn() を呼び出す、という意味になります。
つまり @invoke マクロはこの関数を扱いやすくするためのモノなのです! 記述は通常の関数呼び出しっぽくなりますし、型シグニチャと実際の引数の対応が arg1::T1 のように直接的になりますし、さらに::T1 の部分は省略できる(省略されたら typeof(arg1) で補完される)など、良いことずくめの便利ものです!
ただし。@invoke は比較的新しいAPIで、Julia 1.7 で導入され、v1.9 から Base モジュールでエクスポートされた(=安定APIとして公開された)たものです。お使いの Julia が最新安定版(2024/03/21 時点で v1.10.2)以降なら問題なく使用できますが、v1.7/1.8 をお使いなら Base.@invoke というようにモジュールプレフィックス Base. を付ける必要がありますし、v1.6(2024/03/21 時点のLTSは v1.6.7)およびそれ以前のバージョンでは使えません。その場合は invoke() 関数を直接利用することになります。
実装例2:Holyトレイト を利用する例
実装例1で「部分型多相をエミュレートするほぼ直接的な方法があるんだからもうそれで良くない?」と思われるかも知れませんが、もう少々お付き合いください。
似たようなことを実現する方法として、続いては Holyトレイト というテクニック(トリック)を利用した方法を紹介します。
まずは実装例を示します。
# AbstractTime
abstract type AbstractTime end # P1
function gethour end # P1
function getminute end # P1
function getsecond end # P1
# ↓のデフォルト実装も先にしておく # P2
getseconds(time::AbstractTime) =
(gethour(time) * 60 + getminute(time)) * 60 + getsecond(time)
getmillisecond(::AbstractTime) = 0
# Trait types # P3
abstract type TimeStyle end
struct Simple <: TimeStyle end
struct WithSeconds <: TimeStyle end
struct WithMS <: TimeStyle end
# トレイト型のデフォルト= `Simple()` # P4
TimeStyle(::Type{<:AbstractTime}) = Simple()
Base.show(io::IO, time::T) where {T <: AbstractTime} =
showtime(io, TimeStyle(T), time) # P5
function showtime(io::IO, ::Simple, time) # P6
print(io,
string(gethour(time), pad=2),
':',
string(getminute(time), pad=2),
':',
string(getsecond(time), pad=2))
end
function showtime(io::IO, ::WithSeconds, time) # P7
showtime(io, Simple(), time) # ←ココ!
print(io, " (", getseconds(time), "sec(s).)")
end
function showtime(io::IO, ::WithMS, time) # P8
showtime(io, Simple(), time) # ←ココ!
print(io, '.', string(getmillisecond(time), pad=3))
end
# MyTime
struct MyTime <: AbstractTime # P9
hour::Int
minute::Int
second::Int
end
gethour(time::MyTime) = time.hour
getminute(time::MyTime) = time.minute
getsecond(time::MyTime) = time.second
# MyTime2
struct MyTime2 <: AbstractTime # P10
seconds::Int
end
gethour(time::MyTime2) = time.seconds ÷ 3600
getminute(time::MyTime2) = time.seconds ÷ 60 % 60
getsecond(time::MyTime2) = time.seconds % 60
getseconds(time::MyTime2) = time.seconds # override
TimeStyle(::Type{MyTime2}) = WithSeconds() # ←ココ! # P11
# MyTimeWithMS
struct MyTimeWithMS <: AbstractTime # P12
hms::MyTime
ms::Int
MyTimeWithMS(h, m, s, ms) = new(MyTime(h, m, s), ms)
end
gethour(time::MyTimeWithMS) = gethour(time.hms)
getminute(time::MyTimeWithMS) = getminute(time.hms)
getsecond(time::MyTimeWithMS) = getsecond(time.hms)
getmillisecond(time::MyTimeWithMS) = time.ms
TimeStyle(::Type{MyTimeWithMS}) = WithMS() # ←ココ! # P13
先ほどと同様、コード中に埋め込んだコメント(# P1 など)を中心に解説していきます。
- (P1)
AbstractTimeの定義は基本そのままです。 - (P2) 先ほどは
Mytime2,MyTimeWithMSを定義したときにだけ定義していたgetseconds()およびgetmillisecond()をここで先に実装(デフォルト実装)しておきます(理由は後述)。-
getseconds()はgethour()/getminute()/getsecond()から算出する実装になっています。getmillisecond()は0を返すだけの実装です。
-
-
(P3) ここからが重要。まずは トレイト型 というものを定義します。
- まずは
TimeStyleという抽象型。これは以下に挙げるすべての「トレイト型」の基本型(親の型)であり、同時に所謂ファクトリー関数の役割をします。 - 続いてこの
TimeStyleから派生した3つの型Simple,WithSeconds,WithMSを定義します。中身は空(フィールド無し)です(シングルトン型 と言います)。これらが「トレイト型」と呼ばれるもので、それぞれ「(時刻のスタイル が)シンプル(=時分秒のみ)」/「秒数付き」/「ミリ秒付き」という意味です。
- まずは
-
(P4) 先ほど述べた「ファクトリー関数」のデフォルト実装です。
AbstractTimeまたはその派生型を受け取ってSimple()を返すよう実装しています。- 仮引数の記述
::Type{<:AbstractTime}は、「引数として 型を受け取る(::Type{~})、その型はAbstractTimeまたはその派生型である(<:AbstractTime)」と読み解いてください。
例えばTimeStyle(Abstracttime) === Simple()となります。
- 仮引数の記述
- (P5) ここでやっと
Base.show()を多重定義(メソッド追加)します。ただしその実装はこの後定義するshowtime()を呼び出すだけの実装(showtime(io, TimeStyle(T), time))です。- 引数列の後の
where {T <: AbstractTime}は、Tという型パラメータを利用し、その型がAbstractTimeまたはその派生型である(型制約)、という意味です。このように型パラメータを利用している関数を特に パラメトリック関数 と呼びます。
- 引数列の後の
- (P6)
showtime()関数の多重定義その1。第2引数にSimple()を受け取る実装です。- 第2引数の仮引数の指定が
::Simpleとなっていますが、これは「Simple型の引数を受け取るがそのインスタンス(実引数の値)は使用しない」という指定です。シングルトン(=シングルトン型のインスタンス)を受け取る時によく利用されます。以下の (P7) および (P8) も同様です。 - 先ほど説明したとおり、
Simpleは「(時刻のスタイルが)シンプル(=時分秒のみ)」という意味です。なのでそのような実装(先ほどの例でAbstractTimeに対して定義したBase.show()のデフォルト実装と同じ内容)となっています。
- 第2引数の仮引数の指定が
- (P7)
showtime()関数の多重定義その2。第2引数にWithSeconds()を受け取る実装です。-
WithSecondsは「(時分秒に加えて)秒数付き」という意味でした。なのでそのような実装なのですが、前半はshowtime(io, Simple(), time)としています(# ←ココ!とコメントしている行)。ここがポイント。これが 部分型多相のエミュレーション になっています(詳細後述)。
-
- (P8)
showtime()関数の多重定義その3。第2引数にWithMS()を受け取る実装です。-
WithMSは「(時分秒に加えて)ミリ秒付き」という意味でした。なのでそのような実装なのですが、先ほどと同様showtime(io, Simple(), time) # ←ココ!という行に注目です。
-
- (P9) 派生型
MyTimeの実装例です(やっと)。これは先ほどの@invokeの例と全く同じです。 - (P10) 派生型
MyTime2の実装例です。これも途中までは先の例と同じです。-
(P11) 先ほどはこの型に特化した
Base.show()の多重定義をしましたが、今回はTimeStyle(::Type{MyTime2}) = WithSeconds()という1行を追加しているだけです。これだけなのですが字面的にも「MyTime2の時刻のスタイルは『秒数付き』」と読めるので 可読性も上がります!
-
(P11) 先ほどはこの型に特化した
- (P12) 派生型
MyTimeWithMSの実装例です。やはり途中までは先の例と同じです。-
(P13)
MyTime2と同様、TimeStyle(::Type{MyTimeWithMS}) = WithMS()という1行を追加しているだけです。やはり「MyTimeWithMSの時刻のスタイルは『ミリ秒付き』」と読めます!
-
(P13)
実行結果は先ほどと全く同様となります(念のため載せておきます)。
mytime1 = MyTime(14, 28, 57);
println(mytime1)
## 14:28:57
string(mytime1)
#> '14:28:57'
mytime2 = MyTime2(10000); # 午前0時の10000秒後は 2時46分40秒
println(mytime2)
## 02:46:40 (10000sec(s).)
string(mytime2)
#> '02:46:40 (10000sec(s).)'
mytime3 = MyTimeWithMS(12, 34, 56, 789);
println(mytime3)
## 12:34:56.789
string(mytime3)
#> '12:34:56.789'
Holyトレイト とは?
Holyトレイト(Tim Holy Traits Trick とも)については JJN でも紹介しています(JJN p.331 第5章「5-6. Holy トレイト」)が、この記事でも簡単に説明しておきます。
まずプログラミングにおける トレイト(Trait)とは、ざっくり言うと『(オブジェクトの)振る舞いを定義したもの』で、ある振る舞い(機能)を、継承やサブタイピングに依らずに複数の型に対して追加することを可能とする仕組みの1つです。言語機能としてトレイトを取り入れている言語もあります。
Julia には言語機能としてのトレイトはありませんが、多重ディスパッチの仕組みを利用してトレイトのような仕組みを実現するテクニック(トリック)があり、それが Holyトレイト と名付けられています(なお "Holy" は考案者(Julia コミッタ)の名前です)。
その仕組みは「役割を意味する名前を持つ型(トレイト型)を用意する」「その型のインスタンスを受け取る関数を多重定義し、その意味(や役割)に合った実装をする」というものです。
上のコード例で言えば、TimeStyle という抽象型と、それから派生した Simple, WithSeconds, WithMS という各 トレイト型 を用意し、そのインスタンスを受け取る showtime() 関数を多重定義して、その第2引数に応じて適切な実装を行っています。
Holyトレイト の肝となるのは、「実装の共有(共通化)」と言う点です。
例えば、今回は「MyTime(時刻のスタイルは Simple すなわち『時:分:秒』のみ)」「MyTime2(時刻のスタイルは WithSeconds すなわち『時:分:秒 (秒数)』)」「MyTimeWithMS(時刻のスタイルは WithMS すなわち『時:分:秒.ミリ秒』)」の3つだけですが、もし別の型を定義(例: MyTime3)して、それも「時:分:秒」/「時:分:秒 (秒数)」/「時:分:秒.ミリ秒」いずれかのスタイル(例:WithMS)で出力したければ、それを意味する一行(例:TimeStyle(::Type{MyTime3}) = WithMS())を追加するだけで済みます。
それに加えて、showtime() 関数の Simple 以外の実装で、内部で showtime(io, Simple(), time) を呼び出していたことを思い出してください。これにより、本来 Simple スタイル(=「時:分:秒」)でない型に対しても、Simple スタイル(=AbstractTime のスタイル)の出力を行うことができているのです。つまりこれが 部分型多相のエミュレーション をしているコードにあたるわけです。ですが、それ以上の機能を持っています。
showtime() の3つの実装(多重定義)、いずれも第3引数 time に型アノテーションが付いていないのに気付きましたか? @invoke の例では time::AbstractTime/time::MyTime2/time::MyTimeWithMS としていましたが、これにより「特定の型(またはその他の AbstractTime の派生型)ごとに実装を分けていた」つまり「time の具象型ごとにメソッドを分けていた」のです。Holyトレイト の例では「time の具象型は何でも良い(見ていない、その代わりにその1つ前の トレイト型 の違いでメソッドを分けているだけ)」なのです。
つまり、time::MyTime でも show(io, MyTimeWithSeconds(), time) や show(io, MyTimeWithMS(), time) が呼び出せます!(time::MyTime2、time::MyTimeWithMS でも同様です)ただしそのためには getseconds() や getmillisecond() のデフォルト実装も必要になってきます(なので (P2) で先に実装していたのです)。
文章での説明が長くなったので、簡単にまとめなおすと以下のようになります。
- Holyトレイト の仕組みを利用すると、処理対象オブジェクト(の具象型)に依らず、振る舞い(意味や役割)ごとに実装を共有できる!
- そのための最低限のAPI(関数とその実装)が用意されていれば、同じオブジェクトでも トレイト型 を切り替えることで 振る舞いを切り替えることができる!
後者の機能を利用した分かりやすい例を挙げておきます。
# 文字列化関数のメソッド追加
Base.string(time::T; style::TimeStyle = TimeStyle(T)) where {T<:AbstractTime} =
sprint(showtime, style, time)
# `sprint()` は、第1引数に `io::IO` を受け取る関数 (`fn`)、第2引数以降にその関数に渡す引数 (`args...`) を受け取り、
# `fn(io, args...)` の出力結果からなる文字列を返す関数
string(mytime1) # `mytime1` は先の例で `MyTime(14, 28, 57);` としたもの
#> "14:28:57"
string(mytime2) # `mytime2` は先の例で `MyTime2(10000);` としたもの
#> "02:46:40 (10000sec(s).)"
string(mytime3) # `mytime3` は先の例で `MyTimeWithMS(12, 34, 56, 789);` としたもの
#> "12:34:56.789"
string(mytime1, style=Simple()) # == string(mytime1)
#> "14:28:57"
string(mytime2, style=Simple())
#> "02:46:40"
string(mytime3, style=Simple())
#> "12:34:56"
string(mytime1, style=WithSeconds())
#> "14:28:57 (52137sec(s).)"
string(mytime2, style=WithSeconds()) # == string(mytime2)
#> "02:46:40 (10000sec(s).)"
string(mytime3, style=WithSeconds())
#> "12:34:56 (45296sec(s).)"
string(mytime1, style=WithMS())
#> "14:28:57.000"
string(mytime2, style=WithMS())
#> "02:46:40.000"
string(mytime3, style=WithMS()) # == string(mytime3)
#> "12:34:56.789"
補足:Holyトレイト の応用例
Holyトレイト はなんだかトリッキーで小難しそうな印象を受けた方もいらっしゃるかもしれませんが、Julia の標準関数(標準ライブラリ)でも多用されています。以下に一例を挙げます:
- 配列(多次元配列)のインデクシング(
IndexLinear、IndexCartesian)(JJN でも紹介(詳解)しています(p.335 第5章「5-6-2. 実例:IndexStyle」)) - イテレータのサイズ(
HasLength,HasShape,IsInfinite,SizeUnknown)(JJN p.356 第6章イテレータ→「Base.IteratorSize」) - イテレータの要素の型について(
HasEltype,EltypeUnknown)(JJN p.361 第6章イテレータ→「Base.IteratorEltype」) - ブロードキャスティングのスタイル(種類が多く独自定義もできるので詳細略、JJN p.412 第7章ブロードキャスティング→「Broadcast.BroadcastStyle」など参照)
まとめ
- Julia の Holyトレイト は 部分型多相のエミュレーション にも使える。
- 加えて 実装の共有(共通化)(本来の目的)および 振る舞いの切り替え という機能を提供する。
- 使いこなせれば応用の幅が広がる!
- Julia 楽しいよ!
参考リンク等
- JJN(実践Julia入門)
- Julia 公式ドキュメント
-
実験notebooks(Jupyter Notebook 形式, nbviewer)
- MyTimes.py.ipynb(Python による参考実装)
-
MyTimeWithInvoke.jl.ipynb(
@invokeを利用した実装例) - MyTimeByHolyTraits.jl.ipynb(Holyトレイト を利用した実装例)
名古屋のAI企業「来栖川電算」です。LLM・機械学習の仕事をしています。 リモート中心の環境で、尖ったエンジニアがそれぞれの強みを活かして挑戦する会社です。 公式HP→ kurusugawa.jp/ |採用情報→ kurusugawa.jp/jobs/

Discussion