細かすぎてたぶん伝わらないJuliaのTips2023

2023/12/25に公開

メリークリスマス!

この記事は Julia Advent Calendar 2023 の25日目(最終日)の記事です。

はじめに

昨年も同じ日に同じようなネタでお祝いしました(細かすぎて伝わらないかもしれないJuliaのTips)。
今年は、ちょっとだけ方向性を変えたいと思います。

今年、私が少しずつ書いてきた Julia の入門書(『実践Julia入門』)が出版(商業出版)されました。
そこに記載の内容も元にして、基本的な「Julia ユーザに知ってほしい常識」を Tips 形式で紹介したカレンダー記事を別日に投稿しています(Julia の(による)新常識 #Julia - Qiita)。

それとも区別(差別化)するために今回は、以下の点を指針とした記事をお届けします:

  • 拙著『実践Julia入門』(長いので以下 JJN と略記しますw)に 載っていない内容 を中心に紹介する。
    • JJN が Julia v1.6/v1.7(ほんの一部 v1.8)をベースとしているので、それ以降に追加(・変更)された Julia の機能・仕様などを主に紹介する。
    • JJN に載せるにはボリュームがありすぎて泣く泣く削った内容も一部含む[1]
  • シチュエーション別と見せかけた API別 寄りの紹介。

それでは早速参りましょう!

1. try 式の else

Julia v1.8 で、try文(try式)にオプションの else ブロックを付けることができるようになりました。

try
    《何らかの処理》
catch e
    println("エラー発生")
else
    println("正常終了")
finally
    println("後処理")
end

このように書くと、以下のような動作となります。

  • 「《何らかの処理》」でエラーが発生した場合→「エラー発生」「後処理」と出力される
  • 「《何らかの処理》」でエラーが発生 しなかった 場合→「正常終了」「後処理」と出力される

でも上記のような動作とするには、別に以下のように書いても良いんじゃないか?って思いますよね?

try
    《何らかの処理》
    println("正常終了")
catch e
    println("エラー発生")
finally
    println("後処理")
end

なんでそれでもわざわざ else ブロックという仕様を追加したんでしょう? それを解説していきます。

例えば、以下のようなコードを考えてみましょう。

"""
    catch_error(fn)

関数 `fn()` を実行したときに発生するエラー(例外)を捕捉して返す。  
エラーが発生しなかった場合は `No Errors Occured!` というエラーを発生させる。

# Arguments
- `fn::Callable` : 実行する関数(`do` 構文利用可能)
"""
function catch_error(fn)
    try
        fn()
    catch e
        @info "catched" e
        e
    else
        @error "No Errors Occured!"
        throw("No Errors Occured!")
    finally
        @debug "catch_error($fn) Done."
    end
end

これは「引数に受け取った関数 fn() を実行したときに発生するエラー(例外)を捕捉して返す」関数です。ただしエラーが発生しなかったときには「何もエラーが発生しなかった!」というエラーをスローします。

動作例↓

julia> ENV["JULIA_DEBUG"] = @__MODULE__
Main

julia> catch_error() do
           3 ÷ 0  # ゼロ除算エラーが発生する
       end
┌ Info: catched
└   e = DivideError: integer division error
┌ Debug: catch_error(#11) Done.
└ @ Main REPL[8]:11
DivideError()

julia> catch_error() do
           3 / 0  # エラーは発生しない(結果は `Inf` になる)
       end
┌ Error: No Errors Occured!
└ @ Main REPL[8]:8
┌ Debug: catch_error(#13) Done.
└ @ Main REPL[8]:11
ERROR: "No Errors Occured!"
Stacktrace:
 [1] catch_error(fn::var"#13#14")
   @ Main ./REPL[8]:9
 [2] top-level scope
   @ REPL[10]:1

整数除算(÷)では 3 ÷ 0 等はゼロ除算エラー(DivideError())が発生します。なので前半は戻り値として DivideError() が得られます。
一方浮動小数点数の演算となる 3 / 03.0 / 0.0 と同じ)は、エラーは発生せず Inf が返ってきます。なので後半は「エラーが発生しなかった!」というエラーとなっていることが分かります。
さらに 随所に @info/@error/@debug というログ出力マクロを埋め込んでいますが、先ほどの説明の通り「エラーが発生したら @infocatch ブロック内)と @debugfinally ブロック内)が出力」され、「エラーが発生しなかったら @errorelse ブロック内)と @debugfinally ブロック内)が出力」されていることが分かりますね(出力順にも注目)。

