はじめに
これはGoに不変参照が存在しない理由を雑に推測してみたものを文章にまとめたものです。
ぜんぜん間違っている可能性があるので、そうだった場合はこっそり教えてください。
Go
Goは値セマンティクスと参照セマンティクスを明確に使い分ける言語です。
それはだいたいの言語がそうなのですが、Goではどうやらこれを不変性と結びつける、すなわち不変セマンティクスと値セマンティクスが結びつき可変セマンティクスと参照セマンティクスが結びついているといった言説を目にすることが少なからずあります。
当然みなさんはC言語を履修していますから、const参照といった概念があるではないかと気がつかれたかと思います。
Goには参照に対して不変性を与えるセマンティクスはありません。
公式のFAQでは値を渡すか参照を渡すかによって使い分けるような設計を奨励しているようです。
なんということでしょう!Goは安全性のための意味論を無意味にゴミ箱に投げ捨てて、値は不変・参照は可変というわけのわからないプロパガンダ(???)を喧伝する悪の組織にみえてきましたね。
ですが少し待ってください。ほんとうにこれはただ思慮に欠けた言語設計なのでしょうか。
それをみるために、はじめにエイリアス参照を含んだGoのコードをみてみましょう。
Goのバージョンはこちらです。
$ go version
go version go1.18 linux/amd64
エイリアス参照を含んだGoのコードはこれです。
package main
import "fmt"
type S struct {
a int64
b int64
}
func piyo(s1 *S, s2 *S) {
fmt.Println("s1: ", *s1) // --> { 0 0 }
(*s2).b += 1
fmt.Println("s1: ", *s1) // --> { 0 1 }
}
func main() {
var s = S { a: 0, b: 0 }
piyo(&s, &s)
fmt.Println(s)
}
s1とs2はポインタエイリアスですから、s2に対する変更操作が当然s1に対しても適用されます。見た目上は変更操作を行っていないにもかかわらず、です。
ではこういった事象は不変な参照をただ与えれば解決するのでしょうか?
それについて、実際に不変な参照を言語仕様として定義している言語の例を眺めながら考えてみます。
C言語
まずC言語のconstについてですが、このガバガバさは説明不要でしょう。
#include <stdio.h>
void foo(const int *a, int *b)
{
printf("%d\n", *a); // --> 0
*b = 42;
printf("%d\n", *a); // --> 42
}
void main(void)
{
int a = 0;
foo(&a, &a);
}
D言語
$ dmd --version
DMD64 D Compiler v2.100.0
Copyright (C) 1999-2022 by The D Language Foundation, All Rights Reserved written by Walter Bright
D言語はC言語のようなconst参照も言語仕様として持っています(実際はより強力な型システムを持っているためより強い制約を付与できます)が、さらに強い不変セマンティクスとして immutable
というキーワードがあります。
D言語の安全性を保証するための制約として、 可変セマンティクスに対して不変な参照を取れない
というルールがあります。
以下のコードをみてみましょう。
import std;
// constではエイリアス参照による暗黙の変更が適用されるが、
// immutableは可変セマンティクスに対して不変な参照をとれないので、
// ここはコンパイルがそもそも通らない。
void foo(ref int i, immutable ref int j) @safe
{
writeln(j);
i++;
writeln(j);
}
void main() @safe
{
int i = 42;
foo(i, i);
}
値 i
は可変セマンティクスですから、これに対して不変参照を取ろうとすると型エラーになります。
safealiasing.d(13): Error: function `safealiasing.foo(ref int i, ref immutable(int) j)` is not callable using argument types `(int, int)`
safealiasing.d(13): cannot pass argument `i` of type `int` to parameter `ref immutable(int) j`
悪くないですね。では明示的にキャストしてみたらどうでしょうか。
import std.stdio;
void hoge(ref immutable int x, ref int y) @safe
{
writeln(x); // --> 1
y++;
writeln(x); // --> 2
}
void main() @safe
{
int x = 0;
hoge(cast(immutable) x, ++x);
writeln(x); // --> 2
}
この結果はあなたが予想したとおりの結果になったでしょうか?
hogeの引数xは不変な値に対する参照にもなっておらず、別のロケーションに存在する可変な値に対する参照になっています。
さらに、挙動もconst参照と同じになっており、不変性に対する推論セマンティクスが崩れてしまっています。
逆のパターンもみてみましょう。immutableな値のmutableなキャストです。
safe Dは不変な値を可変な値にキャストできてしまうの?というまったくうれしくない驚きがありますが、それはいったん脇において仮引数と実引数の対応を注目してみてください。
import std.stdio;
void hoge(return ref immutable int x, return ref int y) @safe
{
writeln(x); // --> 1
y++;
writeln(x); // --> 2
}
void main() @safe
{
immutable int x = 0;
hoge(x, ++(cast(int) x));
writeln(x); // --> 0
}
こちらはどうでしょうか。仮引数と実引数の対応はあなたが期待したとおりになっていますか?
平たくいってしまうと、不変な値と可変な値が同じロケーションを取ることができないという制約を満たすためにこういった挙動になっていると考えられます。
(もっともsafe関数内の定義だったらmutableとimmutableの相互変換をコンパイルエラーにしてほしいところです、これはsafe Dでは仕様違反のようにも読めます)
値の不変性にかんしての意味論にはある種の一貫性があるともいえますが、正直いって直感的ではないかなと思います。
Nim
$ nim --version
Nim Compiler Version 1.6.6 [Linux: amd64]
Compiled at 2022-05-05
Copyright (c) 2006-2021 by Andreas Rumpf
git hash: 0565a70eab02122ce278b98181c7d1170870865c
active boot switches: -d:release
Nimは不変なセマンティクス(修飾がない場合は基本的に不変となります)ではパラメータが値渡しでくるのか参照渡しでくるのかは型の大きさによって変化します。
この特徴がエイリアス参照と組み合わさると、かなり不可解な挙動となってしまいます。
type S1 = object
dummy1: int64
dummy2: int64
i: int
# 不変な値と可変な参照
proc foo(a: S1, b: var S1) =
echo a.i # --> 0
inc(b.i)
echo a.i # --> 0
type S2 = object
dummy1: int64
dummy2: int64
dummy3: int64
i: int
# 不変な参照と可変な参照
proc bar(a: S2, b: var S2) =
echo a.i # --> 0
inc(b.i)
echo a.i # --> 1
proc main() =
var s1 = S1(dummy1: 0, dummy2: 0, i: 0)
foo(s1, s1)
echo s1.i # --> 1
var s2 = S2(dummy1: 0, dummy2: 0, dummy3: 0, i: 0)
bar(s2, s2)
echo s2.i # --> 1
main()
仕様ではとくに参照に対してのmutabilityの言及はないようですが、いわゆるconst参照相当の意味論になっていそうです。
さらに、これの本質的な問題として、このコードは全体が未定義な動作となってしまっていることです。
Nimコンパイラは値渡しか参照渡しかが切り替わる型のサイズを明確に定義しません。
これは切り替える目的が最適化のためであり、アーキテクチャやその世代によって最適な値が変わりうるためです。
つまり、このコードは別のマシンアーキテクチャや同一のマシンアーキテクチャであっても将来世代のプロセッサでは結果が変わる可能性があります。
組み合わせによって起きてしまう変わった例ではありますが、これも不変参照がもたらす厄介な問題のひとつといえるのではないでしょうか。
(これは蛇足ですが、Nimは1.0のリリースを急ぎすぎてしまったように感じます。並行・並列まわりの仕組みも完全に一から再設計するとのことなので2.0でどれほど進化するかは楽しみにしています。)
もちろんNimコミュニティはこの問題に気がついており、Aliasing restrictions in parameter passingのような提案(具体的な解決案はまだ提案されていませんが)があったりAraq氏もrestrict the mutability to a single ownerというような発言をしていたりこの方向での解決が進みそうです。
ところで、似たようなセンテンスをどこかで目にしたことがあるのではないでしょうか?そう、Rustです。
Rust
$ rustc --version
rustc 1.60.0 (7737e0b5c 2022-04-04)
Rustのshared XOR mutabilityの原理原則はsafe Rustの範囲においてこの問題を完全に防いでくれます。
可変な参照は他の参照(可変・不変にかかわらず)と同じロケーションを共有することはできません。
そのため以下のコードはコンパイルが通りません。
fn foo(a: &i32, b: &mut i32) {
println!("{}", a);
*b += 1;
println!("{}", a);
}
fn main() {
let mut a = 0;
foo(&a, &mut a);
}
$ rustc aliasing.rs
error[E0502]: cannot borrow `a` as mutable because it is also borrowed as immutable
--> aliasing.rs:9:13
|
9 | foo(&a, &mut a);
| --- -- ^^^^^^ mutable borrow occurs here
| | |
| | immutable borrow occurs here
| immutable borrow later used by call
error: aborting due to previous error
For more information about this error, try `rustc --explain E0502`.
ついでに、この原理原則によって複数の可変参照がある場合それはエイリアスになりえないという強い性質も手に入れることができます。
// 0000000000000000 <_ZN7noalias3foo17h95dfcc64a7afa149E>:
// 0: c7 07 17 00 00 00 mov DWORD PTR [rdi],0x17
// 6: c7 06 13 00 00 00 mov DWORD PTR [rsi],0x13
// c: b8 17 00 00 00 mov eax,0x17 <--- 即値の23
// 11: c3 ret
pub fn foo(x: &mut i32, y: &mut i32) -> i32 {
*x = 23;
*y = 19; // <-- yはxのエイリアスになりえない!
*x
}
まとめ
Goはconst参照のような不変セマンティクスはありません。
しかし不変な参照セマンティクスはエイリアシングにおいて非直感的な意味論をもたらす弊害が起こりえます。
Goチームは安全性に対する洞察が欠如していたのではなく、むしろ思慮深い意思決定においてこの言語機能を取り入れなかったのかもしれません。
そして、この問題はRustがshared XOR mutabilityという概念をコアにした言語設計を行ったことでエレガントに解決しました。
Nimのように今後この方向性をフォローしていく言語がどんどん現れてくるかもしれません。