👨🏻‍🦱

細かすぎて伝わらないかもしれないJuliaのTips

2022/12/25に公開

メリークリスマス!

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

はじめに

この記事は、 2022/12/18 にオンラインで実施された勉強会 JuliaTokai #13 年末LT大会 での私の発表内容↓を元に再構成したものとなっております。

細かすぎて伝わらないかもしれないJuliaのTips-HackMD.png
https://hackmd.io/@antimon2/H1WoZ-jOi

その時に出た質問とその回答や、拡がった話題・補足情報なども追記し、丁寧に説明しなおした内容となっております。
(逆に自己紹介や「Julia」そのものの簡単な紹介パートなどは省略しております)。

それではお楽しみください!

シチュエーション1: findXXX() 系関数?

例えば「配列(1次元配列)で同じ要素が3つ続く箇所を見つけてそのインデックスの範囲を返す」という要件があったとします。どう実装すれば良いでしょう?
なお Julia には標準で findXXX() 系の関数が用意されています。こういうとき使えるのでしょうか?

解決法1:for 文で回せばOK

こういうときはあまり深く考えずにロジックを実現する for ループを書けば十分です。

コード例:

# 同じ要素がN個続く箇所を見つけてそのインデックスの範囲を返す
function findNrepeats(a::AbstractVector, N)
    _lastindex = lastindex(a)
    for i in eachindex(a)  # keys(a) でもOK
        r = i:i+N-1
        last(r) > _lastindex && break
        allequal(a[r]) && return r
    end
    nothing
end

実行例:

入力
a = [0, 7, 7, 5, 0, 0, 0, 1, 1, 2];  # a[5:7] == [0, 0, 0]
@show findNrepeats(a, 3)
@show findNrepeats([3, 1, 4, 1, 5, 9, 2, 6, 5, 3], 2)  # === nothing
出力
findNrepeats(a, 3) = 5:7
findNrepeats([3, 1, 4, 1, 5, 9, 2, 6, 5, 3], 2) = nothing

Point1

  • Julia の findXXX() 系の関数は「条件に合致するもののインデックス(の範囲)を返す関数(存在しなければ nothing)」という共通仕様がある(例外:findmax()/findmin())ので、その基本仕様に大体沿っている findNrepeats() という関数を書いてみた。
  • Julia の for 文でインデックスでループを回す場合は for i in eachindex(a) ~ または for i in keys(a) ~ と書くようにしましょう。
  • 「コレクションの要素が全て同じかどうか」を判定するズバリ allequal() て関数が標準であります。便利♪知ってました?
    • なお「コレクションの要素が全て異なるかどうか」を判定する allunique() てのもあります。

解決法2:無理矢理 findXXX() を利用する形に落とし込む

解決法1との比較(ベンチマーク)も添えて。

コード例:

# 同じ要素がN個続く箇所を見つけてそのインデックスの範囲を返す ver.2
function findNrepeats_v2(a::AbstractVector, N)
    rs = [i:i+N-1 for i in eachindex(a) if i+N-1  lastindex(a)]
    index = findfirst(rs) do r
        allequal(a[r])
    end
    isnothing(index) ? nothing : rs[index]
end

実行例:(解決法1と結果が同じであることを確認してください)

入力
a = [0, 7, 7, 5, 0, 0, 0, 1, 1, 2];  # a[5:7] == [0, 0, 0]
@show findNrepeats_v2(a, 3)
@show findNrepeats_v2([3, 1, 4, 1, 5, 9, 2, 6, 5, 3], 2)  # === nothing
出力
findNrepeats_v2(a, 3) = 5:7
findNrepeats_v2([3, 1, 4, 1, 5, 9, 2, 6, 5, 3], 2) = nothing

ベンチマーク:(外部パッケージ BenchmarkTools を利用、以降同様)

入力
using BenchmarkTools, Random

N=3;

Random.seed!(1234);
@benchmark findNrepeats(a, $N) setup=(a=rand(0:9, 100))

Random.seed!(1234);
@benchmark findNrepeats_v2(a, $N) setup=(a=rand(0:9, 100))