これを、elseブロック内の内容(@error ~とその次の行throw("No Errors Occured!"))を try ブロックの中に書いてしまうと、それも catch ブロックで捕捉されてしまします。ログには @error/@info/@debug の内容全てが出力され、捕捉されたエラーは戻り値として返ります。
また try ブロックの外に書いてしまうと、@debugの出力の後に @error の出力がされてしまいます。今回は分かりやすいようにただのログ出力にしているのであまり重要性が感じられないかもしれませんが、もしその順番が大事な処理だったら問題となります。

またこれは動作が簡単に確認できるような分かりやすい極端な例ですが、明示的にエラーを発生させるのではなく「エラーが発生するかもしれないけれどそれを捕捉してほしくない」というケースに置き換えれば、きっと有用なユースケースがあると思います。
つまり仮想コードを書き直すと以下のようになります。

try
    《エラーが発生したときにそれを捕捉してほしい何らかの処理》
catch e
    《捕捉したエラーに対してのなんらかの適切な処置》
else
    《エラーが発生しなかったときの後続処理、
     ただしここで発生したエラーは捕捉せずに呼び出し元にそのまま投げればOK》
finally
    《どうしても一番最後(`try`/`catch`/`else` 各ブロックの処理よりも後)に実行したい後処理》
end

まとめると以下のようになります:

補足1: Julia v1.7 以前はどうすれば良い?

実はこのような動作を期待するコードは、以下のように else を用いなくても一応は実現可能です。

仮想コード:

try
    try
        《エラーが発生したときにそれを捕捉してほしい何らかの処理》
    catch e
        《捕捉したエラーに対してのなんらかの適切な処置》
        《`break`(ループ内の場合)や `return`(関数の場合)などで後続処理に移らないよう工夫する(※1)》
    end
    《エラーが発生しなかったときの後続処理、
     ただしここで発生したエラーは捕捉せずに呼び出し元にそのまま投げればOK》
finally
    《どうしても一番最後に実行したい後処理》
end

上記仮想コード中の『(※1)』の部分は「補足したエラー(例外)を呼び出し元に伝播させたくない」というシチュエーションを想定したものです。
もしそうでなければここは rethorw() でOKです。これは catch ブロック内で捕捉したエラーをそのまま呼び出し元に投げる関数です。

先ほどの例の書き直し:

"""
    catch_error_wo_else(fn)

関数 `fn()` を実行したときに発生するエラー(例外)を捕捉して返す。  
エラーが発生しなかった場合は `No Errors Occured!` というエラーを発生させる。

# Arguments
- `fn::Callable` : 実行する関数(`do` 構文利用可能)
"""
function catch_error_wo_else(fn)
    try
        try
            fn()
        catch e
            @info "catched" e
            return e
        end
        @error "No Errors Occured!"
        throw("No Errors Occured!")
    finally
        @debug "catch_error($fn) Done."
    end
end

Julia v1.8 以降でもこのコードは動作します。ただし else を用いたコードの方がほんの少し効率の良いコードにコンパイルされるようになっている模様です。
それにもしこのように書かざるを得ないシチュエーションが発生した場合、確かに else を用いたコードの方がよっぽどスマートですね[2]

補足2: 他言語の場合

この try文の elseブロック と同様の仕様は、他言語にも存在します。

例えば Python では try: ~ except: ~ else: ~ finally: ~ という構文があります。仕様は Julia と全く同様です(というよりその仕様を Julia に持ち込んだのが v1.8 による仕様追加となっています)。

あと Ruby も begin ~ rescue ~ else ~ ensure ~ end という構文があります。役割は全く同じです。
さらに言うと Ruby の場合は、これをメソッド定義の中に埋め込むことができます。つまり以下のような定義が有効です。

