ミドル Go 面接対策

ミドル Go Backend 面接質問

実用上のトレードオフ、並行処理、サービスの振る舞いを説明する必要がある Backend Developer 向けに、ミドル Go の面接質問を 15 問厳選しました。

ミドル Go の AI 面接を始めるクレジットカードは不要です。1回分の無料セッションがあります。
英語での技術面接練習非ネイティブ話者が技術面接の合格を目指して練習できるモードです。

型システム

1Goにおいて、ポインタ、スライス、マップ、チャネル、関数、インターフェイスにおけるnilの動作の違いを説明してください。

Goでは、nilはポインタ、スライス、マップ、チャネル、関数、およびインターフェイスのゼロ値ですが、それらのnil値に対する操作は型ごとに異なります。nilポインタはnilと比較できますが、デリファレンスするとパニックが発生します。nilスライスは長さと容量が0であり、rangeループでの反復処理やappendが可能です。nilマップは値の読み取りやrangeループでの反復処理が可能ですが、代入を行うとパニックが発生します。nilチャネルに対する送信や受信は永久にブロックし、nilチャネルをクローズするとパニックが発生します。nil関数を呼び出すとパニックが発生します。インターフェイスは動的型と動的値の双方が存在しない場合にのみnilとなります。型付きのnil値(nilポインタなど)を保持しているインターフェイス自体はnilにはなりません。

package main

import "fmt"

type MyErr struct{}

func (*MyErr) Error() string { return "my error" }

func returnsTypedNil() error {
	var e *MyErr = nil
	return e
}

func main() {
	var p *int = nil
	var s []int = nil
	var m map[string]int = nil
	var err error = returnsTypedNil()

	fmt.Println(p == nil)
	fmt.Println(len(s), cap(s), s == nil)
	fmt.Println(m["missing"])
	fmt.Println(err == nil)
}
AI コーチを使ってこの質問に答えてみる

2Goにおける比較可能(comparable)な型とは何ですか?また、比較可能性のルールはマップのキー、等値性比較、およびジェネリクスの制約にどのように影響しますか?

Goにおける比較可能な型とは、`==` および `!=` を使用して値を比較できる型のことです。基本型、ポインタ、チャネル、インターフェイス、ならびにすべてのフィールドまたは要素が比較可能である構造体や配列は比較可能です。一方、スライス、マップ、関数は `nil` との比較を除いて比較可能ではありません。マップのキーは比較可能でなければなりません。等値性判定は各型の比較ルールに従い、インターフェイスの比較は動的な具象値に依存します。比較対象のインターフェイスに比較不可能な動的値が含まれている場合、比較処理はパニック(panic)を引き起こします。ジェネリクスでは、事前宣言された `comparable` 制約を使用することで、型パラメータを `==`/`!=` で比較したり、マップのキーとして使用したりできるようになります。

package main

import "fmt"

type Point struct{ X, Y int }      // comparable
type Bag struct{ Items []string }  // not comparable because of slice field

func Contains[T comparable](xs []T, target T) bool {
    for _, x := range xs {
        if x == target {
            return true
        }
    }
    return false
}

func main() {
    p1, p2 := Point{1, 2}, Point{1, 2}
    fmt.Println(p1 == p2)

    m := map[Point]string{p1: "value"}
    fmt.Println(m[p2])

    fmt.Println(Contains([]string{"a", "b"}, "b"))

    var a any = []int{1}
    var b any = []int{1}
    _, _ = a, b
    // fmt.Println(a == b) // panic: comparing uncomparable type []int
}
AI コーチを使ってこの質問に答えてみる

3Go言語においてbyte、rune、UTF-8(8-bit Unicode Transformation Format)でエンコードされたテキストはどのように表現され、len(s)がユーザーに見える文字数と異なる場合があるのはなぜですか?

Go言語において、`byte`は`uint8`のエイリアスであり1つの生バイトを表し、`rune`は`int32`のエイリアスで1つのUnicodeコードポイントを表します。`string`はバイトの読み取り専用シーケンスであり、通常はUTF-8でエンコードされたテキストですが、任意のバイトを含めることもできます。`len(s)`はruneの数やユーザーに見える文字数ではなく、バイト数を返します。文字列のインデックスアクセスはバイトを返しますが、文字列に対して`range`ループを回すとUTF-8がデコードされ、バイトインデックスとruneが取得されます。`len(s)`が表示上の文字数と一致しない場合があるのは、UTF-8では1つのコードポイントに複数バイトが使用される場合があること、および結合文字や絵文字シーケンスのように1つのユーザーに見える文字が複数のコードポイントで構成される場合があるためです。