ベンチマーク結果(findNrepeats)
ベンチマーク出力例

Point2

  • findXXX() 系の関数は「条件に合致する インデックス を返す関数(2回目)」という性質上、それを使って別のものを検索しようとすると回りくどくなって使いにくい。
  • しかも思ったほどのパフォーマンスがでないこともある、今回の場合 for ループの方が少し速いしメモリ効率も良い。
  • てか Julia の for ループ速い! 速度を気にする場合でも下手にベクタ化とか既存の関数をとか考えなくても愚直に書いてもそこそこ速い!

結論

今回の場合は普通に for ループで実現しても十分に高パフォーマンスな実装が得られます。

参考:文字列なら 正規表現+findfirst() でOK!

同様なシチュエーションでも文字列検索なら findXXX() が有用な場合も多いです。

コード例:

# 前準備:「同じ文字のN回繰り返し」という正規表現を生成しキャッシュする仕組み
@generated function getNrepeatsRegex(::Val{N}) where {N}
    Regex("(.)" * "\\1" ^ (N-1))  # N=3 なら `r"(.)\1\1"` となる
end

# 同じ文字がN個続く箇所を見つけてそのインデックスの範囲を返す
function findNrepeats(s::AbstractString, N)
    rex = getNrepeatsRegex(Val(N))
    findfirst(rex, s)
end

実行例:

入力
s = "ABC123あああ😁漢字";  # s[7:13] == "あああ"

@show findNrepeats(s, 3)
出力
findNrepeats(s, 3) = 7:13

参考のPoint

  • findXXX は文字列検索の場合第1引数は関数だけではなくパターン(正規表現、文字の範囲等)もOK!
  • ただし毎回正規表現を生成するのは意外とコスト高い(=遅い)ので何らかの方法でキャッシュする必要あり
    • インラインで(rex = Regex("(.)" * "\\1" ^ (N-1)))のように書いてしまうと意外と遅い(後述)
    • 通常は正規表現リテラル(例:r"(.)\1\1")を使うのが吉(生成済の正規表現オブジェクトが埋め込まれます)。
  • @generated(生成関数)はこういうときに(も)利用できます(てかこのサンプルを作ることで生成関数の使い途を1つ見つけてしまった)

参考の参考:ちょっとだけ遅い別実装とベンチマーク比較

コード例:

# 同じ要素がN個続く箇所を見つけてそのインデックスの範囲を返す ver.2
function findNrepeats_v2(s::AbstractString, N)
    rex = Regex("(.)" * "\\1" ^ (N-1))  # N=3 なら `r"(.)\1\1"`
    findfirst(rex, s)
end

実行例:

入力
s = "ABC123あああ😁漢字";  # s[7:13] == "あああ"

@show findNrepeats_v2(s, 3)
出力
findNrepeats_v2(s, 3) = 7:13

ベンチマーク:

入力
using BenchmarkTools, Random

N=3;

Random.seed!(1234);
@benchmark findNrepeats(s, $N) setup=(
    s=randstring("123ABCあいう😁漢字", 100))

Random.seed!(1234);
@benchmark findNrepeats_v2(s, $N) setup=(
    s=randstring("123ABCあいう😁漢字", 100))

ベンチマーク結果2(findNrepeats w String)
ベンチマーク出力例

シチュエーション2: Y/A findXXX() 系関数

findXXX() 系関数の理念や使い途は分かった。分かったけれどやっぱ使いにくい! 正直私もそう思います。
どのあたりが使いにくいのかというと、主に以下の2点(個人的な感想です)

  1. インデックスやキーで参照できるコレクションしか扱えない
  2. 戻り値がインデックスやキーなのが分かりにくい

つまり「a[i] みたいに要素に参照できるコレクション」にしか使えない(だから戻り値もインデックス)なわけなのですが、下手に他言語の素養があると「検索系の関数は『見つかった要素』を返してほしい」と思いませんか? それと「インデックス参照じゃなくて、一般のイテレータで『条件に合致した要素そのもの』を返すようなもの」を期待しませんか?
そういった「一般のイテレータで使える」「条件に合致する 最初の 要素を取得(なければ nothing)」っていう関数は Julia にはないのでしょうか?