def catch_error
  yield
rescue => e
  puts "catched #{e}"
  return e
else
  puts "No Errors Occured!"
  raise "No Errors Occured!"
ensure
  puts "catch_error Done."
end

つまりメソッドの処理全体で例外を補足したい場合は begin ~ end をネストせずに書けるのです。その場合 else がないと「例外が発生しなかった場合の(別の例外が発生する可能性のある)処理を書く場所」がそもそもなくなってしまいます。そういう意味でも Ruby の else は理にかなった有用な機能ですね。

動作例:

irb(main):012:1* catch_error do
irb(main):013:1*   3 / 0
irb(main):014:0> end
catched divided by 0
catch_error Done.
=> #<ZeroDivisionError: divided by 0>
irb(main):015:1* catch_error do
irb(main):016:1*   3.0 / 0.0
irb(main):017:0> end
No Errors Occured!
catch_error Done.
(irb):8:in `catch_error': No Errors Occured! (RuntimeError)
        from (irb):15:in `<main>'
        from path/to/ruby/3.2.2/lib/ruby/gems/3.2.0/gems/irb-1.6.2/exe/irb:11:in `<top (required)>'
        from path/to/ruby/3.2.2/bin/irb:25:in `load'
        from path/to/ruby/3.2.2/bin/irb:25:in `<main>'

2. 文字列の 先頭/末尾 から特定の文字列を除去する

Julia v1.8 で、chopprefix() および chopsuffix() という関数が追加されました。

まずは REPL で help を見てみましょう。

help?> chopprefix
search: chopprefix

  `chopprefix(s::AbstractString, prefix::Union{AbstractString,Regex}) -> SubString`

  Remove the prefix `prefix` from `s`. If `s` does not start with `prefix`, a string equal to `s` is returned.

  See also `chopsuffix`.

  │ Julia 1.8
  │
  │  This function is available as of Julia 1.8.

  Examples
  ≡≡≡≡≡≡≡≡≡≡

  julia> chopprefix("Hamburger", "Ham")
  "burger"

  julia> chopprefix("Hamburger", "hotdog")
  "Hamburger"

help?> chopsuffix
search: chopsuffix

  `chopsuffix(s::AbstractString, suffix::Union{AbstractString,Regex}) -> SubString`

  Remove the suffix `suffix` from `s`. If `s` does not end with `suffix`, a string equal to `s` is returned.

  See also `chopprefix`.

  │ Julia 1.8
  │
  │  This function is available as of Julia 1.8.

  Examples
  ≡≡≡≡≡≡≡≡≡≡

  julia> chopsuffix("Hamburger", "er")
  "Hamburg"

  julia> chopsuffix("Hamburger", "hotdog")
  "Hamburger"

つまり、chopprefix() は「文字列 s の先頭にある prefix を削除した文字列を返す」関数であり、chopsuffix() は「文字列 s の末尾にある suffix を削除した文字列を返す」関数です。

動作例もヘルプに載っていますが、比較のためにこの関数を使わずに同じような結果となるコードも示すと以下のようになります:

s_org = "実践Julia入門"  #> "実践Julia入門"

s_new1 = if startswith(s_org, "実践")
    s_org[nextind(s_org, lastindex("実践")):end]
else
    s_org
end  #> "Julia入門"
@assert s_new1 == chopprefix(s_org, "実践") == "Julia入門"

s_new2 = if endswith(s_org, "入門")
    s_org[begin:prevind(s_org, end, length("入門"))]
else
    s_org
end  #> "実践Julia"
@assert s_new2 == chopsuffix(s_org, "入門") == "実践Julia"

意外と面倒な処理をしていることが分かりますね。それにこれらは prefixsuffix として文字列を指定したパターンですが、実際には Regex 型の値(つまり 正規表現)を指定することもできます。
そのような仕様も踏まえてこれを効率良くまた型安定となるように実装されているのが chopprefix()/chopsuffix() 、という訳です。

用途としては、例えば chopsuffix() は、ファイル名から拡張子を取り除くとか、URLからクエリ文字列を取り除くとか、そういうのに使えそうですね[3]

補足: 類似機能を持つ関数

「文字列の先頭/末尾から文字(列)を除去する関数って、今までにもなかったっけ?」と疑問に思った方もいらっしゃるかもしれません。JJN でも紹介していますが、確かにそのような関数としては chop()/strip()lstrip()/rstrip()) が既にあります。
機能的には確かに似ていますが、以下のような違いがあります:

  • chop() は、キーワード引数 head=N/tail=N で先頭/末尾から除去する 文字数 を指定する(デフォルト:head=0, tail=1)。
  • strip()/lstrip()/rstrip() は、第2引数に 文字の種類 を指定(デフォルト:空白文字)もしくは第1引数に 文字の種類を判別するための述語関数(1文字受け取って Booltrueまたはfalse)を返す関数)を指定する。
  • chopprefix()/chopsuffix() は、除去したい 文字列(または 正規表現)を指定する。
s_org = "実践Julia入門"  #> "実践Julia入門"

chop(s_org)  #> "実践Julia入"
chop(s_org, head=2, tail=0)  #> "Julia入門"
chop(s_org, head=0, tail=2)  #> "実践Julia"

strip(s_org)  #> "実践Julia入門"  # (空白文字がないので見た目変化なし)
strip(s_org, ['実', '践', '入', '門'])  #> "Julia"
lstrip(s_org, ['実', '践', '入', '門'])  #> "Julia入門"  # 左側のみ除去
rstrip(s_org, ['実', '践', '入', '門'])  #> "実践Julia"  # 右側のみ除去

chop() は「除去する文字数」が指定できるだけで、具体的な「文字列」を指定できるわけではありません。
strip()lstrip()/rstrip())は、除去する文字の種類を指定できるだけで、こちらもやはり具体的な「文字列」を指定できません。さらに補足すると、文字の種類として指定できるのは「文字(Char型の値)」または「文字(Char型)のコレクション(配列等)」だけであり、文字列を指定するとエラーになります。またあくまで「文字の種類」であり「該当する文字種が続く限りそれを除去する」という挙動となるので、例えば後ろの "入門" だけを消したかったのに rstrip("羅生門入門", ['入', '門']) == "羅生" となってしまいます(『羅生門入門』って何や?というツッコミは置いといてください…良い例が思い浮かばなかった…)。

つまり今まで「文字列の先頭/末尾から 特定の文字列 を除去する関数」ってそのものズバリのものはなかったわけです。
ただし上記の chop() を利用すれば、先ほど示した「同じような結果となるコード(prefix/suffix が文字列の場合)」が少しだけ簡単に書けるようになります:

my_chopprefix(s::AbstractString, prefix::AbstractString) = 
    chop(s, head=(startswith(s, prefix) ? length(prefix) : 0), tail=0)
my_chopsuffix(s::AbstractString, suffix::AbstractString) = 
    chop(s, head=0, tail=(endswith(s, suffix) ? length(suffix) : 0))

3. 日付の範囲

Julia では範囲オブジェクトを n:m というリテラル表記[4]で生成できます。
Julia v1.9 で、日付型でも Date(xx):Date(yy) のように書けるようになりました。
なお Date(日付型)は Dates 標準パッケージ(Dates モジュール)で定義されています(事前に using Dates が必要です)。

using Dates

for date in Date(2023, 12, 1):Date(2023, 12, 25)
    println(date)
end
## 2023-12-01
## 2023-12-02
##  : # 《中略》
## 2023-12-25

補足: 前からそう書けなかったっけ?

n:m というのは、範囲オブジェクトを生成するリテラル表記(広義のリテラル)で、range(n, m) と同じ意味になります。なお Julia の範囲オブジェクトは閉区間(=開始も終了も含む)です。
n:s:m という書式も利用でき、この場合は s は「ステップ値」です。これは range(n, m, step=s) と同じ意味になります(言い換えると省略時のステップ値のデフォルト値は 1 です)。
ここで nm は、よく使われるのは整数ですが、(整数との)加算(+)が定義されている多くの型で多重定義されています(例:'A':'E' == ['A', 'B', 'C', 'D', 'E'])。

Dates パッケージで定義されている Date(日付型)/Time(時刻型)/DateTime(日時型)も、範囲オブジェクトの生成に対応しています。
ただし、以前は実は Date(xx):Date(yy) のように書くとエラーになっていました。なぜなら、Date(xx) + 1 のような日付型と整数との加算が定義されていないからです。その代わり Date(xx):Day(1):Date(yy) と書く必要がありました。つまり「ステップ値を明示する必要があった」というわけです。

それが v1.9 で Date(xx):Date(yy) と書けるようになった、ということです。なおこれは Date(xx):Day(1):Date(yy) と同じ意味になります。つまり言い換えると「範囲オブジェクトを生成するとき、その開始・終了値の型が Date 型の場合、ステップ値のデフォルト値が Day(1) となるようになった」ということです。
なお REPL で確認すると Date(xx):Date(yy) と書いた場合、Date(xx):Day(1):Date(yy) と表示されます。

julia> @assert VERSION  v"1.9"  # ここでエラーが出なければ以下は期待通りに動作

julia> using Dates

julia> Date(2023, 12, 1):Date(2023, 12, 25)
Date("2023-12-01"):Day(1):Date("2023-12-25")

注意点として、特別扱いされるようになったのは Date(日付型)だけで、Time(時刻型)/DateTime(日時型)は従来通りステップ値を指定する必要があります。
逆に言うと(JJN でも解説していますが)日付・時刻・日時の範囲オブジェクトは、ステップ値に様々な 期間オブジェクト を指定でき、柔軟な「日付計算」ができるようになっています。なので Date の範囲(でステップ値が 1日 の場合)も今まで通り Date(xx):Day(1):Date(yy) と書く癖を付けておいた方が良いと思います(個人の感想です)。

using Dates

for date in Date(2024, 1, 31):Month(1):Date(2024, 12, 31)  # 2024年1月31日から2023年12月31日まで1ヶ月単位(=毎月末!)
    println(date)
end
## 2024-01-31
## 2024-02-29
## 2024-03-31
## 2024-04-30
## 2024-05-31
## 2024-06-30
## 2024-07-31
## 2024-08-31
## 2024-09-30
## 2024-10-31
## 2024-11-30
## 2024-12-31

4. 数値以外の値の無限列挙

Iterators.countfrom(start, step) は、第1引数に指定した値(start)から第2引数に指定した値(step)ずつ増加する無限の(数)列を生成するイテレータを返す関数です。第2引数は省略可能で、省略時のデフォルト値は 1 です。

for n in Iterators.countfrom(1)  # 1以上の整数を列挙
    print(n, ", ")
    if n  10
        println("…")
        break
    end
end
## 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, …

for n in Iterators.countfrom(1, 2)  # 1以上の奇数を列挙
    print(n, ", ")
    if n  20
        println("…")
        break
    end
end
## 1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, …

この関数自身は古くからあり、Julia v1.7 までは第1引数 start に指定できるのは数値(Number の派生型)だけでした。
Julia v1.8 からは、start + step が計算できる(startstep との間に + 演算が定義されている)ものなら何でも指定できるようになりました。
例えば「ある文字以降の文字を列挙する(例:Iterators.countfrom(' ', 1))」とか、前節と類似の例になりますが「ある日付以降の日付を列挙する(例:Iterators.countfrom(Dates.today(), Day(1)))」など、そういったことができるようになった、ということです。
ただしそのような(start に数値以外の値を指定して)利用する場合、一般には第2引数 step は省略できず、明示的に指定する必要があります。

for c in Iterators.countfrom(' ', 1)
    c > '~' && break
    print(c)
    Int(c) % 16 == 15 && println()
end
##  !"#$%&'()*+,-./
## 0123456789:;<=>?
## @ABCDEFGHIJKLMNO
## PQRSTUVWXYZ[\]^_
## `abcdefghijklmno
## pqrstuvwxyz{|}~

