AI在庫管理の開発チームでバックエンドエンジニアをしている沖(@takuoki)です。AI在庫管理では、サーバーサイドの大部分で Python を使用しているため、私も毎日 Python をごりごり書いています。ただ、私が Python をちゃんと触り始めたのは、カケハシに入社した 1 年半前で、それまでは主に Go を書いていました。
Go の方が後発の言語のため、Pythonista が Gopher になったという記事の方が簡単に見つかるのですが、ここでは逆に「Gopher のための Python 入門」みたいなものに挑戦してみようかなと思っています。入門といいつつも、今回は、私が実際に業務で使用したものにフォーカスしているため、ちょっと偏りがあります。また、私自身が Go の前は Java などのオブジェクト指向言語の経験があるため、その辺りもあまり詳細には触れていない点もご容赦ください。
想定している読者は下記のような感じです。
- Go についてはある程度理解していて、これから Python を始めたい / 始めることになったエンジニア
- Python を業務で書いたことはないけど、カケハシに興味があるエンジニア
- Go と Python の比較に単純に興味がある人
なお、この記事はカケハシ Advent Calendar 2024 の 14 日目の記事になります。他の記事も合わせて楽しんでいただけたら嬉しいです。
Gopher のための Python 入門
組み込み型
Python で用意されている組み込み型は、ざっくり言ってしまうと Go とあまり差はなく、最低限これだけ頭に入れておけば良さそうかなと思うのは下記のとおりです。詳細はこちらを参照してください。
- Go では避けて通れないポインタの概念は隠蔽されているため、普段意識することは少ない。ただし、Python はプリミティブな型であってもすべての変数がポインタとなっているため、ポインタについての理解は無駄にはならない。
- Python 特有の型として、タプル(
tuple
)型がある。タプルはリスト(配列)とよく似ているが、イミュータブルである(要素の追加 / 変更 / 削除ができない)という点でリストと異なる。イミュータブルなため、後述する dict のキーとしても使用できる。 - Go の map に相当するものは辞書(
dict
)型。Go の map はイテレート時に順序がランダムになるよう意図的な実装があるが、Python では逆に順序が保証されている(Python 3.7 以降)。 - Go では map を用いて表現する必要があった set の概念が、Python では
set
型として用意されている。set
は{1, 2, 3}
のように初期化できるが、空で初期化する場合はset()
とする必要がある({}
は空の dict になるため)。
ポインタについて少し補足すると、int
型などのイミュータブルな型の場合、オブジェクト自体は変更されず、変数の参照先が変わることになります。この辺りは、ポインタが隠蔽されている Java の String 型などと同じ挙動ですね。
# イミュータブルオブジェクト(例: int 型) a = 1 b = 2 print(f"{a=}, {id(a)=}") # a=1, id(a)=4355629464 print(f"{b=}, {id(b)=}") # b=2, id(b)=4355629496 a += 1 print(f"{a=}, {id(a)=}") # a=2, id(a)=4355629496 (参照先が変わる) print(f"{b=}, {id(b)=}") # b=2, id(b)=4355629496 # ミュータブルオブジェクト(例: list 型) la: list[int] = [] lb: list[int] = [] print(f"{la=}, {id(la)=}") # la=[], id(la)=4345268544 print(f"{lb=}, {id(lb)=}") # lb=[], id(lb)=4345270400 la.append(1) print(f"{la=}, {id(la)=}") # la=[1], id(la)=4345268544 (参照先は変わらない) print(f"{lb=}, {id(lb)=}") # lb=[], id(lb)=4345270400
変数スコープ
変数スコープに関しても、パッケージグローバルな変数や関数内部のローカル変数といった考え方は Go と Python で同じです。(ちょっと雑な説明になりますが、同一ディレクトリに配置されたファイルがひとつのパッケージに属するというルールも同じです)
一方で、Go にはブロックスコープがありますが、Python にはありません。そのため、変数スコープを小さくするためには、関数を細かく分割するといった工夫が必要です。例えば、Python には Go の変数宣言と似た記述でセイウチ(walrus)演算子というものがありますが、ブロックスコープが存在しないため、Go と Python で同じように記述しても、挙動が異なります。
// go package main func main() { a := 1 println(a) // 1 if a := 2; a != 0 { println(a) // 2 } println(a) // 1 }
# python a = 1 print(a) # 1 if a := 2: # セイウチ演算子(代入と同時に a が falsy かどうかを判定) print(a) # 2 print(a) # 2
また、Python では変数宣言と代入を明示的に区別する書き方がないため、スコープをまたいで変数に値を代入する場合は、global
や nonlocal
といったキーワードを使用する必要があります。下記はクロージャを用いたカウンターの例です。
def counter(): count = 0 def increment() -> int: nonlocal count # 外側の関数の変数 count を使用するための宣言 count += 1 return count return increment my_counter = counter() print(my_counter()) # 1 print(my_counter()) # 2 print(my_counter()) # 3
関数
関数における、Go と Python との比較は下記のとおりです。
- Go では
func
を用いて定義するが、Python ではdef
を利用する。 - オプション引数を定義したい場合、Go では Functional options pattern などで複雑な実装をする必要があるが、Python ではデフォルト値付きの引数を定義できるため、かなり実装を簡易化できる。
- 関数呼び出し時の引数の指定方法として、Go では位置引数のみをサポートしているが、Python ではキーワード引数も利用できる。引数の数が多い場合やオプション引数が含まれる場合などは、キーワード引数を利用することで可読性を上げられる。
- Go で
args ...string
のように記述する可変長引数は、Python では*args: str
のように*
を用いて記述する。また、任意のキーワード引数を dict 型で受け取れる**kwargs
も利用できる。 - Go と同様に、Python も戻り値を複数指定できる。複数指定した場合の戻り値の型はタプル型となるが、複数の変数で受け取ることもできる(アンパックされる)ため、Go と同じように書くことができる。
- Go でクロージャを実装するときは無名関数を利用するが、Python でも
lambda
式として無名関数を利用できる。ただし、lambda
式は単一の式しかサポートしないため、複数行必要な場合は前述のように関数内に関数を定義して実装する。 - Python には、関数に機能を追加するデコレータというものがある。実体は Go で Middleware と呼ばれる高階関数と似ているが、Middleware が API サーバーにおいてハンドラが呼び出される前後に追加される処理を指すのに対し、デコレータは関数全般で利用できる。
キーワード引数について補足すると、引数が多い場合に位置引数とキーワード引数が混在すると可読性が下がるため、/
や *
を用いて位置専用引数やキーワード専用引数を定義することができます。(個人的には、キーワード専用引数(*
)だけで十分な気はします)
def foo(a: str, /, *, b: str) -> None: print(f"{a=}, {b=}") # 正しい呼び出し foo("Hello", b="World") # エラーとなる呼び出し foo(a="Hello", b="World") # TypeError: foo() got some positional-only arguments foo("Hello", "World") # TypeError: foo() takes 1 positional argument but 2 were given
また、デフォルト引数について、デフォルト値はモジュール読み込み時に一度しか評価されないということを理解しておく必要があります。そのため、None
(Go の nil
のようなもの)またはイミュータブルな値以外をデフォルト値として指定することは推奨されません。
import time from datetime import datetime def append_one(li: list[int] = []) -> list: li.append(1) return li print(append_one([1, 2])) # [1, 2, 1] print(append_one()) # [1] print(append_one()) # [1, 1] <- 同じリストを参照しているため、前回の結果に 1 が追加される def get_time(t: datetime = datetime.now()) -> str: return t.isoformat() print(get_time(datetime.fromisoformat("2024-12-14"))) # 2024-12-14T00:00:00 print(get_time()) # 2024-12-13T12:34:56.241535 time.sleep(1) print(get_time()) # 2024-12-13T12:34:56.241535 <- 関数呼び出し時に評価されているわけではないため値が変わらない
分岐 / 繰り返し処理
分岐や繰り返し処理については、Python として特殊な点は多くないので、まずは対応する書き方を抑えておけば良いかなと思います。
Go | Python | |
---|---|---|
分岐 | if , else if , else |
if , elif , else |
switch , case , default |
match , case , case _ (Python 3.10以降) |
|
繰り返し | for i := 0; i < n; i++ |
for i in range(n) |
for i < n |
while i < n |
|
for _, v := range <list> |
for v in <list> |
|
for i, v := range <list> |
for i, v in enumerate(<list>) |
|
for k := range <map> |
for k in <map> |
|
for _, v := range <map> |
for v in <map>.values() |
|
for k, v := range <map> |
for k, v in <map>.items() |
Go には存在しないものの、Python では三項式(一般に「三項演算子」として知られる構文)が利用可能で、次のように記述できます。
max = x if x > y else y
また、Python にはイテレータのための組み込み関数が多く用意されています。中でも zip 関数を用いると、2 つのイテレータを簡単に並列処理できます。イテレータの長さが異なる場合は、短い方の要素数分繰り返されます。
a = [1, 2, 3] b = ["a", "b", "c"] for v1, v2 in zip(a, b): print(v1, v2) # 1 a, 2 b, 3 c
他にも、内包表記という書き方があり、リストやセット、辞書などをひとつの式で生成することができます。下記のように、処理によって行数を大幅に削減でき、パフォーマンス上の利点もあるのですが、無理に内包表記で記述すると逆に可読性が落ちるため、シンプルなものに限って利用するようにしましょう。
// go package main import "fmt" func main() { result := []int{} for i := 0; i < 10; i++ { if i%2 == 0 { result = append(result, i) } } fmt.Println(result) // [0 2 4 6 8] }
# python li = [i for i in range(10) if i % 2 == 0] print(li) # [0, 2, 4, 6, 8]
ちなみに、Python には for
や while
にも else
を付けることができます。この else
は、for
の繰り返しが最後まで完了すると実行されます(繰り返し数が 0 回の場合も実行されます)。逆に break
等で抜けた場合は実行されません。ただ、あまり直感的な挙動ではないことと誤解を生みやすいことから、使用しない方がよいと Effective Python (項目 9) にも明記されています。
for i in range(3): print(i) else: print("done") # 0 # 1 # 2 # done
クラス / 構造体
Go と違い Python はオブジェクト指向の言語のため「クラス」という概念があり、基本要素であるカプセル化、継承、ポリモフィズムをサポートしています(Go にもカプセル化やポリモフィズム(インターフェース)はありますが、継承は意図的に実装されていません)。ただ Python のカプセル化は、シンボルの先頭を __
(アンダースコア 2 つ)で始めるとシンボル名が単純に変換されるだけ(マングリング)で、完全にアクセス不可にすることはできません。
class Hoge: def __init__(self, foo: str, bar: str): self.foo = foo self.__bar = bar # プライベート hoge = Hoge("a", "b") print(hoge.foo) # a # print(hoge.__bar) # AttributeError print(hoge._Hoge__bar) # b
一方で、Go にはクラスという概念がないため、ここでは Go の構造体と Python のクラスとを比較します。
- Go の構造体をインスタンス化するファクトリー関数(一般的に
NewXxx
で定義される関数)に相当するものは Python のコンストラクタであり、クラス内に__init__
関数として定義する。 - Go のメソッドに相当するものは Python のインスタンスメソッドであり、第一引数がレシーバ(引数名は
self
が一般的)の関数としてクラス内に定義する。 - Go には対応するものがないが、Python ではインスタンスメソッドとは別に、クラスメソッドや静的メソッドを定義でき、どちらもインスタンス化せずに呼び出せる。
なお、Go の構造体により近い感覚で使用できるものとして、@dataclass
というデコレータがあります。通常、クラスの定義にはフィールドの初期化や __repr__
、__eq__
などのダンダーメソッドを手動で記述する必要がありますが、dataclass を使うと、これらのコードを自動的に生成してくれます。
from dataclasses import dataclass @dataclass class Point: x: int y: int p1 = Point(2, 3) p2 = Point(2, 3) print(p1) # Point(x=2, y=3) print(p1 == p2) # True
dataclass を使わない場合は、下記のような挙動になります。
class Point: x: int y: int def __init__(self, x: int, y: int): # 手動で定義する必要がある self.x = x self.y = y p1 = Point(2, 3) p2 = Point(2, 3) print(p1) # <__main__.Point object at 0x1021b9cd0> print(p1 == p2) # False
データ構造を表すための候補としては、dataclass の他にも namedtuple や TypedDict などが挙げられますが、型安全で柔軟な定義が可能なため、個人的には dataclass を使用する頻度が高いです。
エラーハンドリング
おそらく Go と Python で最も違いを感じるのはエラーハンドリングではないでしょうか。といっても、Python のエラーハンドリングは例外をスローする方式なので、同じ方式である他の言語(Java や C#、JavaScript / TypeScript など)の経験があれば、問題なく馴染めるかなと思います。
- Go における、戻り値としてエラーを返す方法と
panic
を発生させる方法の両方が、Python では例外をスローする方法で実装される。raise
を利用して例外をスローし、try
,except
,finally
でエラーハンドリングする。 - 汎用的な
Exception
をスローすると取り回しが難しくなるため、できるだけカスタム例外を定義してスローする。 - Python では、Java の検査例外のような「必ずハンドリングする必要のある例外」を定義することはできないため、例外をハンドリングするか無視するかは実装者に委ねられる。
例外発生時も正しくリソースを解放するために、Go では defer
を利用しますが、Python では with
を利用します(finally
でも書けますが with
の方が簡略化できます)。下記の例ではリソースの数に合わせて with
のネストが深くなっていますが、with
に複数のリソースを同時に渡したり contextlib.ExitStack
を利用することで、ネストが深くなることを防ぐこともできます。
// go package main import ( "fmt" ) type Closer struct { name string } func NewCloser(name string) *Closer { return &Closer{name: name} } func (c *Closer) Close() { fmt.Printf("Closing resource: %s\n", c.name) } func main() { closer1 := NewCloser("Resource 1") defer closer1.Close() closer2 := NewCloser("Resource 2") defer closer2.Close() fmt.Println("Using resources...") } // Using resources... // Closing resource: Resource 2 // Closing resource: Resource 1
# python class Closer: def __init__(self, name): self.name = name def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): print(f"Closing resource: {self.name}") def main(): with Closer("Resource 1") as closer1: with Closer("Resource 2") as closer2: print("Using resources...") if __name__ == "__main__": main() # 出力は同じ
その他
エントリーポイント
Go の場合、エントリーポイントは main
パッケージに含まれる main
関数ですが、Python にはエントリーポイントはありません。そのため、あるファイルを実行しようとした場合は、ファイルの先頭から順に処理が実行されます。特定のエントリーポイントを設けたい場合は、以下のように実装します。
def main(): print("Hello, Python!") if __name__ == "__main__": main()
ファイル名が hello.py
の場合、python hello.py
で実行された時のみ、__name__
の値は "__main__"
になり、結果 main
関数が実行されます。別のモジュールから読み込まれたタイミングでは、 main
関数は実行されません。
nil != None
Go は静的型付け言語のため、変数宣言時に代入をしない場合はゼロ値で初期化されます。つまり、「型」と「値」が区別されていることになります。一方で、Python は動的型付け言語のため、「型」と「値」を区別する(値未設定の変数を宣言する)ことはできません。
「Go の nil
のようなもの」として Python の None
を紹介していますが、nil
と None
は大きく異なります。例えば、変数の型ヒントとして list[str] | None
のような記述はできますが、あくまで str
と None
のユニオン型であるだけで、Go の []string
とは挙動が異なります。
// go package main import "fmt" func main() { var li []string fmt.Printf("%#[1]v, %[1]T\n", li) // []string(nil), []string fmt.Println(len(li)) // 0 }
# python li: list[str] | None = ["foo"] li = None print(f"{li}, {type(li)}") # None, <class 'NoneType'> print(len(li)) # TypeError: object of type 'NoneType' has no len()
Go と Python の設計思想
今回記事を書いている中で、ふと「Pythonってどんな思想で設計されているんだろう」と思い調べたところ、The Zen of Python という形で表現されていることを知りました(import this
で確認できます)。これを読んでいると、「あれ?これって Go の設計思想かな?」と錯覚してしまうほどだったので、私が印象に残ったところを抜粋して紹介します。
なお、Go のコントリビュータである Andrew Gerrand さんも、"Go and the Zen of Python" の中で "Go is Zenlike" と述べているので、そう感じている人は多いのかもしれません。
Flat is better than nested.
ネストが深くなると読みにくいですね。Go のメソッドは構造体の中ではなくトップレベルに定義します。また Go の defer
は複数のリソースを解放する処理をフラットに書くことができます。
Errors should never pass silently.
例外をスローしても、キャッチし忘れてしまうことは多々あります。その意味でも、Go のようにエラーを戻り値で返すことは、正しくハンドリングすることを促す意味で重要かなと思います。
There should be one-- and preferably only one --obvious way to do it.
Python もスクリプト言語の中では機能が絞られている方かなとは思いつつ、Go の方がより機能が絞られているように感じています。いずれにせよ、誰が見ても明らかな最良の実装を追い求めていきたい気持ちがあります。
If the implementation is hard to explain, it's a bad idea. If the implementation is easy to explain, it may be a good idea.
実装が複雑になりすぎた場合、その仕様にはきっとバグが潜んでいます。仕様を愚直に実装するのではなく、怪しい部分を敏感に嗅ぎ取って、シンプルな仕様へと導けるエンジニアになりたい。
Python も Go と似た設計思想を持っているからこそ、比較的スムーズに Python に馴染むことができたのかなと感じています。
おわりに
カケハシに転職する際、基本的には Go を使っている企業を候補にしていたため、応募した企業の中で Go を使っていないのはカケハシだけでした。ただ、カケハシのミッションへの共感と、チームとして成果を出そうとしている組織文化に惹かれて、Python や TypeScript をメイン言語として使っているカケハシを選びました。
実際に入社してから 1 年半ほど Python を書いた今、結局プログラミング言語はツールでしかないなと感じています。Go と Python の設計思想が近しいこともひとつの要因ではありますが、特定の言語をしっかり身につけられていたからこそ、新しい言語でもそれなりに丁寧にコードを書けているかなとは思っています。ただ、プログラミング言語が、チームやユーザーに対する価値貢献の程度に与える影響は、思った以上に小さいんだなと再認識しました。
個人的には、新しく Web アプリケーションを構築する場合に、バックエンドで Python を採用することはあまりないかなと思っていますが、Notebook や pandas を使用できる Python を身につけられたことは、データ分析や集計なども手を出せるようになったということで、エンジニアとしての幅も広がったかなと思っています。そういう意味で、Python を新しく学ぶのも価値があるのではと感じています。