解決法1:Iterators.dropwhile()Base.first() を組み合わせればOK

イテレータを操作するなら Iterators 標準モジュールに便利な関数がいくつか用意されています。今回の目的は「条件を満たさないうちは読み飛ばして、その後最初の要素を取り出す」と読み替えれば Iterators.dropwhile()first() が利用できることが分かります。

コード例:

# 第1引数で条件判定して、第2引数のコレクションで最初に合致する要素を返す
# (なければ `nothing`)
function meetfirst_v1(pred::Function, itr)
    # NG: `return first(Iterators.dropwhile(!pred, itr))`
    itr2 = Iterators.dropwhile(!pred, itr)
    try
        first(itr2)
    catch e
        if isa(e, BoundsError) || isa(e, ArgumentError)
            return nothing
        end
        rethrow(e)
    end
end

実行例:

入力
a = [314, 159, 265, 358, 979, 323, 846, 264];

@show meetfirst_v1(n -> n % 11 == 0, a)  # 979 % 11 == 0, 264 % 11 == 0
@show meetfirst_v1(n -> n % 7 == 0, a)  # === nothing
出力
meetfirst_v1((n->begin
            #= REPL[15]:1 =#
            n % 11 == 0
        end), a) = 979
meetfirst_v1((n->begin
            #= REPL[16]:1 =#
            n % 7 == 0
        end), a) = nothing

入力
# コラッツ数列を列挙するイテレータ(`Channel`)を返す関数
function collatz(n::Int)
    Channel{Int}() do chnl
        put!(chnl, n)
        while n > 1
            n = iseven(n) ? n ÷ 2 : 3n + 1
            put!(chnl, n)
        end
    end
end

@show meetfirst_v1((200), collatz(27))

@show meetfirst_v1((50), collatz(3))
出力
meetfirst_v1(()(200), collatz(27)) = 214
meetfirst_v1(()(50), collatz(3)) = nothing

解決法2:より高パフォーマンスな実装

要件を満たすなら解決法1でも良いですが、少し工夫するともっと高パフォーマンス(速度・メモリ使用量いずれも)な実装にもできます。

コード例:

# 第1引数で条件判定して、第2引数のコレクションで最初に合致する要素を返す
# (なければ `nothing`) ver.2
function meetfirst(pred::Function, itr)
    itr2 = Iterators.dropwhile(!pred, itr)
    next = iterate(itr2)
    isnothing(next) ? nothing : first(next)  # `first(next)` はタプルの第1要素を取得している
end

実行例:

入力
a = [314, 159, 265, 358, 979, 323, 846, 264];

@show meetfirst(n -> n % 11 == 0, a)  # 979 % 11 == 0, 264 % 11 == 0
@show meetfirst(n -> n % 7 == 0, a)  # === nothing
出力
meetfirst((n->begin
            #= REPL[22]:1 =#
            n % 11 == 0
        end), a) = 979
meetfirst((n->begin
            #= REPL[23]:1 =#
            n % 7 == 0
        end), a) = nothing
入力
# コラッツ数列を列挙するイテレータ(`Channel`)を返す関数
function collatz end;  # 長いので略

@show meetfirst((200), collatz(27))

@show meetfirst((50), collatz(3))
出力
meetfirst(()(200), collatz(27)) = 214
meetfirst(()(50), collatz(3)) = nothing

ベンチマーク:

入力
using BenchmarkTools, Random

Random.seed!(1234);
@benchmark meetfirst_v1((200), c) setup=(c=collatz(rand(3:100)))

Random.seed!(1234);
@benchmark meetfirst((200), c) setup=(c=collatz(rand(3:100)))

ベンチマーク結果(meetfirst)
ベンチマーク出力例