package main

import "fmt"

func main() {
    s := "é🙂"

    fmt.Println(len(s))         // bytes: é is 2 bytes, 🙂 is 4 bytes
    fmt.Println(len([]rune(s))) // Unicode code points

    fmt.Printf("first byte: %x\n", s[0])

    for i, r := range s {
        fmt.Printf("byte index %d: %q U+%04X\n", i, r, r)
    }
}
AI コーチを使ってこの質問に答えてみる

4型エイリアス(type alias)と定義型(defined type)の違いと、それぞれをどのような場面で使い分けるべきかを説明してください。

`type UserID int64` のような定義型は、`int64` を基底型(underlying type)とする新しい独立した型を作成します。明示的な型変換を行わなければ `int64` に代入できず、独自のメソッドを定義できます。一方、`type UserID = int64` のような型エイリアスは既存の型に対する別名に過ぎず、型の同一性や代入可能性はそのまま維持されます。ドメインモデリング、型安全性の確保、独自メソッドの定義には定義型を使用し、新しい型を導入せずにリファクタリング、移行、後方互換性を維持したい場合には主に型エイリアスを使用します。

type UserID int64      // defined type
type AccountID = int64 // alias

func takeInt64(x int64) {}

var u UserID = 10
var a AccountID = 20

takeInt64(int64(u)) // explicit conversion required
takeInt64(a)        // ok: AccountID is exactly int64

func (u UserID) String() string {
    return fmt.Sprintf("user:%d", u)
}
AI コーチを使ってこの質問に答えてみる

5Go は浮動小数点数の特殊値をどのように扱い、バックエンドシステムにおいて等価性の比較で注意すべき落とし穴にはどのようなものがありますか?

Go の `float32` と `float64` は IEEE-754 形式の振る舞いを採用しており、正負の無限大や NaN(非数)などの特殊値を含みます。`float64` のヘルパー関数としては、`math.Inf`、`math.IsInf`、`math.NaN`、`math.IsNaN` などがあります。NaN は自身を含むいかなる値とも等しくないため、`x` が NaN の場合 `x == x` は false になります。また、計算によって得られた浮動小数点数の厳密な等価比較も危険です。丸め誤差や精度の限界によって、数学的には等しい値でも差異が生じる可能性があるためです。ドメインに適した許容誤差(イプシロン)を用いるか、金額のような厳密性が求められるビジネス上の値には浮動小数点数の使用を避けてください。浮動小数点数はマップのキーとして使用可能ですが、マップの検索は等価性に依存し、NaN は自身とも等しいと判定されないため、NaN をキーにすると問題が生じます。

nan := math.NaN()
fmt.Println(nan == nan)
fmt.Println(math.IsNaN(nan))

m := map[float64]string{nan: "bad key"}
fmt.Println(m[nan]) // lookup fails because nan != nan
AI コーチを使ってこの質問に答えてみる

6Goにおける構造体の埋め込み(struct embedding)と、昇格したフィールドやメソッドの挙動について説明してください。

Goにおける構造体の埋め込みとは、明示的なフィールド名を付けずに型のみでフィールドを宣言することを指します(例: `type User struct { Person }`)。埋め込まれた値は通常のフィールドとして保持され、`u.Person`のようにアクセスできますが、その公開またはアクセス可能なフィールドやメソッドは昇格(promotion)されるため、呼び出し側は埋め込みフィールドを経由する短縮記法として`u.Name`や`u.Greet()`のようなセレクタを記述できます。埋め込みはコンポジション(合成)であり、古典的な継承ではありません。外側の型が自動的に埋め込まれた型のサブタイプになるわけではありません。昇格したセレクタ名が衝突した場合、Goは推測を行わないため、曖昧な名前は明示的に修飾して指定する必要があり、外側の値から直接セレクタとして参照することはできなくなります。

package main

import "fmt"

type Person struct {
    Name string
}

func (p Person) Greet() string {
    return "hi " + p.Name
}

type User struct {
    Person // embedded field; field name is Person
    ID     int
}

func main() {
    u := User{Person: Person{Name: "Ana"}, ID: 10}

    fmt.Println(u.Name)        // promoted field: u.Person.Name
    fmt.Println(u.Greet())     // promoted method: u.Person.Greet()
    fmt.Println(u.Person.Name) // explicit qualification still works
}
AI コーチを使ってこの質問に答えてみる

データ構造

7スライスの再スライス(reslice)や代入によって複数のスライスが同じ基底配列を共有する仕組みと、それによって発生しうるバグについて説明してください。