using Dates

_today = Dates.today()  #> Date("2023-12-25")
for dt in Iterators.countfrom(Date(2023, 12, 1), Week(1))
    dt > _today && break
    println(dt)
end
## 2023-12-01
## 2023-12-08
## 2023-12-15
## 2023-12-22

# Iterators.countfrom(_today)  # NG
# #@ ERROR: MethodError: no method matching one(::Date)

補足: step が省略できる条件

前節(「3. 日付の範囲」)では「Char なら 'A':'E' のように書けるけれど Date は書けなかった(書けるようになった、でも TimeDateTime は依然としてダメ)」というようなことを述べたと思いますが、こちらは DateCharstep が省略不可です。
その仕組みは、step が省略された場合のデフォルト値を one(start) で算出しようとしているためです(先のコード例の最後のエラーメッセージを見るとそれが分かります)。
one(hoge)hoge と同じ型の 1 の値を返す関数(Int なら 1Float64 なら 1.0ComplexF64(実部・虚部が Float64 の複素数)なら 1.0 + 0.0im など)なのですが、one(' ')one(Date(~)) も定義されていない(のでエラーになる)、と言うわけです。

なので逆に言えば、one(hoge::HOGE) が定義されている型 HOGE なら step は省略可能です。そのような例も挙げておきます。