Point

  • 基本的な発想は first(Iterators.dropwhile(!pred, itr))
    • !pred というのは、pred() 関数の結果(Bool値 という前提)を否定、つまり x -> !(pred(x)) 相当です。
    • first(《空のイテレータ》) は例外が発生してしまうので適切に対処(例外処理)が必要となります。
  • イテレータに対して iterate(itr) を使うと例外処理を回避できます!
    • 最初の要素があるときの戻り値: (《最初の要素》, 《状態オブジェクト》)
    • ないときの戻り値: nothing
    • つまり iterate(itr2) の戻り値が nothing なら nothing を返し、そうでなければ2値タプルが返ってきているのでその第1要素を(first() 関数で)取得すればOK、という仕組み!
  • ベンチマーク結果を見ると分かるとおり、Julia の例外処理はメモリも消費するし決して速くない(意外と重い)! 回避できるなら回避した方が良いです!

結論

Iterators.dropwhile()iterate()(と first())の組み合わせで実現OK!

補足

「条件に合致する最初の要素」ではなく「条件に合致する 全ての 要素」という要件なら、普通に Iterators.filter() でOKです。

シチュエーション3: 文字列の折り返し

Julia はフリーインデントなので、例えば長い配列をソースコードに直書きするときは適宜改行して書けます。良いですね。
でも長い文字列は、普通に書くと横にずらーっと長くなってしまいます。Julia は("~" によるリテラル表記でも)文字列中に改行を入れる事ができますが、その場合そこに「改行文字」が挿入されてしまいます。
「見た目がアレなのでただ折り返したいだけ(そこに改行を挿入する意図はない)」という場合は、どうすれば良いでしょう?

解決:行末に \ を入れることで折り返しできる(ようになった)よ!(≥v1.7)

Julia v1.7 以降なら仕様が拡張され、文字列リテラル中、行末に \ を書くことでその後の改行が無視されるようになりました!

コード例:

入力
jgm0 = "寿限無寿限無五劫の擦り切れ海砂利水魚の水行末雲来末風来末食う寝る処に住む処やぶらこうじのぶらこうじパイポ・パイポ・パイポのシューリンガン・シューリンガンのグーリンダイ・グーリンダイのポンポコピーのポンポコナの長久命の長助";

jgm1 = "寿限無寿限無五劫の擦り切れ\
海砂利水魚の水行末雲来末風来末\
食う寝る処に住む処やぶらこうじのぶらこうじ\
パイポ・パイポ・パイポのシューリンガン・\
シューリンガンのグーリンダイ・グーリンダイのポンポコピーのポンポコナの\
長久命の長助";

jgm2 = """
    寿限無寿限無五劫の擦り切れ海砂利水魚の水行末雲来末風来末\
    食う寝る処に住む処やぶらこうじのぶらこうじ\
    パイポ・パイポ・パイポのシューリンガン・シューリンガンのグーリンダイ・\
    グーリンダイのポンポコピーのポンポコナの\
    長久命の長助""";
# ↑ `"""~"""` ならインデントも無視されるのでさらに見やすく!

@show jgm0 == jgm1 == jgm2  # もちろん文字列として同一視できる

@show jgm0 === jgm1 === jgm2  # リテラルとしても同一になる(今回の場合)!
出力
jgm0 == jgm1 == jgm2 = true
jgm0 === jgm1 === jgm2 = true

Point かつ 結論

  • 文字列の折り返し便利になりました!
  • (折り返し部分を除いた)文字列の構成が同一なら、どこで折り返してもオブジェクトレベルで同一(つまり全く同じリテラル)になります!

補足:文字列中にバックスラッシュ \ を入れたい場合は?

※このサブセクションの内容は、勉強会の発表時に実際にあった質問とその回答に基づいています。

文字列中に \ を入れたい場合は、\\ と書く必要があります。
そもそも \ は所謂 エスケープ文字 で、エスケープシーケンスの1文字目を意味します。エスケープシーケンス は文字列(など)に特殊な文字を埋め込むための手段でよく利用されるのは \n(改行)、\t(タブ文字)、\"(引用符)等でしょう。

コード例:

s = "123\\ABC"  # '1','2','3','\','A','B','C' の7文字からなる文字列
s = "123\\
ABC"  # '1','2','3','\',(改行文字),'A','B','C' の8文字からなる文字列
s = "123\\\
ABC"  # '1','2','3','\','A','B','C' の7文字からなる文字列(Julia v1.7以降)