スライス値は、基底配列を指すヘッダーです。スライスの代入や関数への受け渡しでは、要素そのものではなくヘッダーのみがコピーされます。再スライスを行うと、同一の基底配列の特定範囲を指す別のヘッダーが作成されます。そのため、複数のスライスが同じ記憶領域を参照(エイリアス)することになります。一方のスライスを介して要素を変更すると他方のスライスにも反映され、空き容量(キャパシティ)が残っている場合は一方のスライスへの append が他方から見えるデータを上書きしてしまうことがあります。これによって引き起こされるバグには、意図しない値の変更、計算結果の破損、小さな部分スライスの保持による巨大な基底配列のメモリリーク、並行処理でエイリアスを共有することによるデータ競合などが挙げられます。意図しない共有を防ぐには、copy や append([]T(nil), s...) を用いて防御的コピーを行うか、append の前に完全スライス式(full-slice expression)を用いてキャパシティを制限します。

package main

import "fmt"

func main() {
	base := []int{1, 2, 3, 4}
	a := base[:2] // len 2, cap 4
	b := base[2:] // len 2, cap 2

	a[0] = 99
	fmt.Println(base, a, b)

	a = append(a, 77) // reuses base's backing array, overwrites base[2]
	fmt.Println(base, a, b)

	c := append([]int(nil), base[:2]...) // defensive copy
	c[0] = 42
	fmt.Println(base, c)
}
AI コーチを使ってこの質問に答えてみる

8append 実行時におけるスライスの拡張メカニズムを概念レベルで説明し、再割り当ての繰り返しがパフォーマンスに与える影響について述べてください。

append によってスライスに要素を追加する際、十分な容量(capacity)があれば既存の背後にある配列(バッキング配列)へそのまま書き込みます。容量が不足している場合、Go はより大きなバッキング配列を新規に割り当て、既存の要素をコピーした上で新しい要素を書き込み、新しいストレージを指すスライスヘッダを返します。具体的な拡張アルゴリズムは実装依存ですが、概念としては append の繰り返しがならし計算量(amortized time)で効率的になるよう十分なサイズで拡張されます。ただし、再割り当てが頻繁に発生すると、要素のコピーによる CPU 負荷、メモリ割り当ての発生、GC(Garbage Collector)への負荷増加が生じるほか、古いスライスとの参照共有が切れる原因にもなります。必要な要素数が事前に判明している場合は、append で追加するなら make([]T, 0, n)、インデックスで直接格納するなら make([]T, n) のように事前割り当てを行うことで、再割り当てを抑えられます。

package main

func collectNoPrealloc(input []int) []int {
	var out []int
	for _, v := range input {
		out = append(out, v*2)
	}
	return out
}

func collectPrealloc(input []int) []int {
	out := make([]int, 0, len(input))
	for _, v := range input {
		out = append(out, v*2)
	}
	return out
}
AI コーチを使ってこの質問に答えてみる

パッケージ

9Goにおけるinit時の登録パターンとは何ですか?また、ブランクインポートによる副作用やグローバルレジストリにはどのようなリスクが伴いますか?

init時の登録パターンとは、パッケージが自身の `init` 関数から共有レジストリに対して実装を登録するパターンのことです。`_ "example.com/driver"` のようなブランクインポートは、エクスポートされた名前を直接参照せず、副作用のみを目的としてパッケージをインポートし、その `init` 関数を実行させるためによく使用されます。これはドライバ、コーデック、プラグイン、メトリクス、シリアライザなどの拡張ポイントで一般的です。リスクとしては、隠れた依存関係や起動時の副作用、変更可能なグローバル状態、重複登録や実行順序への依存、テストの分離の困難さ、依存関係の接続が明示的でなくなることなどが挙げられます。このパターンは慎重に使用して明確にドキュメント化すべきであり、明示的な登録への切り替え、冪等かつ並行処理に対して安全なレジストリの採用、テスト用の注入可能またはリセット可能なレジストリの導入などによってリスクを軽減することが一般的です。

// registry/registry.go
package registry

import "fmt"

type Factory func() string

var factories = map[string]Factory{}

func Register(name string, f Factory) {
    if _, exists := factories[name]; exists {
        panic("duplicate registration: " + name)
    }
    factories[name] = f
}

func New(name string) (string, error) {
    f, ok := factories[name]
    if !ok {
        return "", fmt.Errorf("unknown implementation %q", name)
    }
    return f(), nil
}

// jsondriver/jsondriver.go
package jsondriver

import "example.com/app/registry"

