ساختمان داده‌ها

متدهای لیست در پایتون: pop، remove، sort و دام alias و clone

۶ دقیقه مطالعه · به‌روزرسانی ۲۸ مهر ۱۴۰۵

در درس لیست دیدیم که لیست جهش‌پذیر است. این ویژگی هم ابزار قدرتمندی است و هم منبع یکی از گیج‌کننده‌ترین باگ‌های پایتون. در این درس اول متدهای کاربردی لیست را مرور می‌کنیم و بعد سراغ همان دام می‌رویم.

حذف عنصر: سه راه، سه معنی

L = [2, 1, 3, 6, 3, 7, 0]

L.remove(2)      # دنبال مقدار ۲ می‌گردد و حذفش می‌کند
print(L)

L.remove(3)      # فقط اولین ۳
print(L)

del(L[1])        # خانهٔ شمارهٔ ۱، هرچه که باشد
print(L)

last = L.pop()   # آخرین خانه را برمی‌دارد و برمی‌گرداند
print(last, L)

خروجی:

[1, 3, 6, 3, 7, 0]
[1, 6, 3, 7, 0]
[1, 3, 7, 0]
0 [1, 3, 7]

تفاوت‌ها را دقت کنید:

  • remove(x) دنبال مقدار می‌گردد و فقط اولین موردی را که پیدا کند حذف می‌کند. اگر آن مقدار اصلاً در لیست نباشد، ValueError می‌گیرید.
  • del(L[i]) بر اساس اندیس کار می‌کند، نه مقدار. (del در واقع یک دستور است، نه تابع؛ برای همین بیشتر برنامه‌نویس‌ها بدون پرانتز می‌نویسند: del L[1]. هر دو کار می‌کنند.)
  • pop() خانهٔ آخر را حذف می‌کند و مقدارش را برمی‌گرداند. هر سه لیست را جهش می‌دهند.

pop و ساختار استک

همین که pop از انتها برمی‌دارد، یک الگوی معروف می‌سازد: آخرین چیزی که گذاشته‌اید، اولین چیزی است که برمی‌دارید. به این می‌گویند LIFO (Last In, First Out) یا استک (stack) — مثل دسته‌ای سی‌دی روی هم؛ برای رسیدن به پایینی باید اول بالایی‌ها را برداشت.

stack = []
stack.append(1)
stack.append(2)
stack.append(7)

print(stack.pop())     # ۷ — آخرین چیزی که اضافه شد
print(stack.pop())     # ۲
print(stack)

💡 خود پایتون هم موقع اجرای برنامه از استک استفاده می‌کند: وقتی تابع a تابع b را صدا می‌زند و b تابع c را، اول c تمام می‌شود، بعد b، بعد a. به این می‌گویند call stack.

مرتب‌سازی: sort یا sorted؟

این دو شبیه‌اند ولی رفتارشان کاملاً فرق دارد و منبع اشتباه رایجی هستند:

warm = ['red', 'yellow', 'orange']
sortedwarm = warm.sort()       # لیست را جهش می‌دهد و None برمی‌گرداند
print(warm)
print(sortedwarm)

cool = ['grey', 'green', 'blue']
sortedcool = sorted(cool)      # لیست جدید می‌سازد، اصلی دست‌نخورده
print(cool)
print(sortedcool)

خروجی:

['orange', 'red', 'yellow']
None
['grey', 'green', 'blue']
['blue', 'green', 'grey']

⚠️ x = my_list.sort() تقریباً همیشه اشتباه است: x می‌شود None. یا my_list.sort() را تنها بنویسید، یا از sorted() استفاده کنید.

reverse() هم مثل sort() جهش می‌دهد:

L = [9, 6, 0, 3]
L.reverse()
print(L)

تبدیل رشته به لیست و برعکس

s = "I <3 cs"

print(list(s))        # هر کاراکتر یک عنصر
print(s.split('<'))   # بریدن بر اساس یک کاراکتر
print("salam donya".split())   # بدون آرگومان: بر اساس فاصله

L = ['a', 'b', 'c']
print(''.join(L))     # چسباندن
print('_'.join(L))    # با جداکننده

خروجی:

['I', ' ', '<', '3', ' ', 'c', 's']
['I ', '3 cs']
['salam', 'donya']
abc
a_b_c

این سه تا (split، join و list) در کار با فایل‌های متنی و ورودی کاربر دائم به کار می‌آیند.

حالا دام اصلی: لیست‌ها در حافظه

اینجا بخشی است که باید کند بخوانید. تا وقتی با int و str کار می‌کردیم، این موضوع پیش نمی‌آمد.

چاپ یکسان یعنی برابری، نه یکی‌بودن

cool = ['blue', 'green', 'grey']
chill = ['blue', 'green', 'grey']

print(cool)
print(chill)

chill[2] = 'blue'
print(chill)
print(cool)        # دست‌نخورده

دو لیست جداگانه ساخته‌ایم که اتفاقاً محتوایشان یکی است. تغییر یکی، روی دیگری اثر ندارد.

ولی این یکی فرق دارد: alias

warm = ['red', 'yellow', 'orange']
hot = warm                 # کپی نشد! فقط یک اسم دیگر

hot.append('pink')
print(hot)
print(warm)                # این هم عوض شد

خروجی:

['red', 'yellow', 'orange', 'pink']
['red', 'yellow', 'orange', 'pink']