参考:v1.6.x も対象のコードなら…

Julia は後方互換性を大事に開発・リリースされており、メジャーバージョンが同じうち(v1.x の x は上がるけれど v2.0 に上がらない間)は破壊的な仕様変更は原則ありません。むしろ新バージョンの方が機能追加もパフォーマンス改善もどんどんされるので、基本的には最新安定版を使えば良いです(2022/12/25 現在の最新安定版は v1.8.4)。
一方で Julia の v1.6.x は LTS(Long Term Supprt)であり「パッケージ開発者はできればこのバージョンでも動作確認してね」という動作保証最低バージョンです(2022/12/25 現在のLTS版は v1.6.7)。
つまり。
もしあなたがパッケージ開発をしていて、LTS(v1.6.x)も動作保証対象ならば、そのコードの中にこの(v1.7 で拡張された)文法は使えません(実は v1.6.x では文法エラーになります)。
v1.6 でも同じようなことを実現したい場合は、文字列をぶつ切りにして文字列連結演算子 * でつなぐしかありません。
ただ。Julia の徹底的な最適化の結果、ある程度の長さの文字列ならば "~" * "~" で連結した文字列も同一オブジェクトとなる(XXX === YYYtrue になる)場合も多いです。

コード例:

jgm0 = "寿限無寿限無五劫の擦り切れ海砂利水魚の水行末雲来末風来末食う寝る処に住む処やぶらこうじのぶらこうじパイポ・パイポ・パイポのシューリンガン・シューリンガンのグーリンダイ・グーリンダイのポンポコピーのポンポコナの長久命の長助";

jgm_oldstyle =
    "寿限無寿限無五劫の擦り切れ" * 
    "海砂利水魚の水行末雲来末風来末" *
    "食う寝る処に住む処やぶらこうじのぶらこうじ" *
    "パイポ・パイポ・パイポのシューリンガン・" *
    "シューリンガンのグーリンダイ・グーリンダイのポンポコピーのポンポコナの" *
    "長久命の長助";

@show jgm0 == jgm_oldstyle  # => true(出力例略)
@show jgm0 === jgm_oldstyle  # => ???(確認してみてください!)

シチュエーション4: OrderedDict とか SortedSet とか

Julia 標準のコレクション型には、Dict(辞書)や Set(集合)も用意されていますが、これらは順不同です、つまり、要素の追加順が保持されるわけでもなければ、自然に(キーの)昇順になるわけでもありません(つまり所謂ハッシュマップ/ハッシュセットのような仕組みです)。
他言語だと標準モジュールで OrderdDict(追加順を保持する辞書)とか SortedSet((指定した)ソート順で要素を保持する集合)が用意されているものもありますが、Julia 標準にはこれらはありません。

解決:外部パッケージ DataStructures があるよ

Julia の REPL のパッケージモードで add DataStructures しましょう。
その後コードで using DataStructures すれば、OrderdDictSortedSet も使えます。

入力(REPL のパッケージモード)
add DataStructures
入力(コード)
using DataStructures

# 標準の Dict: 順不同
dic = Dict(["Carol", "Alice", "Ellen", "Bob", "Dave"].=>1:5);
@show dic

# OrderedDict: 追加順
odic = OrderedDict(["Carol", "Alice", "Ellen", "Bob", "Dave"].=>1:5);
@show odic

# 標準の Set: 順不同
set = Set(["Carol", "Alice", "Ellen", "Bob", "Dave"]);
@show set

# SortedSet: 値の昇順
sset = SortedSet(["Carol", "Alice", "Ellen", "Bob", "Dave"]);
@show sset
出力
dic = Dict("Carol" => 1, "Alice" => 2, "Dave" => 5, "Ellen" => 3, "Bob" => 4)
odic = OrderedDict("Carol" => 1, "Alice" => 2, "Ellen" => 3, "Bob" => 4, "Dave" => 5)
set = Set(["Carol", "Alice", "Dave", "Ellen", "Bob"])
sset = SortedSet(["Alice", "Bob", "Carol", "Dave", "Ellen"], Base.Order.ForwardOrdering())

