[Python]変数を扱う際の注意点

# はじめに
- Pythonにおける変数とオブジェクト、不変なオブジェクトと可変なオブジェクト、および可変値を扱う際の注意点についてまとめてみました。
- 動作環境
    - Python 3.11.0

# オブジェクトについて
Pythonのすべてのオブジェクトは「値」、「データ型」、「ID」を持っています。`オブジェクトの値を変更できないオブジェクトは不変な`（immutable）オブジェクトで、`値を変更できるオブジェクトは可変な`（mutable）オブジェクトです。
以下、Pythonにおける不変なオブジェクトと可変なオブジェクトのデータ型の一例です。

| 不変    | 可変   | 
| --------- | -------- | 
| int       | list     | 
| float     | dict     | 
| bool      | set      | 
| str       | byearray | 
| frozenset | array    | 
| bytes     |          | 
| tuple     |          | 

type()関数でオブジェクトのデータ型を取得できます。
```
>>> type("hello")
<class 'str'>
>>> type(300)
<class 'int'>
>>> type([1, 2, 3])
<class 'list'>
```

# 変数について
## 変数は値を格納する箱ではない
Pythonでは、すべての変数がオブジェクトへの参照になります。オブジェクトは固有のIDを持っており、id()関数でオブジェクトのIDを確認することができます。IDとはオブジェクトの識別子で、オブジェクトが`メモリ上で割り当てられたアドレス`を指しています。

```python
# str1とstr2は異なるオブジェクトを参照するため異なるIDを持つ
>>> str1 = "hello"
>>> str2 = "hi"
>>> id(str1)
140270955849136
>>> id(str2)
140270955719728
```
また、複数の変数が同じオブジェクトを参照することができます。

```python
# list1とlist2は同じオブジェクトを参照するため同じIDを持つ
>>> list1 = [1, 2, 3]
>>> list2 = list1
>>> id(list1)
139669259568960
>>> id(list2)
139669259568960
```

変数を「値を格納する箱」とたとえることがよくありますが、参照に関してはこのたとえは適切ではないと言えるでしょう。なぜなら、複数の箱は同じオブジェクトを格納できないからです。
「箱」の代わりに、`「ラベル」`とたとえることができます。
![python_var.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2954734/ddb84069-08d7-4dc5-c16a-97b54673e8b6.png)



## is演算子と==演算子
「is」演算子を使ってオブジェクトの同一性を比較することができます。同じオブジェクト（同じIDを持つ）であればTrueを返します。 

```python
>>> str1 = "hello"
>>> str2 = "hi"
>>> list1 = [1, 2, 3]
>>> list2 = list1
>>> str1 is str2
False
>>> list1 is list2
True
```
一方、「==」演算子は同値性を比較します。同じ値を持つ異なるオブジェクトでもTrueを返します。
```python
>>> num1 = 300
>>> num2 = 300
>>> id(num1)
140414463929584
>>> id(num2)
140414463929648
# 異なるオブジェクトなのでFalse
>>> num1 is num2
False
# 値が同じなのでTrue
>>> num1 == num2
True
```
## 代入演算子「=」は参照をコピーする
代入演算子「=」はオブジェクトではなくオブジェクトへの参照をコピーします。

```python
# 値が300の２つの異なるオブジェクトが生成される
>>> num1 = 300
>>> num2 = 300
>>> num1 is num2
False
# num1の参照をnum2にコピーしているので、同じオブジェクトを参照することになる
>>> num2 = num1
>>> num1 is num2
True
```

不変なオブジェクトを参照する変数の場合、変数の値を更新すると、オブジェクトの値が更新されるのではなく、`新しいオブジェクトが生成され、そのオブジェクトを参照する`ことになります。

```python
>>> str1 = "hello"
>>> id(str1)
140213893921904
>>> str1 = "hi"
>>> id(str1)
140213893786608
```


代入演算子「=」がオブジェクトの複製を作っていると勘違いすると、バグが発生する可能性があります。たとえば`可変なオブジェクトを参照する変数を扱う際`に、下記のように気を付ける必要があります。
```python
>>> list1 = [1, 2, 3]
# list1の値をコピーしているつもりだが、参照をコピーしてしまう
>>> list2 = list1
# list型のオブジェクトは可変なオブジェクトなので、値は変更可能
# list2に新しい要素を追加すると、list1も更新されてしまう
>>> list2.append(4)
>>> list2
[1, 2, 3, 4]
>>> list1
[1, 2, 3, 4]
# 同じオブジェクトを参照していることがわかる
>>> list2 is list1
True
```


## 事前配置整数と文字列のインターニング
Python（CPython）ではちょっとした最適化として、プログラム開始時に`-5~256`までの整数オブジェクトを作成します。参照ごとに重複したオブジェクトを使用するのでなく、１つの整数オブジェクトを参照することでメモリーを節約しています。

```python
>>> num1 = 256
>>> num2 = 256
>>> num1 is num2
True
>>> num3 = 257
>>> num4 = 257
>>> num3 is num4
False
```
同様に同じ文字列リテラルを表す際にも、`オブジェクトを再利用`します。ただし、この最適化はどんな文字列でも見つけられるわけでありません。