one([0 1; 1 0])  #> [1 0; 0 1]  # 正方行列に対して `one()` を適用すると、単位行列が返る

for matrix in Iterators.countfrom([0 1; 1 0])
    display(matrix)
    matrix[1]  10 && break
end
## 2×2 Matrix{Int64}:
##  0  1
##  1  0
## 2×2 Matrix{Int64}:
##  1  1
##  1  1
## 2×2 Matrix{Int64}:
##  2  1
##  1  2
##   : # 《中略》
## 2×2 Matrix{Int64}:
##  10   1
##   1  10

5.

Julia は、排他的論理和演算子が というユニコード文字であることで有名ですが、実は (NAND、否定論理積)と (NOR、否定論理和)も存在します。
a ⊼ b!(a & b) と等価で、a ⊽ b!(a | b) と等価です。
なお という演算子が xor() 関数のエイリアスであるのと同様、/ はそれぞれ nand()/nor() という関数のエイリアスです。

X = [false, true, false, true];
Y = [false, false, true, true];
X .⊼ Y  #> [true, true, true, false]  # nand.(X, Y) と同じ
X .⊽ Y  #> [true, false, false, false]  # nor.(X, Y) と同じ

nand())、nor())は Julia v1.7 以降で使用可能です。

補足: Julia のユニコード演算子の入力方法