Point

  • DataStructures パッケージ便利♪
  • OrderedDict/SortedSet の他に SortedDict/OrderedSet などももちろんあります
  • 他にも有名どころのデータ構造多数用意(↓参照)

参考1:DataStructures パッケージに含まれるその他のデータ構造

  • Stack, Queue, Deque, LinkedList などの基本的なデータ構造
  • RBTree, AVLTree, SplayTree 等の木構造
  • RobinDict, SwissDict などの内部アルゴリズム(ハッシュアルゴリズムなど)に特定のものを採用した辞書
  • その他色々(詳細割愛)

参考2:別に DataStructures を使わなくても…

もし「列挙時にだけ追加順・ソート順が必要」なら、キーを Vector(Julia の1次元配列)で別管理するだけでもOKです。
もちろんその分余計にメモリを消費するので、それが気にならない程度に長さが短い場合など、使い方次第ですね。

コード例:

入力
dkeys = ["Carol", "Alice", "Ellen", "Bob", "Dave"];

dic = Dict(dkeys.=>1:5);

# 列挙(順不同)
@show [key=>value for (key, value) in dic]

# 列挙(追加順)
@show [key=>dic[key] for key in dkeys]

# 列挙(キーの昇順)
@show [key=>dic[key] for key in sort(dkeys)]
出力
[key => value for (key, value) = dic] = ["Carol" => 1, "Alice" => 2, "Dave" => 5, "Ellen" => 3, "Bob" => 4]
[key => dic[key] for key = dkeys] = ["Carol" => 1, "Alice" => 2, "Ellen" => 3, "Bob" => 4, "Dave" => 5]
[key => dic[key] for key = sort(dkeys)] = ["Alice" => 2, "Bob" => 4, "Carol" => 1, "Dave" => 5, "Ellen" => 3]

シチュエーション5: 無名関数の多重定義

Julia の最大の特長(と言っても過言ではないもの)は、多重ディスパッチという機構を備えている、ということ。
これは同じ名前の関数をシグニチャ(≒引数の組み合わせ)の違いで多重定義でき(て実行時に引数に合わせて適切な実装実体(=メソッド)が選択され実行され)る、というもの。

ところで、Julia は無名関数を定義する書式もあり、例えば fn = x -> x * x と書くと fn(2) == 4 となります。でも fn(2, 3) を呼び出そうとすると「そんなメソッドはない」というエラーで怒られます(多重定義していないからですね)。
なお Julia はインラインで関数を定義する別の方法が用意されており、fn2(x) = x * x と書くと fn2(x, y) = x * y という多重定義ができてどちらも有効になります(fn2(2) == 4fn2(2, 3) == 6)。
その感覚で fn = x -> x * x のあとに fn = (x, y) -> x * y と書くと、多重定義ではなく「変数 fn に新しい無名関数 (x, y) -> x * y を再代入しただけ」になってしまいます(つまり2引数の関数で上書きされ1引数の関数がどこかへ消えてしまいます)。

無名関数は多重定義できないのでしょうか?

解決:できます!

以下のようにすればできます。

入力
fn = x -> x * x

(::typeof(fn))(x, y) = x * y

methods(fn)

@show fn(2)
@show fn(2, 3)
出力
# 2 methods for anonymous function "#28":
[1] (::var"#28#29")(x) in Main at XXX[XX]:1
[2] (::var"#28#29")(x, y) in Main at XXX[YY]:1

fn(2) = 4

fn(2, 3) = 6

Point

できますが ほぼ使い途はありません!(重要)
なのでこの件はこれ以上深入りしません!
多重定義(多重ディスパッチ)が必要なら通常の(無名じゃない)関数を定義して使いましょう!

シチュエーション6: 文字列のインデックス

Julia のインデックスは 1-origin すなわち(標準の)配列や文字列のインデックスは 1 から始まります。
それはそれでそれなりの理由もあるので、Julia を使う上では慣れましょう(あと「シチュエーション1」でも触れたようにインデックスを直接数値の範囲で考えるのではなく eachindex() 関数等を使うことで意識せずに間接的に扱う癖を付けるのが Better です)。