```python
>>> str1 = "hello"
>>> str2 = "hello"
>>> str1 is str2
True
>>>
>>> str2 = "he"
>>> str2 += "llo"
>>> str2
'hello'
>>> str1 is str2
False
```


# 可変値を扱う際の注意点
ほかにも色々あると思いますが、可変値を扱う際の注意点を３つ取り上げます。

## copy.copy()やcopy.deepcopy()で可変値をコピーする
たとえばlistをコピーする際に、以下のようにcopy()メソッドを使うことができます。

```python
>>> list1 = [1, 2, 3]
>>> list2 = list1.copy()
>>> list2
[1, 2, 3]
>>> list2.append(4)
>>> list2
[1, 2, 3, 4]
>>> list1
[1, 2, 3]
>>> list2 is list1
False
```
また、copy.copy()関数を使うこともできます。

```python
>>> import copy
>>> list1 = [1, 2, 3]
>>> list2 = copy.copy(list1)
>>> list2
[1, 2, 3]
>>> list2.append(4)
>>> list2
[1, 2, 3, 4]
>>> list1
[1, 2, 3]
>>> list2 is list1
False
```

しかし、上記の2つの方法では、`可変値の中に含まれる可変値をコピーできません`。

```python
>>> import copy
>>> list1 = [[1, 2], [3, 4]]
>>> list2 = copy.copy(list1)
>>> list2
[[1, 2], [3, 4]]
>>> list2[0][0] = "hello"
>>> list2
[['hello', 2], [3, 4]]
>>> list1
[['hello', 2], [3, 4]]
# list2とlist1は異なるオブジェクトだが、内部では同じオブジェクトを参照している
>>> list2 is list1
False
>>> list2[0] is list1[0]
True
```

可変値の中に含まれる可変値をコピーしたい場合は`copy.deepcopy()`を使うことができます。

```python
>>> import copy
>>> list1 = [[1, 2], [3, 4]]
>>> list2 = copy.deepcopy(list1)
>>> list2
[[1, 2], [3, 4]]
>>> list2[0][0] = "hello"
>>> list2
[['hello', 2], [3, 4]]
>>> list1
[[1, 2], [3, 4]]
```

## 関数のデフォルト引数に可変値を使わない
関数のデフォルト引数にlistやdictのような可変なオブジェクトを使うと、バグが発生しやすいです。

```python
def sample_func(num, l=[]):
    l.append(num)
    return l

x = sample_func(4)
print(x)
x = sample_func(5)
print(x)
```

```
[4]
[4, 5]
```
一回目の実行では空のリストが作成され、整数4がリストに追加されたのですが、二回目の実行では、以前作成されたリストを参照しているので、既存のリストをそのまま使うことになっています。
この場合、デフォルト引数を`None`に設定することができます。

```python
def sample_func(num, l=None):
    if l is None:
        l = []
    l.append(num)
    return l

x = sample_func(4)
print(x)
x = sample_func(5)
print(x)
```
```
[4]
[5]
```

## ループ時にリストの追加と削除をしない
ループ時にリストの追加と削除を行うと、バグが発生しやすいです。
### リストの追加
例えば以下の処理では、リストに「"Python"」という文字列を見つけたら、同じリストに同じ文字列「"Python"」を追加しようとしていますが、これを実行すると、`無限ループ`になってしまいます。

```python
languages = ["Java", "Go", "Python"]

# 無限ループに陥る
for language in languages:
    if "Python" in language:
        languages.append(language)
        print("要素を追加済み:", language)
```
```
...
...
...
要素を追加済み: Python
要素を追加済み: Python
要素を追加済み: Python
要素を追加済み: Python
要素を追加済み: Python
^C
Traceback (most recent call last):
  File "/tmp/sample.py", line 7, in <module>
    print("要素を追加済み:", language)
KeyboardInterrupt
```

解決策として、新しいリストを用意します。

```python
languages = ["Java", "Go", "Python"]
new_languages = []

for language in languages:
    if "Python" in language:
        new_languages.append(language)
        print("要素を追加済み:", language)

# new_languagesの要素をlanguagesに追加
languages.extend(new_languages)
print(languages)
```
```
要素を追加済み: Python
['Java', 'Go', 'Python', 'Python']
```

### リストの削除
例えば以下の処理では、リストをforループで回し、「"Python"」以外の要素をすべて削除しようとしています。しかし、これを実行すると、要素が削除された途端に、次の要素のインデックスが１つ前に移動するので、「"Go"」がスキップされてしまいます。

```python
languages = ["Python", "Python", "Java", "Go", "Python"]

# "Go"がスキップされる
for i, language in enumerate(languages):
    if language != "Python":
        del languages[i]

print(languages)
```
```
['Python', 'Python', 'Go', 'Python']
```

解決策として、削除したい要素を除く要素をコピーしたリストを作成し、元のリストを置き換えます。

```python
languages = ["Python", "Python", "Java", "Go", "Python"]
new_languages = []

for language in languages:
    if language == "Python":
        new_languages.append(language)

languages = new_languages
print(languages)
```
```
['Python', 'Python', 'Python']
```

# 参考
- [Pythonドキュメント](https://docs.python.org/ja/3/)