Julia ではユニコード文字(非ASCII文字)の演算子が利用できること、それが標準でもいくつか用意されていることで有名です。
ただ普通のキーボードにはその文字を直接入力する方法はもちろん提供されていません(それらの文字を直接入力できるキーボードの類(G◯◯gle とかがやりそうなエイプリルフールネタ)も今のところ出ていないと思います)。

その代わり。Julia では REPL や各種エディタの拡張機能で \[文字名](+Tab)というような形式でユニコード文字を入力できるようになっています。例えば REPL 上で \alpha と入力してその後 Tab キーを押すと α という文字が入力されます(エディタの拡張機能によっては選択して Enter となります)。
"[文字名]" の部分は主に TeX 由来の表記が利用できますが、Julia 特有のものもあります。

以下に「Julia 標準で用意されているユニコード演算子・定数」の一覧(抜粋)を示します。入力方法に複数記載のあるものは、文字通り「入力方法が複数ある(どちらでも同じ文字を入力できる)」ことを表しています。

演算子・定数等 入力方法 説明 備考
\xor/\veebar 排他的論理和演算子 xor() 関数のエイリアス
\nand/\barwedge 否定論理積演算子 nand() 関数のエイリアス
\nor/\barvee 否定論理和演算子 nor() 関数のエイリアス
÷ \div 整数除算演算子 div() 関数のエイリアス
π \pi 円周率(数学定数)
\euler 自然対数の底(数学定数)
\in 包含演算子(含む) in() 関数のエイリアス、for ◯ ∈ XXX ~ でも利用可能
\approx 近似値演算子 isapprox() 関数のエイリアス
\equiv 同値演算子 === 演算子(組込)のエイリアス
\ge 比較演算子(以上) >= 演算子のエイリアス、\le)も存在
\ne 比較演算子(不等) != 演算子のエイリアス、他の比較演算子に対する「否定 ̸ 」付きの演算子もいくつか存在