hot = warm لیست را کپی نمی‌کند؛ فقط یک اسم دیگر روی همان شیء می‌گذارد. به hot می‌گوییم alias (نام مستعار) برای warm. هر تغییری از طریق یکی، از طریق دیگری هم دیده می‌شود. به این پدیده side effect (اثر جانبی) می‌گویند.

💡 تشبیه ساده: یک نفر ممکن است اسم رسمی، نام خانوادگی، لقب و اسم مستعار داشته باشد. اگر به او با یکی از این اسم‌ها ویژگی تازه‌ای نسبت بدهید، آن ویژگی مال خود آن شخص است و با هر اسم دیگری هم صدایش بزنید همان را دارد.

کلون کردن: وقتی واقعاً کپی می‌خواهید

cool = ['blue', 'green', 'grey']
chill = cool[:]            # برش کامل = لیست جدید با همان عنصرها

chill.append('black')
print(chill)
print(cool)                # دست‌نخورده

خروجی:

['blue', 'green', 'grey', 'black']
['blue', 'green', 'grey']

cool[:] یک لیست تازه می‌سازد و عنصرها را در آن کپی می‌کند. هر وقت می‌خواهید لیستی را تغییر بدهید ولی نسخهٔ اصلی سالم بماند، اول کلون کنید.

💡 cool.copy() و list(cool) هم دقیقاً همین کار را می‌کنند. copy() خواناتر است و در کد حرفه‌ای بیشتر می‌بینید.

لیست تودرتو: اثر جانبی همچنان هست

warm = ['yellow', 'orange']
hot = ['red']
brightcolors = [warm]
brightcolors.append(hot)
print(brightcolors)

hot.append('pink')
print(hot)
print(brightcolors)        # تغییر از اینجا هم دیده می‌شود

خروجی:

[['yellow', 'orange'], ['red']]
['red', 'pink']
[['yellow', 'orange'], ['red', 'pink']]

brightcolors خود لیست‌ها را در خودش کپی نکرده؛ فقط به همان‌ها اشاره می‌کند. پس تغییر hot در brightcolors هم پیداست. همین موضوع دربارهٔ کلون هم صدق می‌کند. cool[:] را کپی سطحی (shallow copy) می‌نامند: خود لیست تازه است، ولی اگر عنصرهایش لیست باشند، همان لیست‌های قبلی‌اند:

grid = [[1, 2], [3, 4]]
clone = grid[:]          # کپی سطحی

clone.append([5, 6])     # به خود لیست بیرونی: اثری روی grid ندارد
clone[0].append(99)      # به یک لیست داخلی: روی grid هم اثر دارد!
print(grid)
print(clone)

خروجی:

[[1, 2, 99], [3, 4]]
[[1, 2, 99], [3, 4], [5, 6]]

اگر کپی کامل تا عمق آخر می‌خواهید، از copy.deepcopy استفاده کنید:

import copy

grid = [[1, 2], [3, 4]]
deep = copy.deepcopy(grid)
deep[0].append(99)
print(grid)      # دست‌نخورده
print(deep)

جهش حین پیمایش: نکنید

و این هم نتیجهٔ مستقیم همهٔ بالایی‌ها:

def remove_dups(L1, L2):
    for e in L1:
        if e in L2:
            L1.remove(e)

L1 = [1, 2, 3, 4]
L2 = [1, 2, 5, 6]
remove_dups(L1, L2)
print(L1)

انتظار داریم [3, 4] باشد، ولی خروجی [2, 3, 4] است!

چرا؟ پایتون برای حلقه یک شمارندهٔ داخلی نگه می‌دارد. در گام اول شمارنده روی اندیس ۰ است و 1 حذف می‌شود؛ حالا لیست [2, 3, 4] است و شمارنده می‌رود روی اندیس ۱، یعنی 3. عنصر 2 کلاً دیده نمی‌شود. طول لیست عوض شده ولی شمارنده از این موضوع خبر ندارد.

راه‌حل: روی یک کلون پیمایش کنید و اصلی را تغییر دهید:

def remove_dups_new(L1, L2):
    L1_copy = L1[:]
    for e in L1_copy:
        if e in L2:
            L1.remove(e)

L1 = [1, 2, 3, 4]
L2 = [1, 2, 5, 6]
remove_dups_new(L1, L2)
print(L1)

خروجی:

[3, 4]

⚠️ قاعدهٔ طلایی: لیستی را که دارید رویش حلقه می‌زنید، تغییر ندهید.

تمرین

تابعی به اسم remove_all(L, x) بنویسید که همهٔ مقدارهای برابر با x را از لیست L حذف کند (نه فقط اولی) و خود L را جهش بدهد — یعنی چیزی برنگرداند. حواستان به دام بالا باشد.

جواب
def remove_all(L, x):
    for e in L[:]:          # پیمایش روی کلون
        if e == x:
            L.remove(x)

nums = [3, 1, 3, 7, 3, 9]
remove_all(nums, 3)
print(nums)

خروجی:

[1, 7, 9]

اگر L[:] را بردارید و مستقیم روی L حلقه بزنید، بعضی از ۳ها جا می‌مانند. راه دیگر، ساختن یک لیست تازه است (که اصلی را دست نمی‌زند):

nums = [3, 1, 3, 7, 3, 9]
result = []
for e in nums:
    if e != 3:
        result.append(e)
print(result)