注意点
- ポインタ型の話について詳しくは触れません。構造体+ポインタの話は他の記事を参照ください。
- 値渡し、参照渡しという単語を使わずに内容を記述します。
この記事で伝えたいこと
- 構造体について
- オブジェクトに似ているのは見た目だけ!
- 変数としての動きは参照型ではなく値型!そのため、int や bool 等のいわゆるプリミティブな型と同じ動きをする!
- 構造体をポインタ型に変換しない状態で、引数に渡したり range の for で扱う際の動きに注意!
この記事の対象読者
- Java や Ruby 等の他のオブジェクト指向言語の知識をベースに Go の学習に取り組み始めた方
- 構造体という、Go におけるオブジェクトのようなもの操作にまだ慣れていない方
- Go のコードの中で構造体を扱って、「不思議な動きするな…」と感じている方
Go の構造体の操作でハマった
- 筆者は今まで Java や Ruby でコードを書いておりました。Go に関しては最近勉強を始めたばかりの初学者です。Go の勉強を進める中で、「構造体」という、見た目はオブジェクトにとても似ている型が Go に存在することを知りました。
- 今まで扱っていた言語はオブジェクト指向言語が多く、構造体がオブジェクトの見た目にとても似ているのでオブジェクトの感覚で構造体を操作できると当初は考えていました。
- しかし、いざコードの中で構造体を扱ってみると、「なんか不思議な動きする…」と違和感を感じ始めました。例えば、以下のように構造体型変数のプロパティを書き換えるシンプルな処理でまず違和感を感じました。
- 以下のコードでは
updateName(user)
で引数に渡した user の name フィールドが書き換わることを期待して記述しています。しかし、Go では期待通りに書き換わりません。
structSample.go
package main
import ("fmt")
type User struct {
name string
}
func updateName(u User) {
u.name = "updated"
}
func main() {
user := User{name: "origin"}
fmt.Println(user.name) // originと出力される
updateName(user)
fmt.Println(user.name) // updatedではなく、originと出力される
}
- 一方で、オブジェクト指向言語である Ruby では
updateName(user)
で引数に渡した user の name フィールドを変更できます。Java 等の他のオブジェクト指向言語でも同様です。 - Ruby で同様のコードを書くと以下のようになります。
classSample.ruby
class User
attr_accessor :name
def initialize(name:)
@name = name
end
end
def updateName(u)
u.name = 'updated'
end
user = User.new(name: 'origin')
p user.name # originと出力される
updateName(user)
p user.name # updateNameで書き換わってupdatedと出力される
- Go でも Ruby でも一見同じような操作をしているように見えますが、
updateName(user)
によってもたらされる結果が大きく異なります。これらの違いはどこから来るのでしょうか?
Go の構造体は参照型ではなく値型
- スターティング Go 言語では、Go の構造体について以下のように解説されています。
構造体は値型です。したがって、関数の引数として構造体を渡した場合は、構造体のコピーが生成され、その構造体が関数によって処理されて、元の構造体には何の影響も与えることができません。
松尾愛賀. スターティング Go 言語 (p.220). 翔泳社
- これは他のオブジェクト指向言語と大きく異なる点です。他のオブジェクト指向言語では、オブジェクトは参照型とするものが多いです。Ruby もその一つです。
- 例えば上記の
updateName(user)
の Ruby のコードにおける動きは以下のようになります。Ruby は動的型付け言語であるため変数の型がコード上に現れませんが、実体の型が読み取られ参照型変数の動きをします。
updateName(user)のRubyでの動き
# ②実引数で渡されたuserの実体はオブジェクトであるため、仮引数uは参照型変数としての動きをする。
def updateName(u)
# uは参照型であるため、仮引数uは実引数のuserの実体を直接参照できる。そのため、実引数userの実体のnameフィールドを直接書き換える事ができる。
u.name = 'updated'
end
# ①実引数でuserが渡される。
updateName(user)
- 一方で Go の場合は構造体型の変数は値型であるため、int 型等と同様に以下のような動きになります。
updateName(user)のGoでの動き
// ②実引数として、構造体のuserが渡される。構造体は値型であるため、仮引数uは値型変数としての動きをする。
func updateName(u User) {
// ③値型であるため、仮引数uには実引数userのコピーが入る。
// ④実引数userのコピーであるuのnameフィールドをupdatedに書き換えているだけなので、実引数のuserには影響がない。
u.name = "updated"
}
// ①実引数でuserが渡される。
updateName(user)
- 他のオブジェクト指向言語に慣れている場合、Go の構造体型の動きはかなり違和感がありませんか?
- この違和感の原因は他言語の感覚では値型っぽくない見た目の構造体が、値型の動きをするからであると筆者は考えています。以下のように、明らかに値型の見た目をしている int 型を操作するメソッドの場合では違和感を感じづらいと思います。
明らかに値型のintで同様の処理を書くと違和感を感じづらい
// ②実引数として、int型のintが渡される。int型は値型であるため、仮引数iは値型変数としての動きをする。
func updateInt(i int) {
// ③値型であるため、仮引数iには実引数intのコピーが入る。
// ④実引数intのコピーであるiを10000に書き換えているだけなので、実引数のintには影響がない。
i = 10000
}
func main() {
int := 20
fmt.Println(int) // 20と出力される
// ①実引数でintが渡される。
updateInt(int)
fmt.Println(int) // 20と出力される
}
構造体のスライスに関しても同様に要注意
- Go の学習を進める中で、「構造体のスライスのループで各要素の変更を反映させたい場合は range の for ではなく、 index を使用した古典的 for で記述する」 といった内容を見かけるかも知れません。
- これについても、上記の構造体が参照型ではなく値型であることから説明できます。なお、下記しておりますがスライスに関しては参照型です。
- range の for を使うと、ループの中でスライスの要素を変数で受け取った時点でコピーとして受け取ります。そのため、range の for を使ったループ内では、各要素と同じ実体の変更ができません。
- 一方で、index を使用した古典的 for を使った場合、値型として変数を格納する処理が発生しません。参照型のスライスの n 番目の要素を直接見に行く、という事が可能になるため、ループの中で各要素と同じ実体の変更が可能になります。
- この他、そもそものスライスに格納する要素をポインタ型にする等で対応が可能ですが、本記事では割愛いたします。
Go の構造体で他言語と同じ参照型のような動きをさせたい場合
- 構造体で他のオブジェクト指向言語と同様の動きをするためには、Go の構造体をポインタ型に変換する必要があります。
具体的にはメソッドの仮引数と、呼び出し側の実引数をポインタ型にすることで他の言語と同様の動きとなります。
ポインタ型を使えば他の言語と同様の動きになる
// 仮引数をポインタ型にする
func updateName(u *User) {
u.name = "updated"
}
func main() {
user := User{name: "origin"}
fmt.Println(user.name) // originと出力される
updateName(&user) // ポインタ型で渡す
fmt.Println(user.name) // updatedと出力される
}
- ポインタ型については詳しい書籍や記事が世にたくさんあるため割愛します。適宜ググって参照ください。
- 以上のように、Go の構造体は値型であるため、他言語のオブジェクトと同じ感覚で操作すると不思議な動きをすることがわかりました。
- では、構造体が参照型でないのであれば Go における参照型のデータ構造は何があるのでしょうか?
Go における参照型は 3 つだけ
- スターティング Go 言語では以下のように述べられています。
Go におけるすべての変数は「型」を備えます。変数の型は、大きく分けると「値型」「参照型」「ポインタ型」の 3 種類に分かれます。 「値型」は C や Java における値型と同様に整数や実数といった「値」そのものを格納する変数です。 「参照型」は少し特殊で、Go では「スライス」「マップ」「チャネル」という 3 つのデータ構造のいずれかを指し示す変数の型になります。
松尾愛賀. スターティング Go 言語 (p.56). 翔泳社
Go には、特殊なデータ構造を備えた「参照型」という型が定義されています。標準で「スライス(slice)」、「マップ(map)」、「チャネル(channel)」の 3 つが定義されており、各々の参照型には強力な機能が備わっています。
松尾愛賀. スターティング Go 言語 (p.158). 翔泳社
- よって、基本的に Go の型は値型であり、「スライス」「マップ」「チャネル」の 3 つだけは例外的に参照型であると解釈したほうが、Go においてはしっくり来るかもしれません。(ポインタ型については話が逸れるので言及しません。)
- Go に馴染みがない方向けに補足をすると、「スライス」とは他言語における「配列」に似た構造のことです。つまり、スライスに関しては他の言語の配列と同じ感覚で扱えるということになります。
Go のスライスは他言語の配列に似ている、Go の構造体はオブジェクトに似ている、を額面通りに受け取ってはいけない
- Go を学び始めた際に以下のような説明を受けたことがあるかも知れません。
- Go における「スライス」とは他言語における「配列」のようなもの
- Go における「構造体」とは他言語における「オブジェクト」のようなもの
- これらの説明は概念理解においてはとても効果的だと思います。実際に、データ構造に関してはどちらも酷似しています。
- 一方で、実際にコードで扱う際には誤解を生みやすいです。以下のように説明するのが適していると筆者は考えます。
- Go における「スライス」
- データ構造:他言語の「配列」と酷似
- 型:他言語と同様に参照型であるため、引数で渡す際の感覚等も他言語と同じ
- つまり、Go における「スライス」を他言語における「配列」と同様の感覚で扱っても、感覚にズレは起きづらい
- Go における「構造体」
- データ構造:他言語の「オブジェクト」と酷似
- 型:他言語と違い値型であるため、引数で渡す際の感覚等は他言語と大きく異なる
- つまり、Go における「構造体」を他言語における「オブジェクト」と同様の感覚で扱うと、感覚にズレが起きやすいので要注意
- Go における「スライス」
まとめ
- Go の構造体はオブジェクトに見た目だけとても似ている。
- 変数に格納してコードに書く際は、値型の動きをするので要注意。