それは良いのですが、インデックス関連でもう1つ気にしなければならないのが、Julia の文字列のインデックスは連続していない(ことがある)、と言う点です。
具体的には、非ASCII文字(UTF-8エンコードで2バイト以上になる文字)を含む文字列は、その文字開始ユニット位置がその文字のインデックスになります。例えば s = "123ABCあいう😁漢字 という文字列に対して '😁' という文字のインデックスは 16 です(10文字目ですが 10 ではありません)。
これを「文字列 s10 文字目」という指定で、文字「'😁'」を取得するにはどうしたらよいでしょう?

解決:nextind() という関数が用意されています!

まずはヘルプを見てみましょう。

ヘルプ
help?> nextind
search: nextind IndexCartesian MissingException current_exceptions InterruptException InvalidStateException

  nextind(str::AbstractString, i::Integer, n::Integer=1) -> Int

    •  Case n == 1
       If i is in bounds in s return the index of the start of the character whose encoding starts after index i.
       In other words, if i is the start of a character, return the start of the next character; if i is not the
       start of a character, move forward until the start of a character and return that index. If i is equal to
       0 return 1. If i is in bounds but greater or equal to lastindex(str) return ncodeunits(str)+1. Otherwise
       throw BoundsError.

    •  Case n > 1
       Behaves like applying n times nextind for n==1. The only difference is that if n is so large that applying
       nextind would reach ncodeunits(str)+1 then each remaining iteration increases the returned value by 1.
       This means that in this case nextind can return a value greater than ncodeunits(str)+1.

    •  Case n == 0
       Return i only if i is a valid index in s or is equal to 0. Otherwise StringIndexError or BoundsError is
       thrown.

  Examples
  ≡≡≡≡≡≡≡≡≡≡

  julia> nextind("α", 0)
  1

  julia> nextind("α", 1)
  3

  julia> nextind("α", 3)
  ERROR: BoundsError: attempt to access 2-codeunit String at index [3]
  [...]

  julia> nextind("α", 0, 2)
  3

  julia> nextind("α", 1, 2)
  4

• Case n > 1 辺りを見てください。「n == 1 の場合を n 回繰り返す」と読めますね?
つまり↓のようにすればOKです。

入力
s = "123ABCあいう😁漢字";
@show nextind(s, 0, 10)  # `0` より後で `10` 番目の文字のインデックス
@show s[nextind(s, 0, 10)]  # `s` の10文字目
出力
nextind(s, 0, 10) = 16
s[nextind(s, 0, 10)] = '😁'

参考1:他の xxxxind() 系の関数

他にも文字列のインデックス関連を扱う xxxxind() 系の関数がいくつかあります。具体的には thisind()prevind() の2つです。使い方の例を簡単に紹介だけしておきます。

入力
s = "123ABCあいう😁漢字";

# ↓ 9ユニット目(9バイト目)に存在する文字の正しいインデックスを返す
@show thisind(s, 9)  # => 7
@show s[thisind(s, 9)]  # => 'あ'

# ↓後ろから3文字目
@show s[prevind(s, end+1, 3)]  # => '😁'
出力
thisind(s, 9) = 7
s[thisind(s, 9)] = 'あ'
s[prevind(s, end + 1, 3)] = '😁'

参考2:eachindex() 関数

(シチュエーション1で紹介しこの節でもさっき少し触れた)eachindex() という関数を利用すれば、全ての文字のインデックスを列挙できます。
(ただし戻り値はイテレータになるので実際のインデックスを全て取得するには collect() に渡す必要あり)。

入力
s = "123ABCあいう😁漢字";
@show eachindex(s)
@show collect(eachindex(s))
出力
eachindex(s) = Base.EachStringIndex{String}("123ABCあいう😁漢字")
collect(eachindex(s)) = [1, 2, 3, 4, 5, 6, 7, 10, 13, 16, 20, 23]

参考3:split() の場合

他言語での素養がある方なら「split() でセパレータ(デリミタ)に ""(空文字列)を指定すれば1文字ずつに分解できるから、それで『10番目』って取得する方法でもいけどうじゃない?」って思うかもしれません。
確かにそれでも目的は達成できますが少しだけ注意が必要です。