func init() {
    registry.Register("json", func() string { return "json impl" })
}

// main.go
package main

import (
    "fmt"

    "example.com/app/registry"
    _ "example.com/app/jsondriver" // imported only for init side effect
)

func main() {
    v, _ := registry.New("json")
    fmt.Println(v)
}
AI コーチを使ってこの質問に答えてみる

エラー処理

10返却されるエラーを変更する可能性のあるクリーンアップ処理を実装する際、defer、panic、および名前付き戻り値はどのように相互作用しますか?

遅延関数(`defer`)は、戻り値が代入された後、関数が呼び出し元に戻る前に実行されます。そのため、遅延クロージャは名前付きの `err` などの名前付き戻り値を読み取ったり変更したりできます。これは一般に、`Close` や `Commit` などの処理によるクリーンアップエラーを返却されるエラーに追加する目的で使用され、理想的にはプライエラ(主要なエラー)を上書きせずに保持するようにします。panic によるアンワインド中も遅延関数は実行されます。遅延関数内で recover して名前付き戻り値を設定することも可能ですが、これは意図した panic の境界内に限定すべきです。また、`err` などの名前付き戻り値変数をシャドーイングしないよう注意してください。意図しない別の変数を defer が参照または変更してしまう可能性があります。

func writeFile(path string, data []byte) (err error) {
	f, err := os.Create(path)
	if err != nil {
		return err
	}

	defer func() {
		cerr := f.Close()
		if cerr == nil {
			return
		}
		if err != nil {
			err = errors.Join(err, fmt.Errorf("close %s: %w", path, cerr))
		} else {
			err = fmt.Errorf("close %s: %w", path, cerr)
		}
	}()

	_, err = f.Write(data)
	if err != nil {
		return fmt.Errorf("write %s: %w", path, err)
	}
	return nil
}
AI コーチを使ってこの質問に答えてみる

11Goのエラーラッピングチェーンにおいて、errors.Is、errors.As、および%wはどのように動作しますか?

`%w`を指定した`fmt.Errorf`は、コンテキストを追加しながら基底のエラーをラップする新しいエラーを作成します。ラッパーは`Unwrap`を通じて内部のエラーを公開し、標準ライブラリが検査可能なチェーンやツリーを形成します。`errors.Is(err, target)`は、`err`またはそれがラップしているいずれかのエラーがターゲットエラーと一致するかどうかを判定します。`errors.As(err, &target)`は、`err`またはそれがラップしているいずれかのエラーがターゲットの型に代入可能であるかを判定し、一致した値を指定されたポインタに格納します。

package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("not found")

type QueryError struct {
	Query string
	Err   error
}

func (e *QueryError) Error() string { return e.Query + ": " + e.Err.Error() }
func (e *QueryError) Unwrap() error { return e.Err }

func load() error {
	return fmt.Errorf("load user: %w", &QueryError{Query: "select user", Err: ErrNotFound})
}

func main() {
	err := load()
	fmt.Println(errors.Is(err, ErrNotFound))

	var qe *QueryError
	fmt.Println(errors.As(err, &qe), qe.Query)
}
AI コーチを使ってこの質問に答えてみる

12センチネルエラー(sentinel error)とは何ですか。また、カスタム型エラーやより表現力の高いドメインエラーモデルと比較した際のトレードオフは何ですか。

センチネルエラーとは、呼び出し元が特定の状態を判定できるように定義された名前付きのエラー値であり、多くは `var ErrNotFound = errors.New("not found")` のようなパッケージレベルの変数として定義されます。エラーのラップに対応している場合は、通常 `errors.Is` を使用して判定します。センチネルエラーはシンプルであり、大まかで変更されない安定したエラー分類には適していますが、公開(エクスポート)されたセンチネルはAPIの一部となるため、呼び出し元を特定の値に密結合させる可能性があります。一方、カスタム型エラーは構造化されたフィールドを保持でき、`errors.As` で検出できます。より表現力の高いドメインエラーモデルでは、種類やエラーコードによって失敗を分類し、安全なメッセージやメタデータを含めることができるため、呼び出し元が単一の固定エラー値にとどまらない安定した動作を必要とする場合に有用です。

package users

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("user not found")

type DomainError struct {
	Kind   string
	UserID int64
	Err    error
}

func (e *DomainError) Error() string {
	return fmt.Sprintf("%s user %d: %v", e.Kind, e.UserID, e.Err)
}
func (e *DomainError) Unwrap() error { return e.Err }