6. splice!()

Julia のベクトル(1次元配列)の要素の操作(追加・挿入・削除)は、push!()(末尾への追加)/pop!()(末尾からの取り出し)/pushfirst!()(先頭への追加(挿入))/popfirst!()(先頭からの取り出し)/insert!()(指定位置への挿入)/popat!()(指定位置からの取り出し)/append!()(末尾へのコレクション一括追加)/prepend!()(先頭へのコレクション一括追加(挿入))/deleteat!()(指定位置(範囲指定可能)の要素の(一括)削除) と言うように、用途に合わせて目的の関数を適切に選択して利用することになります。

ところが実は、これらを複雑に組み合わせたようなユーティリティ関数 splice!() も用意されています。これは Perl の splice 関数や JavaScript の Array.prototype.splice() メソッドと同じ設計思想の関数です。その機能を紹介するだけで1つの記事が書けてしまうので、ここではサンプルコードのみ示しておきます。

q = [1, 2, 3, 4, 5, 6];

#= `splice!(q, index)` のように2引数だけ指定した場合は `deleteat!(q, index)` と機能的には同じ、
   ただし戻り値は「削除した値(の列)」 =#
splice!(q, 3:5)  #> [3, 4, 5]
q  #> [1, 2, 6]

# 第3引数を指定した場合は、削除+挿入(=置換)、戻り値は削除した値(の列)
splice!(q, 2, 21:24)  #> 2
q  #> [1, 21, 22, 23, 24, 6]

# 第2引数を `n:n-1` のように指定した場合は、`n` 番目に挿入、戻り値は空のベクトル
splice!(q, 4:3, 0)  #> Int64[]
q  #> [1, 21, 22, 0, 23, 24, 6]

なお splice!() は Julia v0.x の時代からずっと存在する関数です。
Perl や(古くから)JavaScript を使っていた(splice() を多用していた)方、ぜひ Julia の splice!() も試してみてください!
ただしインデックスの開始値が異なる(Perl や JavaScript は 0-origin、Julia は 1-origin)他引数の指定の仕方等が微妙に異なるので、使いやすいかどうかは保証しかねます![6]

7. sum!()/prod!()/maximum!()/minimum!()/Statistics.mean!()

JJN では「map()map!()」/「filter()filter!()」/「fill()fill!()」のように、末尾の ! の有無に違いがあるだけのほぼ同じ名前の関数の組を紹介しています。
! 無しの方は 非破壊的関数、! ありの方は 破壊的関数 となっています。つまり「! 無しの方は引数に渡したコレクションを変更せずに新しいコレクションを返す関数」で、「! ありの方は引数に渡したコレクションを変更(更新)する関数」です。
なおそのような「!有無で2つ組で用意されている関数」の多くは、「! 無しの方は新しいコレクション(配列等)を確保してからそれを ! ありの関数で加工するというパターンが多い」ということも JJN で紹介しています。

そのような関数は他にもあります。代表的な(でも JJN で泣く泣く割愛した)ものが、sum!()/prod!()/maximum!()/minimum!()/Statistics.mean!() 等です。いずれも集計系の関数であることが分かりますよね。なお mean() 以外は標準関数(Base モジュールで export されているのでいつでも使える関数)、Statistics.mean()(平均値関数)のみ Statistics 標準パッケージの関数で事前に using Statistics しておく必要があります(その後は Statistics モジュールプレフィックス無しで mean() だけで呼び出せます)。

使用方法はほぼ同じなので、sum!() のみ例を交えて解説します。

まずは sum() について。Julia の sum() 関数は多次元配列に適用する場合、キーワード引数 dims=... で「どの次元を集計(集約)するか」を指定することができます。
REPL で実行すると結果が分かりやすいですね。

julia> A = [1 2; 3 4;;; 5 6; 7 8]
2×2×2 Array{Int64, 3}:
[:, :, 1] =
 1  2
 3  4