まずはコード例を見てください:

入力
s = "123ABCあいう😁漢字";
@show split(s, "")
@show split(s, "")[10]
出力
split(s, "") = SubString{String}["1", "2", "3", "A", "B", "C", "あ", "い", "う", "😁", "漢", "字"]
(split(s, ""))[10] = "😁"

何が起きているか説明すると以下:

  • split(s, "") は「文字列の配列(より正確には部分文字列の配列)」が生成される(つまり無駄にメモリを消費する)
  • split(s, "")[10] の結果は文字(Char型)ではなく(1文字の)文字列(SubString{String}型)となる(ので Char が欲しいシチュエーションならもう一手間必要になる)

つまり使い方次第になります。

補足1:collect(s) の場合

※これ以降は勉強会時にはなかった内容、追記内容となります。

split(s, "") ではなく collect(s) にすると、文字列の配列ではなく「文字(Char)の配列」が取得できます。メモリを消費するという点では同じですが collect(s)[10] は文字(Char型)が得られます。

入力
s = "123ABCあいう😁漢字";
@show collect(s)
@show collect(s)[10]
出力
collect(s) = ['1', '2', '3', 'A', 'B', 'C', 'あ', 'い', 'う', '😁', '漢', '字']
(collect(s))[10] = '😁'

補足2:見た目1文字だけど1文字じゃない文字の場合

例えば "👨🏻‍🦱" という絵文字があります。これは見た目1文字ですが、実は「ベースとなる絵文字」「肌の色情報(特殊文字)」「文字列を重ね合わせる役割をする特殊文字(ゼロ幅ジョイナー、これも特殊文字)」「髪型を表す絵文字」の4つの文字が組み合わさった文字(というか文字列)になっています。@show collect("👨🏻‍🦱") の結果は collect("👨🏻\u200d🦱") = ['👨', '🏻', '\u200d', '🦱'] となります。

例えば "禰󠄀豆子" という文字列も collect("禰󠄀豆子") = ['禰', '󠄀', '豆', '子'] となります。'禰' のあとに空の文字のようなものが表示されていますが、これは文字コード(Unicodeコードポイント)で U+E0100 の文字(?)で「異体字セレクタバリエーションセレクタ)」と呼ばれるものです。今回の場合、適切なフォントと表示アプリを利用すれば "禰"(異体字セレクタなし)と "禰󠄀"(異体字セレクタあり)の見た目に少し変化がある(「しめすへん」が『示』になるか『ネ』になるかの違い)のが分かると思います。

他にも @show collect("x̄")'x' の上に '‾' が組み合わさった 合字)も collect("x̄") = ['x', '̄'] (2文字目は U+0305(合成可能なオーバーライン文字))となります。

これら「Char(Julia の文字型)としては2つ以上の組み合わせになるが見た目1文字になる文字」というのが Unicode の世界にはけっこうあります。その塊のことを 書記素(または 字素、英語で grapheme)と言います。こういったものに対して collect() したり eachindex() したりすると、その各文字ごとに(特殊文字なども個別に)扱ってしまい書記素をバラバラにしてしまうため、むしろ扱いにくい場合があります。
そんなときは、Julia 標準パッケージの1つ Unicode パッケージの関数を使うと書記素を適切に扱ってくれます。

入力
using Unicode

s1 = "👨🏻‍🦱/禰󠄀豆子/x̄"

@show collect(s1)

@show graphemes(s1)
@show collect(graphemes(s1))
@show collect(graphemes(s1))[1]
出力
collect(s1) = ['👨', '🏻', '\u200d', '🦱', '/', '禰', '󠄀', '豆', '子', '/', 'x', '̄']
graphemes(s1) = length-7 GraphemeIterator{String} for "👨🏻‍🦱/禰󠄀豆子/x̄"
collect(graphemes(s1)) = SubString{String}["👨🏻‍🦱", "/", "禰󠄀", "豆", "子", "/", "x̄"]
(collect(graphemes(s1)))[1] = "👨🏻‍🦱"

まとめ

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

リンク

実験Note

  • Binder

参考リンク

Discussion