func Lookup(id int64) error {
	return &DomainError{Kind: "not_found", UserID: id, Err: ErrNotFound}
}
AI コーチを使ってこの質問に答えてみる

13Goのバックエンドにおいて、運用担当者が診断可能なシグナルを維持しながら、内部エラーを有益なクライアントレスポンスへマッピングするにはどのようにすべきですか?

Goのバックエンドでは、アプリケーション境界やトランスポート境界において、内部エラーをクライアントに対して安全で一貫した分類やレスポンスに変換すべきです。生の内部エラーではなく、適切なHTTPステータスコードや同等のトランスポート状態、安全なメッセージ、機械可読なエラーコードへとマッピングします。一方で運用担当者に対しては、構造化ログ、トレース、メトリクス、相関ID/リクエストID、保持された根本原因を通じて診断可能な情報を提供し続ける必要があります。ログ出力は、障害の見落としと過剰な重複ログの双方を防ぐため、リクエストコンテキストを持つ境界で原則1回のみ行うのが適切です。

type ErrorCode string

const (
	CodeNotFound ErrorCode = "not_found"
	CodeInvalid  ErrorCode = "invalid_argument"
	CodeInternal ErrorCode = "internal"
)

type AppError struct {
	Code    ErrorCode
	Message string // safe for clients
	Err     error  // internal cause
}

func (e *AppError) Error() string { return string(e.Code) + ": " + e.Err.Error() }
func (e *AppError) Unwrap() error { return e.Err }

func statusFor(code ErrorCode) int {
	switch code {
	case CodeNotFound:
		return 404
	case CodeInvalid:
		return 400
	default:
		return 500
	}
}

// Handler boundary idea:
// - call service
// - classify or convert error to AppError
// - log original error with request_id and safe request context
// - return {"code":"not_found","message":"user not found","request_id":"..."}
AI コーチを使ってこの質問に答えてみる

14Goでカスタムエラー型を正しく実装するにはどうすればよいですか?また、errors.Join はエラーの検査やクリーンアップ処理におけるエラーハンドリングにどのような影響を与えますか?

Goのカスタムエラー型は、`Error() string` を実装することで `error` を満たします。また、構造化されたフィールドを保持したり、根本原因を公開するために `Unwrap() error` を実装したりすることもできます。ポインタレシーバと値レシーバのどちらを使うかも重要です。ポインタレシーバの場合は `*T` のみが `error` を満たしますが、値レシーバの場合は通常 `T` と `*T` の両方が満たすことになり、値のコピー動作や呼び出し元が `errors.As` で指定すべき型に影響します。`errors.Join` は複数のエラーを1つのエラーにまとめ、`errors.Is` や `errors.As` は結合された個々のエラーを検査できます。これは、主要な操作で発生したエラーとクリーンアップ処理(defer)で発生したエラーの両方を、どちらの失敗も失うことなく返却したい場合に有用です。

package main

import (
	"errors"
	"fmt"
)

var ErrWrite = errors.New("write failed")
var ErrClose = errors.New("close failed")

type OpError struct {
	Op  string
	Err error
}

func (e *OpError) Error() string { return e.Op + ": " + e.Err.Error() }
func (e *OpError) Unwrap() error { return e.Err }

func save() (err error) {
	defer func() {
		closeErr := ErrClose
		err = errors.Join(err, closeErr)
	}()
	return &OpError{Op: "write file", Err: ErrWrite}
}

func main() {
	err := save()
	fmt.Println(errors.Is(err, ErrWrite))
	fmt.Println(errors.Is(err, ErrClose))

	var op *OpError
	fmt.Println(errors.As(err, &op), op.Op)
}
AI コーチを使ってこの質問に答えてみる

並行処理

15Go の select はチャネルに対してどのように動作しますか?準備完了ケースの選択、default ケース、キャンセル可能な操作を含めて説明してください。

select は複数のチャネル操作を待機し、送信または受信の処理が進行可能なケースを1つ実行します。準備が完了しているチャネルケースがない場合、default ケースが存在しない限りブロックされます。default はチャネル操作がいずれも実行できない場合にのみ即座に実行されるため、ノンブロッキングな送受信の試行に役立ちます。複数のケースが準備完了している場合、Go はソースコードの記述順ではなく擬似乱数的に1つを選択します。キャンセル可能なチャネル操作では、通常 ctx.Done() からの受信ケースを追加することで、コンテキストがキャンセルされた際やタイムアウト時に goroutine が待機を終了できるようにします。

func send(ctx context.Context, ch chan<- string, v string) error {
    select {
    case ch <- v:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}
AI コーチを使ってこの質問に答えてみる