[:, :, 2] =
 5  6
 7  8

julia> sum(A, dims=1)
1×2×2 Array{Int64, 3}:
[:, :, 1] =
 4  6

[:, :, 2] =
 12  14

julia> sum(A, dims=3)
2×2×1 Array{Int64, 3}:
[:, :, 1] =
  6   8
 10  12

julia> sum(A, dims=(1, 2))
1×1×2 Array{Int64, 3}:
[:, :, 1] =
 10

[:, :, 2] =
 26

sum!() は、sum!(R, A) のように指定します。R は集計結果を格納する配列、A は集計対象の方です。
動作的には「R のシェイプに合うように A の要素を集計する」という感じになります。
先の例と同じ結果となる具体例を示しましょう。

julia> A = [1 2; 3 4;;; 5 6; 7 8]
2×2×2 Array{Int64, 3}:
[:, :, 1] =
 1  2
 3  4

[:, :, 2] =
 5  6
 7  8

julia> R1 = similar(A, (1, 2, 2)); sum!(R1, A)
1×2×2 Array{Int64, 3}:
[:, :, 1] =
 4  6

[:, :, 2] =
 12  14

julia> R2 = similar(A, (2, 2, 1)); sum!(R2, A)
2×2×1 Array{Int64, 3}:
[:, :, 1] =
  6   8
 10  12

julia> R3 = similar(A, (1, 1, 2)); sum!(R3, A)
1×1×2 Array{Int64, 3}:
[:, :, 1] =
 10

[:, :, 2] =
 26

つまり。
「どの軸に沿って集計するか(どの次元を集約するか)」がはっきりしていればそれを sum(A, dims=xxx) のように指定すればOKですが、慣れないと「dims にどう指定すればどこを集約して結果の配列のシェイプがどうなるか分からん」ということになりかねません。
一方で「結果としてこういうシェイプの配列になるように集計したい」ということがはっきりしているなら、むしろこの方法で sum!() を利用した方が分かりやすいと思います。
(もちろん「集計結果の方の配列 R を毎回生成するのではなく、使い回すことでメモリの浪費を抑えたい」という本来の『破壊的関数』の目的にも有用です)

なお先ほどもちらっと触れたのと同様、sum() に 多次元配列と dims=xxx キーワード引数を利用する使い方は、内部で実は sum!() を利用しています。
(他の prod()/maximum()/minimum()/Statistics.mean() も同様です)

まとめに変えて

  • Julia 楽しいよ!(まとめになってない)
  • そして良いお年を!

参考リンク

脚注
  1. お手元に拙著『実践Julia入門』がありかつお暇な方は、本文中に『割愛します』という言葉がいくつ出てくるかカウントしてみてくださいw ↩︎

  2. 熱弁して擁護しているように見せかけていますが、実際の所は私も tryelse が必要なシチュエーションに遭ったことはありませんし、ざっくり検索した限りは Julia 標準のコード のどこにも tyr~else~ を利用している箇所は見つけられませんでした…。 ↩︎

  3. この部分、copilot が割とまともな文章を Suggest したのでそのまま採用しましたが、ファイル名の拡張子を分離するには splitext() という関数があるのでそちらを使うのが良いかもしれません。ただし splitext() はファイル名の先頭に . がある場合には対応していないので、そういう場合は chopsuffix() を使うと良いかもしれません(この内容も半分以上 copilot が生成しました、なんだ、いつの間に学習したんだ賢い)。 ↩︎

  4. これは広義のリテラルです。実際には : という二項演算子(range() 関数のエイリアス)に値を適用しているだけです。 ↩︎

  5. 「無限列挙」と言っても、多くの型は最大値が決まっておりそれを超えての列挙はできません。整数型(BigInt を除く)の場合はエラーにならずローテート(typemax(I) の次が typemin(I))になりますし、Char の場合はどこかでエラーになります。Date の場合は…ぜひご自分で試してみてください!途中から訳分からない値が出てきます! ↩︎

  6. splice!() は代表的な「JJN に載せようと思ったけれど割愛した関数」です、その理由は、まぁ、察してくださいw ↩︎

来栖川電算

Discussion