インターフェース(Interfaces) - Golang learning step 2-2
- 公開日
- カテゴリ:GoingDeeper
- タグ:Golang,roadmap.sh,学習メモ
roadmap.sh > Go > Going Deeper > Interfaces の学習を進めていきます。
※ 学習メモとしての記録ですが、後にこのセクションを学ぶ道しるべとなるよう、ですます調で記載しています。
contents
- 開発環境
- インターフェース(Interfaces)
- インターフェースの基本概念
- インターフェースの定義例
- インターフェースの実装
- インターフェースの使用
- インターフェースとポリモーフィズム
- 空インターフェース
- インターフェースのベストプラクティス
- インターフェースの実践的な使用例
開発環境
- チップ: Apple M2 Pro
- OS: macOS Sonoma
- go version: go1.23.2 darwin/arm64
インターフェース(Interfaces)
- [official] Go Interfaces
- [official] Effective Go: Interfaces
- [article] Go by Example: Interfaces
- [video] Golang Tutorial #22 - Interfaces (by Tech With Tim on YouTube)
- [video] Understanding Go Interfaces
インターフェースの基本概念
Go のインターフェースは、メソッドの集合を定義する型です。具体的な実装は持たず、どの型がそのインターフェースを満たすか(そのインターフェースを「実装している」とされるか)は、定義されたメソッドが実装されているかどうかに依存します。
また、Go の特徴として、他の言語と違い「暗黙的なインターフェースの実装」があります。これは、特定の型がインターフェースに必要なメソッドをすべて持っていれば、その型はそのインターフェースを明示的な宣言なしに自動的に満たしているとみなされます。
インターフェースの定義例
type Speaker interface {
Speak() string
}
この例では、Speaker
というインターフェースが定義されており、Speak
というメソッドを持つことが要求されています。
インターフェースの実装
次に、Speaker
インターフェースを実装する型を作成します。
type Person struct {
Name string
}
func (p Person) Speak() string {
return "Hello, my name is " + p.Name
}
ここで Person
型は、Speak
メソッドを持っているため、自動的に Speaker
インターフェースを実装しているということになります。
インターフェースの使用
インターフェースは関数の引数や戻り値として利用することができ、具体的な型に依存せず柔軟な設計を可能にします。
func Greet(s Speaker) {
fmt.Println(s.Speak())
}
func main() {
person := Person{Name: "Alice"}
Greet(person)
// Hello, my name is Alice
}
このように、Greet
関数は Speaker
インターフェースを受け取り、Person
型は Speaker
インターフェースを満たしているため Greet
関数に渡すことができます。
インターフェースとポリモーフィズム
Go では、インターフェースを使って異なる型のインスタンスでも同じインターフェースを持っていれば、同じメソッドを呼び出すことができます。これにより、コードの柔軟性と拡張性が向上します。
type Animal struct {
Name string
}
func (a Animal) Speak() string {
return a.Name + " says woof!"
}
func main() {
var s Speaker
s = Person{Name: "Bob"}
Greet(s)
// Hello, my name is Bob
s = Animal{Name: "Dog"}
Greet(s)
// Dog says woof!
}
この例では、Person
と Animal
という異なる型が Speaker
インターフェースを満たしており、同じ Greet
関数で扱うことができます。
空インターフェース
Go には、すべての型を受け取ることができる特殊なインターフェース interface{}
があります。これは、Go の多くの標準ライブラリで使用されています。
func PrintAnything(a interface{}) {
fmt.Println(a)
}
この関数 PrintAnything
は、どんな型の引数でも受け取ることができます。
インターフェースのベストプラクティス
1. インターフェースは必要最小限のメソッドのみを定義する
Go では、小さいインターフェースを組み合わせることが推奨されています。最も有名な例は io.Reader
と io.Writer
です。
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
// 必要に応じて組み合わせることができる
type ReadWriter interface {
Reader
Writer
}
このアプローチの利点:
- インターフェースの実装が容易になる
- コードの再利用性が高まる
- テストが書きやすくなる
- 単一責任の原則に従いやすい
2. インターフェースは使用する側のパッケージで定義する
これは「インターフェースのユーザーが定義を所有すべき」という Go の重要な設計原則です。具体的な利点と使用例を見てみましょう。
// client/service.go
package client
type DataFetcher interface {
Fetch(id string) ([]byte, error)
}
type Client struct {
fetcher DataFetcher
}
func NewClient(f DataFetcher) *Client {
return &Client{fetcher: f}
}
// server/server.go
package server
type Server struct {
// フィールド
}
// DataFetcherインターフェースを実装
func (s *Server) Fetch(id string) ([]byte, error) {
// 実装
return nil, nil
}
このアプローチの利点:
- 依存関係が明確になる
- モックの作成が容易になる
- パッケージの分離が適切に行える
- インターフェースの設計が使用目的に即したものになる
3. 1 つのメソッドのインターフェースを積極的に活用する
単一のメソッドを持つインターフェースは、特定の振る舞いに焦点を当てた抽象化を提供します。
type Stringer interface {
String() string
}
type Logger interface {
Log(message string)
}
実際の使用例:
type ErrorHandler interface {
Handle(err error)
}
func ProcessWithErrorHandling(handler ErrorHandler) {
if err := doSomething(); err != nil {
handler.Handle(err)
}
}
このアプローチの利点:
- 単一責任の原則を自然に満たす
- テストが容易になる
- コードの再利用性が高まる
- 機能の組み合わせが柔軟になる
4. 空インターフェースの使用は最小限に抑える
interface{}
または any
(Go 1.18以降) で表現される空インターフェースは、どんな型の値でも保持できます。
var i interface{}
i = 42 // int
i = "hello" // string
i = struct{}{} // 空構造体
空インターフェースを多用すると、型安全性の喪失につながります。例えば、型アサーションのオーバーヘッドなど、場合によってはパフォーマンスへの影響もある上、コンパイル時の型チェックの恩恵を受けられない場合もあります。
// 良くない例:型安全性が失われる(コンパイル時の型チェックが効かない)
func ProcessData(data interface{}) {
// 型アサーションが必要
switch v := data.(type) {
case string:
// 文字列処理
case int:
// 数値処理
default:
// 予期しない型
}
}
// 良い例:ジェネリクス(Go 1.18以降)を使用することで、受け入れる型を制限
func ProcessData[T string | int](data T) { // string か int 以外はコンパイルエラー
// 型の分岐は依然として必要だが、想定外の型を考慮する必要がない
switch v := data.(type) {
case string:
fmt.Println("文字列:", v)
case int:
fmt.Println("数値:", v)
}
}
// 使用例
ProcessData("hello") // OK
ProcessData(42) // OK
ProcessData(true) // コンパイルエラー
また、型アサーションが頻繁に必要になれば、それだけハンドリングすることが増え、コードの可読性や保守性も低下します。
// 良くない例(可読性が低く、保守性も低い)
func HandleAny(items []interface{}) {
for _, item := range items {
// 型アサーションが必要で、エラーハンドリングも複雑になる
if str, ok := item.(string); ok {
// 文字列処理
} else if num, ok := item.(int); ok {
// 数値処理
}
}
}
// 良い例(可読性が高く、保守性も保てる)
type Item interface {
Process()
}
func HandleItems(items []Item) {
for _, item := range items {
item.Process() // 明確なインターフェース
}
}
インターフェースの実践的な使用例
以下の例では、Storage
インターフェースを使用することで、実際のデータの保存先(ファイルかメモリか)を抽象化しています。
// データの保存を抽象化するインターフェース
type Storage interface {
Save(data []byte) error
Load() ([]byte, error)
}
// ファイルへの実装
type FileStorage struct {
path string
}
func (fs FileStorage) Save(data []byte) error {
return os.WriteFile(fs.path, data, 0644)
}
func (fs FileStorage) Load() ([]byte, error) {
return os.ReadFile(fs.path)
}
// メモリへの実装
type MemoryStorage struct {
data []byte
}
func (ms *MemoryStorage) Save(data []byte) error {
ms.data = make([]byte, len(data))
copy(ms.data, data)
return nil
}
func (ms *MemoryStorage) Load() ([]byte, error) {
return ms.data, nil
}
例のように抽象化することで、以下のメリットがあります。
- テストが容易になる(本番環境ではファイル、テストではメモリを使用など)
- 新しい実装の追加が容易(例:データベースやクラウドストレージへの保存)
- 依存性の注入が可能
まとめ
- インターフェースの実装は暗黙的に行われ、明示的な宣言は不要。
- インターフェースを使用すると、柔軟で拡張性の高いコードを実現できる。
- 空インターフェースはすべての型を扱うことができるが、使用は最小限に抑えるべき。
- 小さなインターフェースを組み合わせることで、単一責任の原則に従った設計が可能。
- インターフェースはその使用側のパッケージで定義することが推奨される。
- 単一のメソッドを持つインターフェースは、特定の振る舞いを抽象化する際に有効。
[Next] Step 2-3: コンテキスト(Context)