সরাসরি পর্বে যান
পর্ব ১৯ · ৫৫-এর মধ্যে · মডিউল ৩

mutability, alias আর copy — দুই নাম, এক list-এর রহস্য

Mutability, Aliasing & Copying — Shallow Copy, Deep Copy and the Shared-List Trap

Taught in Bangla, this lesson explains why two names can share one list: mutable versus immutable objects, rebinding versus mutating, call by sharing, shallow copies, the [[0]*3]*3 seating trap, deepcopy and its cost, and defensive copying as a design tool.

এই পর্বে যা শিখবেন

  • কোন type-এর object বদলানো যায় আর কোনটির যায় না — এবং এই পার্থক্য কেন বাগের মূল উৎস
  • “নাম সরল” আর “object বদলাল” — দুই ধরনের কাজ আলাদা করে চেনা, +=-এর দ্বৈত চরিত্রসহ
  • ফাংশনে list পাঠালে কী ঘটে (call by sharing), আর ফাংশন কীভাবে আপনার ডেটা চুপচাপ বদলে দিতে পারে
  • Shallow copy আর deep copy-র আসল পার্থক্য, খরচ, আর কখন কোনটি লাগে
  • Defensive copy আর immutability দিয়ে এমন কোড লেখা, যেখানে এই বাগ জন্মাতেই পারে না

১ · একটি রহস্যময় বাগ — যে কপি আসলে কপি নয়

খুলনার একটি স্কুলের এক শিক্ষক Python দিয়ে নিজের ক্লাসের নম্বরপত্র রাখেন। প্রতিটি শিক্ষার্থীর জন্য একটি ছোট list: নাম আর বিষয়ভিত্তিক নম্বর। পুরো শ্রেণির নম্বর একটি বড় list-এ। পরীক্ষার পর তিনি একটি “যদি” হিসাব করতে চাইলেন — যদি সবাইকে ৫ নম্বর গ্রেস দিই, তাহলে কতজন পাস করবে? আসল নম্বর যেন না বদলায়, তাই তিনি সাবধানে লিখলেন trial = marks, তারপর trial-এ ৫ যোগ করলেন।

হিসাব ঠিকঠাক এল। কিন্তু পরদিন ছাপানো ফলাফলে দেখা গেল — সবার নম্বরে ৫ বেশি! আসল নম্বরপত্রও বদলে গেছে। তিনি ভাবলেন trial = marks লিখেই তো কপি করেছিলাম, তাহলে আসলটা বদলাল কীভাবে?

পর্ব ০৫-এ আপনি এর উত্তরের বীজ দেখেছেন: Python-এ নাম হলো লেবেল, বাক্স নয়। এই পর্বে সেই বীজ থেকে পুরো গাছ জন্মাবে। কারণ “দুটি নাম একই object দেখাচ্ছে” — এই একটি তথ্য না জানার কারণে Python-এর সবচেয়ে বেশি “রহস্যময়” বাগ তৈরি হয়। নতুনদের কোডে, অভিজ্ঞদের কোডে, আর AI-এর লেখা কোডেও।

মূল ধারণা

Assignment কখনো কপি করে না। b = a, ফাংশনে argument পাঠানো, list-এ কিছু রাখা, ফাংশন থেকে কিছু ফেরত দেওয়া — সব ক্ষেত্রে শুধু একটি নতুন reference তৈরি হয়, object নয়। এটা ক্ষতিকর কি না, তা নির্ভর করে একটিমাত্র প্রশ্নের ওপর: object-টি কি বদলানো যায়? যায় না হলে ভাগাভাগি সম্পূর্ণ নিরাপদ। যায় হলে, একটি নাম দিয়ে করা বদল অন্য সব নাম দিয়েও দেখা যাবে।

= মানে “এই লেবেলটি ওই object-এ লাগাও”
কপি চাইলে কপি করতে বলতে হয় — স্পষ্ট করে

একটি পরিবারের সবাই মিলে একটিই Google Sheet ব্যবহার করে বাজারের হিসাব রাখে। মা লিংকটি বাবাকে পাঠালেন, বাবা পাঠালেন বড় ছেলেকে। তিনজনের ফোনে তিনটি লিংক, কিন্তু শিট একটাই। ছেলে একটি ঘরে “ইলিশ — ১২০০” লিখলে মা-বাবাও সেটা দেখবেন। লিংক পাঠানো হলো assignment. আলাদা শিট চাইলে “Make a copy” চাপতে হয় — নিজে থেকে কপি হয় না। Python-এর b = a হলো লিংক পাঠানো, b = a.copy() হলো “Make a copy”.

২ · Mutable আর immutable — কোন object বদলানো যায়

Python-এর প্রতিটি object তৈরির মুহূর্তেই একটি স্থায়ী বৈশিষ্ট্য নিয়ে জন্মায়: তার ভেতরের মান পরে বদলানো যাবে কি না। যে object বদলানো যায় তাকে বলে mutable, যে যায় না তাকে immutable. এটা object-এর type ঠিক করে, আপনি নন।

Typeবদলানো যায়?বদলাতে চাইলে কী হয়
int, float, bool, complexনা (immutable)n += 1 নতুন সংখ্যা-object বানিয়ে নাম সরায়
strনাs[0] = "x" → TypeError; s.upper() নতুন string ফেরত দেয়
tupleনা (তবে ভেতরে mutable জিনিস থাকতে পারে)t[0] = 5 → TypeError
frozenset, bytes, Noneনাযোগ-বিয়োগের কোনো method-ই নেই
listহ্যাঁ (mutable)append, remove, sort, x[i] = ... — একই object বদলায়
dictহ্যাঁd[key] = ..., update, pop
set, bytearrayহ্যাঁadd, discard
নিজের বানানো class-এর object (পর্ব ২৯)সাধারণত হ্যাঁobj.name = ... দিয়ে attribute বদলানো যায়

একটি ভুল ধারণা শুরুতেই ভেঙে দিই। “String immutable” মানে এই নয় যে name নামটি আর অন্য কিছুতে লাগানো যাবে না। name = "রহিম", তারপর name = "করিম" — সম্পূর্ণ বৈধ। এখানে “রহিম” object বদলায়নি; লেবেলটি খুলে “করিম” নামের নতুন object-এ লাগানো হয়েছে। Immutability হলো object-এর বৈশিষ্ট্য, নামের নয়। নাম সবসময় সরানো যায়।

এখন বুঝুন কেন এই পার্থক্য এত গুরুত্বপূর্ণ। ধরুন a = 10 আর b = a. দুটি নাম একই 10-কে দেখাচ্ছে। এবার b += 1. যেহেতু 10 নিজে বদলাতে পারে না, Python একটি নতুন 11 বানিয়ে শুধু b-কে সেখানে সরায়। a তখনো 10. তাই সংখ্যা বা string নিয়ে কাজ করার সময় কেউ কখনো “alias বাগ” টের পায় না — immutable object ভাগ করা সম্পূর্ণ নিরাপদ, যেন ছাপানো বইয়ের একই পাতা দুজন পড়ছে; কেউ পাতা বদলাতে পারে না।

Immutability অগভীর। একটি tuple নিজে বদলায় না — তার ঘরগুলো সবসময় একই object দেখায়। কিন্তু সেই object যদি list হয়, list-টির ভেতর বদলানো যায়: t = ([1, 2], "ক"), তারপর t[0].append(3) চলবে, t হবে ([1, 2, 3], "ক"). Tuple কথা রেখেছে — তার প্রথম ঘর এখনো সেই একই list-কে দেখাচ্ছে। এ কারণেই ভেতরে list থাকা tuple hashable নয়, dict-এর চাবি হতে পারে না (পর্ব ১৬)।

৩ · নাম সরানো, নাকি object বদলানো — দুই ধরনের কাজ

Python-এ ডেটার ওপর যত কাজ করেন, সবই দুই দলের কোনো একটিতে পড়ে। এই দুটি আলাদা করে চিনতে পারলে এই পর্বের প্রায় সব রহস্য খুলে যায়।

🏷️ Rebinding — নাম সরানো

লেবেল খুলে অন্য object-এ লাগানো। পুরোনো object যেমন ছিল তেমনই থাকে; একই object-এর অন্য নামগুলো কিছুই টের পায় না। চেনার উপায়: বাম পাশে শুধু একটি নাম, তারপর =.

x = x + [1] · x = []

✏️ Mutation — object বদলানো

লেবেল যেখানে ছিল সেখানেই; বদলায় object-এর ভেতরটা। ওই object-কে যত নাম দেখায়, সবাই বদলটি দেখবে। চেনার উপায়: method ডাকা, x[i] = , x.attr = , del x[i].

x.append(1) · x[0] = 9 · x.sort()

এই পার্থক্যের নাম দিই: প্রথমটি rebinding, দ্বিতীয়টি mutation. নিচের stepper-এ দুটোই একসাথে দেখুন। প্রতিটি ধাপে প্রশ্ন করুন: “লেবেল সরল, নাকি বাক্সের ভেতরটা বদলাল?”

1a = [1, 2, 3]2b = a3b.append(4)4b += [5]5b = b + [6]6print(a, b)
নাম → object
a
b
list #1
list #2
  1. একটি list, একটি নাম। [1, 2, 3] একটি নতুন list object (list #1) বানাল, a তার গায়ে লাগল।
  2. b = a — alias তৈরি। কোনো কপি নয়। b হলো list #1-এর দ্বিতীয় নাম, অর্থাৎ alias. এখনো object মাত্র একটি।
  3. b.append(4) — mutation. লেবেল সরেনি, list #1-এর ভেতরে 4 যোগ হলো। a দিয়ে দেখলেও 4 দেখবেন।
  4. b += [5] — এটাও mutation! অনেকে ভাবেন += মানে b = b + [5]. List-এর ক্ষেত্রে তা নয়। List-এর += পুরোনো object-কেই জায়গায় বাড়ায় (ভেতরে extend-এর মতো), তারপর b-কে সেই একই object-এ আবার লাগায়। ফলে a-ও 5 পেল।
  5. b = b + [6] — rebinding. ডান পাশের + সবসময় একটি নতুন list বানায় (list #2), পুরোনো দুটি list থেকে উপাদান নিয়ে। তারপর b লেবেলটি খুলে list #2-তে লাগানো হলো। list #1 অক্ষত, a সেখানেই।
  6. ফল: a পেল 4 আর 5, কিন্তু 6 নয়। একই দেখতে তিনটি লাইনের দুটি mutation, একটি rebinding. এই একটি stepper মনে রাখলে “আমার list নিজে নিজে বদলে গেল” ধরনের বেশিরভাগ বাগ নিজেই ধরতে পারবেন।

+=-এর দ্বৈত চরিত্র

x += y একটি augmented assignment. Python এটি চালানোর সময় প্রথমে জিজ্ঞেস করে: “x-এর type কি জায়গাতেই যোগ করতে জানে?” এই প্রশ্নের উত্তর দেয় __iadd__ নামের একটি বিশেষ method (দুই পাশে দুটি আন্ডারস্কোর থাকে বলে এদের বলে dunder method; বিস্তারিত পর্ব ৩০-এ)। List-এর এই method আছে, তাই list জায়গাতেই বাড়ে — একে বলে in-place বদল। Int, str, tuple-এর এই method নেই, কারণ তারা বদলাতেই পারে না। তখন Python সাধারণ + দিয়ে নতুন object বানায় আর নামটি সরায়।

কোডx-এর typeকী ঘটেx-এর অন্য alias বদল দেখবে?
x += [5]listএকই object বাড়ে (mutation)হ্যাঁ
x += 1intনতুন int, নাম সরে (rebinding)না
x += "!"strনতুন string, নাম সরেনা
x += (5,)tupleনতুন tuple, নাম সরেনা
x |= {"ক"}setএকই set বাড়েহ্যাঁ

শেষ সারির আগের সারিটি খেয়াল করুন। t = (1, 2), u = t, তারপর t += (3,) — এখন t হলো (1, 2, 3) কিন্তু u এখনো (1, 2). Tuple বদলায়নি, t নামটি নতুন tuple-এ সরে গেছে। আরেকটি ছোট পার্থক্য: list-এর += যেকোনো iterable নেয়, + নেয় না। x += "ab" লিখলে list-এ 'a' আর 'b' আলাদা উপাদান হয়ে যোগ হয়, কিন্তু x + "ab" দেয় TypeError.

যে method বদলায়, সে None ফেরত দেয়

Python-এর built-in type-গুলো একটি সুন্দর নিয়ম মানে: যে method object-কে জায়গাতেই বদলায় (list.sort, list.append, list.reverse, dict.update), সে কিছু ফেরত দেয় না — অর্থাৎ None. আর যে কাজ নতুন object বানায়, সে সেটি ফেরত দেয় (sorted(x), reversed(x), s.upper()). নিয়মটি ইচ্ছাকৃত, যাতে কোডে দেখেই বোঝা যায় কোনটি বদলাচ্ছে। এ কারণেই marks = marks.sort() একটি বিখ্যাত ভুল — marks হয়ে যায় None. হয় marks.sort() (জায়গায় সাজাও), নয়তো ranked = sorted(marks) (নতুন সাজানো list নাও, আসলটি অক্ষত রাখো)।

৪ · ফাংশনে list পাঠালে — call by sharing

পর্ব ১৩-এ আপনি ফাংশনে argument পাঠাতে শিখেছেন। এখন সেই কাজটির ভেতরটা দেখুন। ফাংশন ডাকার সময় Python যা করে, তা আসলে একটি assignment: ফাংশনের parameter-এর নামটি সেই একই object-এ লাগিয়ে দেয়, যা আপনি পাঠিয়েছেন। কোনো কপি হয় না। ফলে ফাংশনের ভেতরের নাম আর বাইরের নাম একই object-এর দুটি alias.

1def add_bonus(scores):2    scores.append(5)3    scores = [0]4    return scores5mine = [10, 20]6out = add_bonus(mine)7print(mine, out)
নাম → object
mine
scores (ফাংশনের ভেতরে)
out
list #1
list #2
  1. Caller-এর list. mine একটি list (list #1) দেখাচ্ছে। এটিই “আপনার” ডেটা।
  2. ফাংশন ডাকা = একটি assignment. add_bonus(mine) চলার মুহূর্তে কার্যত scores = mine ঘটে। ফাংশনের ভেতরের scores আর বাইরের mine — একই list-এর দুটি নাম।
  3. scores.append(5) — বাইরে পৌঁছে গেল। এটি mutation, তাই list #1 নিজেই বদলাল। ফাংশন শেষ হওয়ার আগেই mine-এর ভেতরে 5 ঢুকে গেছে।
  4. scores = [0] — বাইরে পৌঁছায় না। এটি rebinding: ফাংশনের ভেতরের লেবেলটি খুলে একটি নতুন list-এ লাগানো হলো। mine এর কিছুই জানে না। ফাংশন বাইরের নামটিকে সরাতে পারে না — শুধু object বদলাতে পারে।
  5. return scores. ফেরত গেল list #2-এর reference, আর out সেখানে লাগল। ফাংশনের ভেতরের scores নামটি ফাংশন শেষে হারিয়ে গেল।
  6. ফল: [10, 20, 5] [0]. ফাংশনের প্রথম লাইন আপনার ডেটা বদলেছে, দ্বিতীয় লাইন বদলায়নি। দুটি নিয়ম মনে রাখুন: ভেতরে mutation বাইরেও দেখা যায়; ভেতরে rebinding বাইরে দেখা যায় না।

এই আচরণের একটি নাম আছে: call by sharing. Python-এর অফিসিয়াল টিউটোরিয়াল (পাদটীকায়) বলে, একে “call by object reference” বলাই বেশি ঠিক — argument হিসেবে যায় object-এর reference, object-এর কপি নয়। এটা C-র মতো “call by value” নয় (তাহলে ফাংশন একটি কপি পেত, আসলটি বদলাতেই পারত না), আবার C++-এর int&-এর মতো “call by reference”-ও নয় (তাহলে ভেতরের scores = [0] বাইরের mine-কেও সরিয়ে দিত)। দুটির মাঝামাঝি একটি তৃতীয় ব্যবস্থা — ভাবনার প্রশ্ন ১-এ এর ইতিহাস দেখবেন।

যে ফাংশন বাজারের তালিকা “পরিষ্কার” করতে গিয়ে চমকে দেয়

বাস্তবে এটি কেমন দেখায়, নিচের কোডে দেখুন। শেষ অংশে একটি ফাংশন বাজারের তালিকা থেকে খালি ঘর সরিয়ে “পরিষ্কার” তালিকা ফেরত দেয়। যিনি ফাংশনটি ডাকছেন, তিনি ভাবছেন তাঁর আসল তালিকা অক্ষত থাকবে। চালিয়ে দেখুন:

marks = [72, 38, 55]
copy_name = marks            # কপি নয় — দ্বিতীয় নাম
copy_name.append(90)
print(marks, copy_name is marks)

nums = [1, 2]
before = nums
nums += [3]                  # একই object বদলায়
print(before, nums is before)
nums = nums + [4]            # নতুন object, নাম সরে যায়
print(before, nums is before)

def clean(items):
    while "" in items:
        items.remove("")     # caller-এর list-ই বদলাচ্ছে
    return items

bazar = ["চাল", "", "ডাল", ""]
result = clean(bazar)
print(bazar, result is bazar)

প্রথম লাইন ছাপে [72, 38, 55, 90] আর True — copy_name নামটি কোনো কপি নয়, তাই marks-ও 90 পেল। দ্বিতীয় অংশে nums += [3]-এর পর before হলো [1, 2, 3] আর is এখনো True: একই object. কিন্তু nums = nums + [4]-এর পর before সেই [1, 2, 3]-ই রইল, আর is হলো False: nums এখন নতুন object দেখায়। শেষ লাইন ['চাল', 'ডাল'] True — ফাংশন caller-এর bazar-কেই বদলে দিয়েছে, আর যা “ফেরত” দিয়েছে তা-ও সেই একই list. Caller যদি পরে আসল তালিকাটি (খালি ঘরসহ, হয়তো সারির হিসাব মেলাতে) ব্যবহার করতে চান, সেটি আর নেই। খেয়াল করুন, আমরা কোথাও id()-এর সংখ্যা ছাপিনি — “একই object কি না” জানতে is-ই যথেষ্ট আর নির্ভরযোগ্য।

ঠিক করার দুটি পরিষ্কার পথ আছে, আর একটি বেছে নিয়ে ফাংশনের নাম ও docstring-এ তা স্পষ্ট করা উচিত। পথ এক — নতুন list ফেরত দিন: return [x for x in items if x != ""]. আসলটি অক্ষত থাকে; comprehension (পর্ব ১৮) এখানে স্বাভাবিক পছন্দ। পথ দুই — জায়গায় বদলান, কিন্তু কিছু ফেরত দেবেন না: ফাংশনটি None ফেরত দিক আর নাম হোক remove_blanks_inplace. Built-in-এর sorted বনাম list.sort-এর মতোই। সবচেয়ে খারাপ হলো দুটোর মিশ্রণ — বদলাও এবং ফেরতও দাও — কারণ তখন caller ধরে নেয় সে একটি নতুন জিনিস পেয়েছে। ফাংশনের বাইরের ডেটা বদলানো একটি side effect, আর লুকানো side effect-ই সবচেয়ে বেশি সময় নষ্ট করে।

ভেতরের list ফেরত দেওয়ার বিপদ

উল্টো দিকেও একই বিপদ। ধরুন একটি module-এ ভর্তি হওয়া শিক্ষার্থীদের একটি list আছে, _students, আর একটি ফাংশন get_students() সেটি ফেরত দেয়। কেউ ফেরত পাওয়া list নিয়ে .sort() করল বা একটি নাম .remove() করল — আপনার module-এর ভেতরের তালিকাও বদলে গেল, কারণ আপনি ভেতরের object-টিরই reference দিয়েছেন। ভেতরের অবস্থা বাইরে দিতে হলে কপি (list(_students)) বা tuple (tuple(_students)) দিন। অংশ ৮-এ এই কৌশলের পুরো নাম দেখবেন — defensive copy.

আরেকটি ফাঁদের ঝলক — mutable default argument. def add_item(item, bag=[]) লিখলে মনে হয় প্রতিবার ডাকলে একটি খালি bag পাবেন। আসলে [] object-টি তৈরি হয় মাত্র একবার — ফাংশনটি সংজ্ঞায়িত হওয়ার সময় — আর প্রতিটি ডাক সেই একই list ভাগ করে নেয়। তাই দ্বিতীয়বার ডাকলে প্রথমবারের জিনিসও ব্যাগে থেকে যায়। এটিও এই পর্বেরই কাহিনি: একাধিক ডাক একই mutable object-এর alias. কেন এমন হয়, আর bag=None দিয়ে কীভাবে ঠিক করতে হয়, তা পুরোপুরি দেখবেন পরের পর্বে (পর্ব ২০)।

৫ · Shallow copy — বাইরের বাক্স নতুন, ভেতরের জিনিস পুরোনো

তাহলে কপি চাইলে কী লিখবেন? Python-এ কপি বানানোর অনেক পথ আছে, আর এদের বেশিরভাগই একই জিনিস করে: shallow copy. অর্থাৎ বাইরের container (list, dict, set) নতুন তৈরি হয়, কিন্তু তার ঘরগুলোতে পুরোনো container-এর একই object-গুলোর reference বসে।

ContainerShallow copy-র উপায়মন্তব্য
lista.copy(), a[:], list(a), [*a]চারটিই একই ফল; a.copy() পড়তে সবচেয়ে স্পষ্ট
dictd.copy(), dict(d), {**d}চাবি আর মানের object ভাগাভাগি থাকে
sets.copy(), set(s)set-এর উপাদান immutable হয় বলে এখানে সাধারণত সমস্যা নেই
যেকোনো objectcopy.copy(x)copy module-এর সাধারণ পথ

সমতল (flat) list-এর জন্য shallow copy-ই পুরো কপি। prices = [40, 25, 60]-এর কপিতে একটি দাম বদলালে আসলটি বদলায় না, কারণ copy[0] = 45 হলো কপি-list-এর একটি ঘরে rebinding, আর ভেতরের সংখ্যাগুলো immutable. সমস্যা শুরু হয় যখন ভেতরের জিনিসগুলো নিজেরাই mutable — অর্থাৎ nested list, list-এর ভেতরে dict, dict-এর ভেতরে list.

is আর == দিয়ে ভাগাভাগি পরীক্ষা

পর্ব ০৮-এ শিখেছেন — == জিজ্ঞেস করে “মান কি সমান?”, আর is জিজ্ঞেস করে “একই object কি?” কপির গভীরতা মাপতে এই দুটি একসাথে ব্যবহার করুন। b = a.copy()-এর পর b == a হবে True (মান এক), b is a হবে False (বাইরের বাক্স আলাদা)। কিন্তু a যদি list-এর list হয়, তাহলে b[0] is a[0] হবে True — ভেতরের বাক্স একই। এই একটি লাইন দিয়েই যেকোনো কপি কত গভীর, নিজে পরীক্ষা করতে পারবেন।

এবার শুরুর নম্বরপত্রে ফিরি। এবার শিক্ষক সাবধান — trial = sheet.copy() লিখেছেন। তবু কেন আসলটা বদলায়, আর deepcopy কীভাবে সমাধান দেয়, দেখুন:

1sheet = [[45, 38], [70, 66]]2trial = sheet.copy()3trial[0][1] += 54trial.append([50, 50])5deep = copy.deepcopy(sheet)6deep[1][0] = 0
বাইরের বাক্স আর ভেতরের বাক্স
sheet →
trial →
deep →
A (রহিম)
B (সুমি)
A2, B2 (নতুন)
  1. দুই স্তরের ডেটা। sheet একটি বাইরের list, যার দুটি ঘর দুটি ভেতরের list-কে দেখায় — A (রহিমের নম্বর) আর B (সুমির নম্বর)। মোট তিনটি list object.
  2. sheet.copy() — শুধু বাইরের বাক্স নতুন। নতুন বাইরের list (#2) তৈরি হলো, কিন্তু তার ঘরে বসল সেই একই A আর B-এর reference. এখন trial is sheet → False, কিন্তু trial[0] is sheet[0] → True.
  3. ভেতরে বদল — দুই দিকেই দেখা যায়। trial[0] আসলে A. তার দ্বিতীয় ঘরে 38 থেকে 43 হলো। A ভাগাভাগি, তাই sheet[0]-ও এখন [45, 43]. শিক্ষকের বাগ ঠিক এটিই — “কপি” করার পরেও আসল নম্বর বদলে গেল।
  4. বাইরে বদল — শুধু কপিতে। trial.append(...) বাইরের list #2-কে বদলায়, যা শুধু trial-এর। sheet-এ এখনো দুটি সারি। Shallow copy বাইরের স্তরকে রক্ষা করে, ভেতরের স্তরকে করে না।
  5. copy.deepcopy — সব স্তর নতুন। Deep copy বাইরের list-এর সাথে সাথে ভেতরে ঢুকে প্রতিটি mutable object-এরও নতুন কপি বানায় (A2, B2). সংখ্যাগুলো immutable, তাই সেগুলো নতুন করে বানানোর দরকার হয় না।
  6. এবার সম্পূর্ণ স্বাধীন। deep[1][0] = 0 শুধু B2 বদলায়; sheet-এর B তখনো [70, 66]. “যদি” হিসাব নিরাপদে করতে এই স্বাধীনতাই দরকার ছিল।

ধরুন একটি অফিসে একটি ফাইল-কেবিনেট আছে, যার প্রতিটি ড্রয়ারের সামনে একটি কাগজে লেখা “কোন আলমারির কোন তাকে কোন ফাইল”। Shallow copy মানে সেই তালিকার ফটোকপি করা। নতুন তালিকায় নতুন লাইন লিখলে পুরোনো তালিকা বদলায় না। কিন্তু দুটি তালিকাই একই আলমারির একই ফাইল দেখায় — কেউ একটি ফাইলে পাতা ছিঁড়লে দুই তালিকা ধরে খুঁজলেই ছেঁড়া পাতা পাবেন। Deep copy মানে প্রতিটি ফাইলেরও ফটোকপি করে নতুন আলমারিতে রাখা। ঝামেলা বেশি, খরচ বেশি, কিন্তু তখন দুই সেট পুরোপুরি আলাদা।

৬ · [[0]*3]*3 — সিট-প্ল্যানের ফাঁদ

পরীক্ষার হলে সিট-প্ল্যান বানানোর একটি প্রোগ্রাম লিখছেন। ৩টি সারি, প্রতি সারিতে ৩টি বেঞ্চ, শুরুতে সব খালি। পর্ব ১৪-এ শিখেছেন [0] * 3 দেয় [0, 0, 0]. তাহলে [[0] * 3] * 3 নিশ্চয়ই তিনটি শূন্যের সারি দেবে? ছাপালে তা-ই দেখায়: [[0, 0, 0], [0, 0, 0], [0, 0, 0]]. কিন্তু প্রথম সারির প্রথম বেঞ্চে রাফিকে বসালেই তিন সারির প্রথম বেঞ্চে রাফি!

কারণ * দিয়ে list পুনরাবৃত্তি (list repetition) উপাদানগুলোর কপি করে না — reference-গুলোর পুনরাবৃত্তি করে। [0] * 3-এ তিনটি ঘরই একই 0-কে দেখায়, কিন্তু 0 immutable, তাই কোনো সমস্যা নেই: কোনো ঘরে নতুন মান বসানো মানে সেই ঘরে rebinding. কিন্তু বাইরের * 3-এ তিনটি ঘরই একই সারি-list-কে দেখায়, আর সেটি mutable. ধাপে ধাপে দেখুন:

1grid = [[0] * 3] * 32grid[0][0] = 13print(grid)4ok = [[0] * 3 for _ in range(3)]5ok[0][0] = 1
grid-এর তিন ঘর কোন সারি দেখায়
grid[0]
grid[1]
grid[2]
সারি #1
ok
  1. ভেতরের অংশ আগে: [0] * 3. Python প্রথমে ভেতরের expression হিসাব করে, একটি সারি-list বানায় — সারি #1, [0, 0, 0]. মোট সারি-object এখন একটিই, আর এরপরেও একটিই থাকবে।
  2. বাইরের * 3 — একই reference তিনবার। [সারি #1] * 3 একটি নতুন বাইরের list বানায়, যার তিনটি ঘরেই সারি #1-এর reference. কোনো নতুন সারি তৈরি হয়নি। grid[0] is grid[2] লিখলে True পাবেন।
  3. grid[0][0] = 1 — সারি #1-এর ভেতরে বদল। grid[0] মানে সারি #1; তার প্রথম ঘরে 1 বসল। এটি সারি #1-এর mutation.
  4. ছাপালে “সব সারি” বদলানো। Python তিনটি ঘর ধরে তিনবার সারি #1 ছাপায়, তাই তিনবারই [1, 0, 0]. আসলে “সব সারি” বদলায়নি — সারি একটিই ছিল।
  5. সমাধান: comprehension. [[0] * 3 for _ in range(3)]-এ ভেতরের [0] * 3 expression লুপের প্রতিটি ঘূর্ণনে নতুন করে চলে, তাই তিনটি আলাদা সারি-object তৈরি হয়। পর্ব ১৮-এর comprehension এখানে শুধু ছোট লেখার সুবিধা নয়, সঠিকতার জন্য দরকারি।
  6. এবার শুধু একটি সারি বদলায়। ok[0][0] = 1 শুধু প্রথম সারি বদলায়। গ্রিড, ম্যাট্রিক্স, দাবার বোর্ড, Excel-এর মতো টেবিল — যেকোনো দুই-মাত্রার ডেটা বানানোর সময় এই নিয়ম মনে রাখুন।

একই পরিবারের আরও দুটি ফাঁদ চিনে রাখুন। [[]] * 4 চারটি খালি list নয়, একটি খালি list চারবার। আর dict.fromkeys(names, []) প্রতিটি নামের জন্য একই একটি list বসায় — কোনো একজনের তালিকায় কিছু যোগ করলে সবার তালিকায় যোগ হয়। সমাধান একই: comprehension দিয়ে প্রতিবার নতুন object — {name: [] for name in names}.

গেম বোর্ডের কপি

টিক-ট্যাক-টো বা লুডোর মতো খেলায় কম্পিউটার-চালিত খেলোয়াড় সাধারণত প্রতিটি সম্ভাব্য চাল একটি কপি-বোর্ডে খেলে দেখে, তারপর সবচেয়ে ভালোটি বেছে নেয়। বোর্ড যদি list-এর list হয় আর কপি হয় board.copy() দিয়ে, তাহলে “পরীক্ষামূলক” চালগুলো আসল বোর্ডেই বসে যায় — খেলা শুরুর আগেই বোর্ড ভরে যায়। এখানে দুই স্তরের কপিই যথেষ্ট: [row[:] for row in board] — প্রতিটি সারির shallow copy, ফলে বাইরে-ভেতরে দুটোই নতুন। বোর্ডের ঘরে যদি আরও গভীর mutable জিনিস (যেমন প্রতিটি ঘরে গুটির list) থাকে, তখন deepcopy.

এবার সব একসাথে চালিয়ে দেখুন — নম্বরপত্রের shallow কপি, deepcopy দিয়ে নিরাপদ কপি, আর সিট-প্ল্যানের ফাঁদ ও তার সমাধান:

import copy

sheet = [["রহিম", [45, 38]], ["সুমি", [70, 66]]]
what_if = sheet.copy()             # shallow copy
what_if[0][1][1] += 5              # রহিমকে ৫ গ্রেস নম্বর
print(sheet[0][1])                 # আসলটাও বদলে গেছে!
print(what_if is sheet, what_if[0] is sheet[0])

safe = copy.deepcopy(sheet)        # পুরো গভীর কপি
safe[1][1][0] += 5
print(sheet[1][1], safe[1][1], safe[1] is sheet[1])

seats = [["খালি"] * 3] * 2        # একই সারি দুবার!
seats[0][0] = "রাফি"
print(seats, seats[0] is seats[1])

seats = [["খালি"] * 3 for _ in range(2)]
seats[0][0] = "রাফি"
print(seats)

প্রথম লাইন [45, 43] — what_if shallow copy, তাই রহিমের নম্বরের list ভাগাভাগি, আর গ্রেস নম্বর আসল নম্বরপত্রে ঢুকে গেছে। দ্বিতীয় লাইন False True প্রমাণ দেয়: বাইরের list আলাদা, কিন্তু প্রথম শিক্ষার্থীর ভেতরের list একই। তৃতীয় লাইন [70, 66] [75, 66] False — deepcopy-র পর কপিতে সুমির নম্বর বাড়ালেও আসলটি অক্ষত, আর ভেতরের list-ও আলাদা object. চতুর্থ লাইনে দুই সারিতেই “রাফি”, আর is দেখায় True — একই সারি দুবার। শেষ লাইনে comprehension দিয়ে বানানো আলাদা সারি, তাই রাফি শুধু প্রথম সারিতে। লক্ষ করুন, "খালি" string-গুলো সব সারিতে একই object হলেও কোনো সমস্যা নেই, কারণ string immutable.

৭ · deepcopy — ভেতরে কী করে, কত খরচ, কখন দরকার

copy.deepcopy(x) একটি recursive কাজ: x-এর প্রতিটি উপাদানের জন্য আবার deepcopy ডাকে, তার উপাদানের জন্য আবার — যতক্ষণ না শুধু immutable জিনিস পায়। তিনটি সূক্ষ্ম বিষয় একে “সব কিছু কপি করো” থেকে বুদ্ধিমান করে তোলে।

  • Immutable জিনিস কপি হয় না। সংখ্যা, string, None-এর মতো object হুবহু ফেরত আসে — নতুন বানানোর কোনো মানে নেই, কারণ এরা বদলাতে পারে না। ভেতরে শুধু immutable জিনিস থাকা tuple-ও একই tuple হিসেবে ফেরত আসে; copy.deepcopy((1, "ক")) is মূল tuple — True.
  • Memo — “এটা আগে কপি করেছি কি?” কাজ শুরুর সময় deepcopy একটি dict বানায়, যার নাম memo: মূল object-এর identity থেকে তার কপি পর্যন্ত। প্রতিটি object কপি করার আগে memo দেখে নেয়। ফলে মূল ডেটায় একই list দুই জায়গায় থাকলে কপিতেও একটিই নতুন list দুই জায়গায় থাকে — ভাগাভাগির গঠন অটুট থাকে।
  • Cycle-এ আটকে যায় না। পর্ব ০৫-এর reference cycle মনে আছে? x = [], x.append(x) — list নিজেই নিজের ভেতরে। সাধারণ recursion এখানে অনন্ত লুপে পড়ত। deepcopy নতুন খালি list বানিয়েই memo-তে লিখে রাখে, তারপর ভেতরে ঢোকে; আবার x-এর দেখা পেলে memo থেকে সেই নতুন list ফেরত দেয়। ফল: কপিটিও নিজের ভেতরে নিজেকে রাখে, মূলটিকে নয়।

খরচ

Deep copy-র সময় আর মেমরি দুটোই পুরো ডেটার আকারের সমানুপাতিক। এক লাখ শিক্ষার্থীর প্রত্যেকের নম্বরের list থাকলে deepcopy এক লাখের বেশি নতুন object বানাবে, প্রতিটির জন্য memo-তে খোঁজ নেবে, প্রতিটির type দেখে কপির নিয়ম বের করবে। বিশুদ্ধ Python-এ লেখা এই কাজ সাধারণ shallow copy-র চেয়ে অনেক গুণ ধীর। একবারের কাজে এটা কিছুই না; কিন্তু লুপের ভেতরে, প্রতিটি ধাপে বড় ডেটার deepcopy — একটি পরিচিত পারফরম্যান্স সমস্যা (পর্ব ৪৩-এ মাপতে শিখবেন)।

যা deepcopy করা যায় না

কিছু object “কপি” করার কোনো অর্থ নেই — খোলা ফাইল, নেটওয়ার্ক সংযোগ, thread-এর lock. দুটি কপি একই ফাইল-হ্যান্ডেল নিয়ে কী করবে? তাই এমন object-এর deepcopy চাইলে সাধারণত TypeError আসে (যেমন cannot pickle 'TextIOWrapper' instances). নিজের class-এ কপির নিয়ম ঠিক করে দিতে চাইলে __copy__ আর __deepcopy__ method লেখা যায়; আর Python 3.13 থেকে copy.replace() দিয়ে কিছু field বদলে একটি নতুন object বানানো যায় — dataclass-এর (পর্ব ৩২) সাথে এটি বিশেষ কাজের।

পরিস্থিতিযা যথেষ্ট
শুধু পড়বেন, বদলাবেন নাকোনো কপিই নয় — alias-ই যথেষ্ট
সমতল list/dict, ভেতরে সংখ্যা বা stringshallow copy (.copy())
দুই স্তর, শুধু ভেতরের স্তর বদলাবেন (গ্রিড, বোর্ড)[row[:] for row in grid]
অনেক স্তরের মিশ্র ডেটা (JSON থেকে আসা কনফিগ), স্বাধীনভাবে বদলাবেনcopy.deepcopy
বদলানো অংশ ছোট, ডেটা বিশালকপি না করে নতুন structure বানান — শুধু বদলানো অংশ নতুন

অনেকে deepcopy-র বদলে json.loads(json.dumps(x)) লেখেন, “দ্রুত” বলে। এটি শুধু JSON-এ রাখা যায় এমন ডেটায় কাজ করে: tuple হয়ে যায় list, set থাকলে error, int চাবি হয়ে যায় string. এটাকে সাধারণ কপির উপায় হিসেবে ব্যবহার করবেন না।

৮ · Immutability নকশার হাতিয়ার — defensive copy আর tuple-এর অদ্ভুত ঘটনা

এতক্ষণ কপি দিয়ে বাগ সারানো দেখলেন। অভিজ্ঞ প্রোগ্রামাররা আরেক ধাপ এগিয়ে ভাবেন: এমনভাবে কোড লিখি, যেন এই বাগ জন্মাতেই না পারে। দুটি কৌশল আছে।

কৌশল এক — সীমানায় কপি (defensive copy)

আপনার ফাংশন বা class যেখানে বাইরের দুনিয়ার সাথে ডেটা আদান-প্রদান করে, সেই সীমানায় কপি করুন। একে বলে defensive copy.

  • ভেতরে নেওয়ার সময়: কেউ আপনাকে একটি list দিল আর আপনি সেটি পরে ব্যবহারের জন্য জমিয়ে রাখবেন? self.items = list(items) লিখুন, self.items = items নয়। নইলে caller পরে তাঁর list বদলালে আপনার ভেতরের অবস্থাও বদলে যাবে।
  • বাইরে দেওয়ার সময়: ভেতরের list ফেরত দিতে হলে কপি বা tuple দিন — অংশ ৪-এর get_students()-এর সমাধান।
  • কপির দাম মনে রাখুন: ছোট ডেটায় defensive copy প্রায় বিনামূল্যে; বিশাল ডেটায় প্রতিবার কপি ব্যয়বহুল। তখন দ্বিতীয় কৌশল ভালো।

কৌশল দুই — বদলাতেই না দেওয়া (immutability)

যে ডেটা বদলানোর কথা নয়, তাকে শুরু থেকেই immutable type-এ রাখুন। তাহলে যত খুশি ভাগ করুন, কপির দরকারই নেই।

  • নির্দিষ্ট গঠনের রেকর্ড (একটি বিন্দুর স্থানাঙ্ক, একটি তারিখের অংশ) — tuple. পর্ব ১৫-এ দেখেছেন।
  • অনুমোদিত মানের স্থির তালিকা (যেমন বিভাগের নাম) — frozenset({"ঢাকা", "চট্টগ্রাম", "রাজশাহী"}). কেউ ভুল করে .add() করতে পারবে না।
  • dict শুধু পড়তে দিতে চাইলে — types.MappingProxyType(d) একটি read-only দৃশ্য দেয়; লেখার চেষ্টায় TypeError.
  • নতুন মান দরকার হলে পুরোনোটি বদলানোর বদলে নতুন object বানান — sorted(), comprehension, s.replace()-এর মতো। এই ধাঁচে লেখা ফাংশনকে pure function বলে (পর্ব ১৩)।

সবচেয়ে অদ্ভুত ঘটনা — error-ও হয়, বদলও হয়

এবার Python-এর বিখ্যাততম ধাঁধাগুলোর একটি। একটি tuple-এর ভেতরে একটি list. Tuple immutable, তাই t[0] += [1] লিখলে error আশা করেন — এবং error আসেও। কিন্তু তারপর t দেখুন:

>>> t = ([],)
>>> t[0] += [1]
Traceback (most recent call last):
  File "<python-input-1>", line 1, in <module>
    t[0] += [1]
    ~^^^
TypeError: 'tuple' object does not support item assignment
>>> t
([1],)

Error এসেছে, তবু list-এ 1 ঢুকে গেছে! (উপরের আউটপুট CPython 3.14-এ যাচাই করা; traceback-এর ফাইলের নাম আর ~^^^ চিহ্ন আপনার পরিবেশে একটু ভিন্ন দেখাতে পারে।) ব্যাখ্যা অংশ ৩-এর +=-এর দুই ধাপে লুকিয়ে আছে। t[0] += [1] আসলে তিনটি কাজ:

  1. t[0] পড়া — পাওয়া গেল ভেতরের list.
  2. সেই list-এর __iadd__ ডাকা — list জায়গাতেই বাড়ল, এখন [1]. এই ধাপ সফল।
  3. ফলটি আবার t[0] = ... দিয়ে বসানো — কারণ += সবসময় শেষে একটি assignment করে (int-এর মতো type-এর জন্য এটা দরকার)। কিন্তু tuple-এর ঘরে assignment নিষেধ, তাই TypeError. ততক্ষণে list বদলে গেছে।

সমাধান সহজ: ভেতরের list বদলাতে চাইলে সরাসরি তার method ডাকুন — t[0].append(1) বা t[0].extend([1]). এগুলোতে শেষের assignment নেই, তাই কোনো error নেই। আর এই ঘটনা আরেকবার মনে করায়: tuple-এর immutability অগভীর।

C/C++ জানলে

Python-এর প্রতিটি নাম অনেকটা সবসময় heap-এর কোনো object-এর দিকে তাক করা একটি pointer-এর মতো। b = a হলো C-র int *b = a; — ঠিকানা কপি হয়, ডেটা নয়; আর shallow copy হলো pointer-এর array কপি করা, যার ঘরগুলো একই ডেটা দেখায়। C++-এ উল্টো: std::vector b = a; ডিফল্টে পুরো (deep) কপি করে, আর ভাগাভাগি চাইলে reference বা pointer লিখতে হয়। const-এর কাছাকাছি ধারণা Python-এ type-এর immutability, তবে Python-এ কোনো নামকে “const” করা যায় না। Pointer-এর ছবিটা পরিষ্কার করতে C কোর্সের pointer পর্ব দেখুন।

পরীক্ষা ও ইন্টারভিউতে
  • Python-এ mutable আর immutable type-এর উদাহরণ দিন। — Immutable: int, float, str, tuple, frozenset; mutable: list, dict, set, সাধারণ class-এর object.
  • Shallow copy আর deep copy-র পার্থক্য? — Shallow শুধু বাইরের container নতুন বানায়, ভেতরের object ভাগাভাগি থাকে; deep প্রতিটি স্তরের mutable object-এর নতুন কপি বানায়।
  • [[0]*3]*3-এ grid[0][0] = 1 করলে কী হয়? — তিনটি সারির প্রথম ঘরই 1 দেখায়, কারণ তিনটি ঘর একই সারি-list-কে দেখায়।
  • Python কি call by value না call by reference? — কোনোটিই পুরোপুরি নয়; call by sharing (object reference যায়): ফাংশন object বদলাতে পারে, caller-এর নাম সরাতে পারে না।
  • t = ([],)-এ t[0] += [1] করলে? — TypeError আসে, কিন্তু list-টি ততক্ষণে বদলে গেছে, তাই t হয় ([1],).

৯ · AI-এর যুগে — ভাগাভাগি করা ডেটার বাগ ধরা

AI-এর লেখা Python কোড সাধারণত দেখতে পরিষ্কার, চলেও। কিন্তু alias-জাতীয় বাগ ঠিক সেই ধরনের, যা “চলে” — কোনো error দেয় না, শুধু ভুল ফল দেয়, অনেক সময় অনেক পরে, অন্য কোনো জায়গায়। AI টেস্ট লেখার সময়ও প্রায়ই প্রতিটি ফাংশন একবার করে ডেকে দেখে, তাই “দ্বিতীয়বার ডাকলে” বা “caller পরে আসল list ব্যবহার করলে” যে বাগ বেরোয়, তা ধরা পড়ে না।

AI-এর কোডে যা দেখবেনসমস্যাঠিক রূপ
ফাংশন argument-এর list-এ .sort(), .append(), .pop() করে তারপর ফেরত দেয়caller-এর ডেটা চুপচাপ বদলায়নতুন list বানান (sorted, comprehension) বা স্পষ্টভাবে in-place ফাংশন লিখুন
nested dict/list-এ .copy() দিয়ে “ব্যাকআপ”ভেতরের স্তর ভাগাভাগিcopy.deepcopy বা প্রতি স্তরে কপি
[[0] * n] * n দিয়ে ম্যাট্রিক্সসব সারি একই object[[0] * n for _ in range(n)]
def f(x, cache={}) বা items=[]সব ডাক একই object ভাগ করে (পর্ব ২০)None default, ভেতরে নতুন object
class-এর constructor-এ self.data = dataবাইরের list বদলালে object-এর অবস্থা বদলায়self.data = list(data)

AI-কে কোড লেখাতে বা রিভিউ করাতে নিচের কাঠামোটি কপি করে ব্যবহার করুন:

আমি Python 3.14 ব্যবহার করি। নিচের কোডটি রিভিউ করো, শুধু mutability আর aliasing-এর দৃষ্টিতে: ১. কোন কোন ফাংশন তার argument হিসেবে পাওয়া list, dict বা set বদলায়? প্রতিটির জন্য লাইন নম্বর দাও। ২. কোথাও কি shallow copy (.copy(), [:], list(), dict()) এমন nested ডেটায় ব্যবহার হয়েছে, যার ভেতরের স্তর পরে বদলানো হয়? ৩. [[...] * n] * m, [[]] * n, dict.fromkeys(..., []) বা mutable default argument আছে কি? ৪. প্রতিটি সমস্যার জন্য একটি ছোট টেস্ট লেখো, যা ফাংশন ডাকার পর আসল ডেটা অক্ষত আছে কি না যাচাই করে (is আর == দিয়ে)। ৫. ঠিক করার সময় বলো কোথায় deepcopy সত্যিই দরকার আর কোথায় সাধারণ কপি বা নতুন list বানানোই যথেষ্ট।

AI-এর কোডে mutability যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলো আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১Python কি “call by value” না “call by reference”? এই প্রশ্ন নিয়ে ইন্টারনেটে এত তর্ক কেন, আর “call by sharing” কথাটি কোথা থেকে এল?

তর্কটির মূলে আছে পুরোনো দুটি শব্দকে এমন একটি ভাষায় চাপানোর চেষ্টা, যার নকশা দুটোর কোনোটিই নয়।

  • Call by value (C-র ধারণা): ফাংশন argument-এর একটি কপি পায়। ভেতরে যা-ই করুক, caller-এর ভ্যারিয়েবল অক্ষত। Python-এ তা হলে scores.append(5) বাইরে দেখা যেত না — কিন্তু যায়।
  • Call by reference (C++-এর &, Pascal-এর var): ফাংশনের parameter হলো caller-এর ভ্যারিয়েবলেরই আরেক নাম। ভেতরে নতুন মান বসালে caller-এর ভ্যারিয়েবলও বদলায়। Python-এ তা হলে ভেতরের scores = [0] বাইরের mine-কেও সরাত — কিন্তু সরায় না।

তাই দুই পক্ষই অর্ধেক ঠিক: “mutation বাইরে দেখা যায়” দেখে কেউ বলেন reference; “rebinding বাইরে যায় না” দেখে কেউ বলেন value. আসল ব্যবস্থাটি তৃতীয় একটি — reference-টি value হিসেবে যায়. ফাংশন caller-এর মতোই একই object-এর একটি reference পায় (তাই object বদলাতে পারে), কিন্তু caller-এর নামটি পায় না (তাই নাম সরাতে পারে না)।

এই তৃতীয় ব্যবস্থার নাম দিয়েছিলেন Barbara Liskov ও তাঁর সহকর্মীরা, ১৯৭০-এর দশকের মাঝামাঝি MIT-তে তৈরি CLU ভাষার নকশায় — “call by sharing” (সাধারণত ১৯৭৪ সালের কাজ হিসেবে উল্লেখ করা হয়)। CLU-তে প্রতিটি ভ্যারিয়েবল ছিল heap-এর object-এর দিকে নির্দেশ, ঠিক Python-এর মতো। Liskov পরে ২০০৮ সালে Turing Award পান, মূলত data abstraction আর CLU-র কাজের জন্য। শব্দটি অবশ্য সর্বজনীন হয়নি। Java-র জগতে একই জিনিসকে বলা হয় “Java সবসময় pass by value — শুধু object-এর ক্ষেত্রে value-টি একটি reference”. Ruby, JavaScript-এর object-ও একইভাবে কাজ করে। Python-এর অফিসিয়াল টিউটোরিয়াল ফাংশনের অংশে লেখে, argument যায় “call by value”-তে, যেখানে value সবসময় একটি object reference — আর পাদটীকায় যোগ করে, “call by object reference” আরও ভালো বর্ণনা হতো। Programming FAQ-এ বলা আছে, Python-এ argument পাঠানো হয় “by assignment”.

শেষ কথাটিই সবচেয়ে কাজের: ফাংশন ডাকা মানে parameter-এর নামে একটি assignment. আপনি assignment বোঝেন, তাই ফাংশন-ডাকাও বোঝেন — আলাদা নিয়ম শেখার দরকার নেই। ইন্টারভিউতে প্রশ্নটি এলে শব্দ নিয়ে তর্ক না করে আচরণটি ব্যাখ্যা করুন: “mutation দেখা যায়, rebinding দেখা যায় না, কারণ argument পাঠানো একটি assignment.”

মূল উপলব্ধি: পুরোনো শ্রেণিবিভাগ নতুন নকশায় খাপ না খেলে শব্দ নিয়ে তর্ক অনন্ত হয়। আচরণের সঠিক মডেল (নাম → object, assignment = নতুন reference) শব্দের চেয়ে অনেক বেশি মূল্যবান।

প্র ০২Python-এ string আর int immutable কেন? সব কিছু mutable হলে কি আরও সুবিধা হতো না — যেমন একটি বড় string-এর একটি অক্ষর জায়গায় বদলানো?

সুবিধা কিছু হতো, কিন্তু বিনিময়ে Python-এর কয়েকটি মৌলিক ভিত্তি ভেঙে পড়ত।

  • Dict-এর চাবি আর hashing: Dict চাবির hash হিসাব করে ঠিক করে, মানটি কোন ঘরে রাখবে। চাবি যদি পরে বদলায়, তার hash বদলাবে, আর dict আর কখনো সেটি খুঁজে পাবে না — মান “হারিয়ে” যাবে। Python-এ চাবি হিসেবে সবচেয়ে বেশি ব্যবহৃত হয় string আর int; তারা immutable বলেই dict নির্ভরযোগ্য। এজন্যই list চাবি হতে পারে না, কিন্তু tuple পারে (যদি ভেতরের সব জিনিস hashable হয়)। আরও একটি সুবিধা: string-এর hash একবার হিসাব করে object-এর ভেতরে জমিয়ে রাখা হয়, পরের বার আর হিসাব করতে হয় না — বদলাতে পারলে এই ক্যাশ অর্থহীন হতো।
  • Interning আর ভাগাভাগি: CPython অনেক string আর −5 থেকে 256 পর্যন্ত ছোট int-এর একটি করে object রাখে, আর সবাই সেটিই ভাগ করে (পর্ব ০৮-এর small-int cache আর interning). ভ্যারিয়েবলের নাম, attribute-এর নাম — সব string হিসেবে ভাগাভাগি হয়, ফলে মেমরি বাঁচে আর তুলনা দ্রুত হয়। কেউ একটি ভাগ করা 1-কে 2 বানাতে পারলে পুরো প্রোগ্রামের সব 1 বদলে যেত! পুরোনো FORTRAN-এর কিছু compiler-এ সত্যিই এমন একটি কুখ্যাত সমস্যা ছিল — ফাংশনে constant পাঠিয়ে ভেতরে বদলালে প্রোগ্রামের বাকি অংশে সেই “constant”-এর মান বদলে যেত।
  • নিরাপদ ভাগাভাগি ও thread: Immutable object কখনো বদলায় না, তাই একাধিক thread একসাথে পড়লেও কোনো তালা লাগে না। পর্ব ৪১-এ free-threaded Python নিয়ে আলোচনায় এর গুরুত্ব আরও স্পষ্ট হবে।
  • যুক্তির সরলতা: greet(name) ডাকার পর name বদলে যেতে পারে কি না — string-এর ক্ষেত্রে এই চিন্তাই নেই।

দাম অবশ্য আছে। লুপে s += "x" করে বড় string বানালে তাত্ত্বিকভাবে প্রতিবার নতুন string তৈরি হয় (CPython কিছু ক্ষেত্রে ভেতরে কৌশলে এটা দ্রুত করে, কিন্তু সেটি নিশ্চয়তা নয়)। তাই অনেক টুকরো জোড়ার Pythonic উপায় হলো একটি list-এ জমিয়ে শেষে "".join(parts). জায়গায় বদলানো যায় এমন বাইট দরকার হলে আছে bytearray. Java, C#, JavaScript-ও string immutable রেখেছে প্রায় একই কারণে।

মূল উপলব্ধি: Immutability একটি সীমাবদ্ধতা মনে হলেও আসলে একটি চুক্তি — “এটা কখনো বদলাবে না” — যার ওপর hashing, ভাগাভাগি, ক্যাশ আর নিরাপদ concurrency দাঁড়িয়ে থাকে।

প্র ০৩Clojure বা Haskell-এর মতো functional ভাষায় প্রায় সব ডেটাই immutable, তবু সেগুলো বড় ডেটা নিয়ে দ্রুত কাজ করে। প্রতিবার “বদল” মানে পুরো কপি হলে তা কীভাবে সম্ভব? Python-এ এমন কিছু আছে কি?

রহস্যটির নাম persistent data structure আর structural sharing. মূল কথা: “বদলানো” রূপটি নতুন একটি object, কিন্তু সে পুরোনো object-এর বেশিরভাগ অংশ ভাগ করে নেয় — কপি করে না।

  • কীভাবে কাজ করে: Clojure-এর vector ভেতরে একটি চওড়া গাছ (প্রতি নোডে ৩২টি শাখা)। এক লাখ উপাদানের vector-এর একটি ঘর বদলাতে শুধু গোড়া থেকে সেই ঘর পর্যন্ত পথের কয়েকটি নোড নতুন বানাতে হয়; বাকি সব নোড পুরোনো আর নতুন vector দুজনেই ভাগ করে। যেহেতু কোনো নোড কখনো বদলায় না, এই ভাগাভাগি সম্পূর্ণ নিরাপদ — ঠিক আমাদের পর্বের উল্টো ছবি, যেখানে ভাগাভাগিই বিপদ ছিল। Clojure-এর map-এর ভিত্তি Phil Bagwell-এর Hash Array Mapped Trie (HAMT). Rich Hickey ২০০৭ সালে Clojure প্রকাশ করেন, আর এই ডেটা স্ট্রাকচারগুলোকে ভাষার কেন্দ্রে রাখেন।
  • সুবিধা: পুরোনো সংস্করণ সবসময় অক্ষত থাকে (তাই “persistent”) — undo, ইতিহাস, একাধিক thread-এ নির্ভয়ে ভাগ করা সহজ। ভিডিও গেমের “আগের অবস্থায় ফেরা”, টেক্সট এডিটরের undo, React/Redux-এর মতো UI লাইব্রেরির state ব্যবস্থাপনা এই চিন্তা থেকে অনুপ্রাণিত।
  • দাম: প্রতিটি পড়া বা লেখায় গাছ বেয়ে নামতে হয়, তাই একটি সাধারণ mutable array-র চেয়ে ধ্রুবক খরচ বেশি; মেমরির ব্যবহারও বেশি ছড়ানো।

Python-এ: built-in ডেটা স্ট্রাকচার mutable (list, dict, set) বা পুরো-কপির immutable (tuple, frozenset — যেখানে একটি উপাদান “যোগ” মানে পুরো নতুন tuple). Persistent স্ট্রাকচার built-in হিসেবে নেই। মজার ব্যাপার, CPython-এর ভেতরে একটি HAMT সত্যিই আছে — Python 3.7-এর contextvars module (PEP 567) সেটি ব্যবহার করে, যাতে প্রতিটি context-এর কপি সস্তা হয়; কিন্তু এটি সাধারণ ব্যবহারের জন্য প্রকাশ করা হয়নি। বাইরের library-তে (যেমন pyrsistent) persistent vector আর map পাওয়া যায়। Python-এর সংস্কৃতি সাধারণত অন্য পথ নেয়: mutable ডেটা, কিন্তু সীমানায় কপি আর পরিষ্কার মালিকানা।

মূল উপলব্ধি: “Immutable মানে ধীর, কারণ সব কপি করতে হয়” — এটি ভুল ধারণা। ভাগাভাগি বিপদ কেবল তখনই, যখন ভাগ করা জিনিস বদলানো যায়। যা বদলায় না, তা যত খুশি ভাগ করা যায় — আর সেই ভাগাভাগিই persistent structure-কে দ্রুত করে।

প্র ০৪copy.deepcopy কীভাবে নিজের ভেতরে নিজেকে রাখা list-এ অনন্ত লুপে পড়ে না? আর মূল ডেটায় একই object দুই জায়গায় থাকলে কপিতে কী হয়?

উত্তরটি একটি ছোট, চতুর dict — memo. CPython-এর copy.py (যা বিশুদ্ধ Python-এ লেখা, চাইলে নিজেই পড়তে পারেন) এর কাজ মোটামুটি এভাবে করে:

  1. deepcopy(x, memo) ডাকা হলে প্রথমে দেখে, id(x) চাবি দিয়ে memo-তে কিছু আছে কি না। থাকলে সেটিই ফেরত — নতুন কপি নয়।
  2. না থাকলে x-এর type অনুযায়ী কপির নিয়ম বেছে নেয়। Atomic immutable type (int, str, None, ফাংশন ইত্যাদি) হলে x-কেই ফেরত দেয়।
  3. List হলে একটি খালি নতুন list বানায় আর সঙ্গে সঙ্গে memo-তে লিখে রাখে: “x-এর কপি হলো এটা”। তারপরই কেবল প্রতিটি উপাদানের জন্য recursive deepcopy ডেকে নতুন list-এ যোগ করে।
  4. নিজের class-এর object-এর জন্য __deepcopy__ থাকলে সেটি ডাকে; না থাকলে pickle-এর __reduce_ex__ প্রোটোকল দিয়ে object-এর অবস্থা নিয়ে নতুন object গড়ে।

ধাপ ৩-এর ক্রমটিই cycle-এর সমাধান। x = []; x.append(x)-এর deepcopy-তে নতুন list y তৈরি হয়ে memo-তে বসে; তারপর x-এর একমাত্র উপাদানের (যা x নিজেই) কপি চাইলে memo বলে “এর কপি তো y” — তাই y-তে y-ই যোগ হয়। ফল: y[0] is y → True, কিন্তু y is x → False. কপিটি মূলের মতোই একটি চক্র, কিন্তু নিজস্ব চক্র।

একই কৌশল ভাগাভাগির গঠনও রক্ষা করে। row = [1]; data = [row, row]-এর deepcopy-তে d[0] is d[1] → True. শুধু-recursive একটি সরল কপি দুটি আলাদা list বানাত, অর্থাৎ ডেটার “আকৃতি” বদলে দিত — মূলে একটিতে বদল অন্যটিতে দেখা যেত, কপিতে যেত না। Memo নিশ্চিত করে কপি মূলের হুবহু গঠনগত প্রতিচ্ছবি।

দুটি পার্শ্ব-বিবরণ: memo চাবি হিসেবে identity ব্যবহার করে, তাই কাজ চলার সময় মূল object-গুলো মুছে গেলে একই identity নতুন object পেতে পারত — এ জন্য copy.py কপি-চলাকালীন মূল object-গুলোকে একটি বাড়তি তালিকায় ধরে রাখে (_keep_alive), যাতে তারা মুছে না যায়। আর নিজের __deepcopy__(self, memo) লিখলে সেই memo-টি পরের deepcopy ডাকে পাঠাতে হয় — নইলে cycle-এর সুরক্ষা হারাবেন। এই কৌশল নতুন নয়: graph traversal-এ “visited” set, serialization-এ (যেমন pickle) object-এর memo — সবখানেই একই চিন্তা।

মূল উপলব্ধি: গ্রাফের মতো ডেটা (যেখানে শেয়ার আর চক্র থাকতে পারে) নিরাপদে ঘোরার মূল কৌশল হলো “যা দেখেছি তা মনে রাখো” — আর “আগে নথিভুক্ত করো, তারপর ভেতরে ঢোকো” ক্রমটিই চক্রকে বশে আনে।

প্র ০৫t = ([],); t[0] += [1] error দেয়, তবু list বদলায়। Bytecode দিয়ে ধাপে ধাপে ব্যাখ্যা করুন — আর এই আচরণ কি একটি “বাগ” যা Python-এর ঠিক করা উচিত?

পর্ব ০৫-এর dis দিয়ে t[0] += [1] দেখলে উত্তর চোখের সামনে আসে। CPython 3.14-এ মূল নির্দেশগুলো (কিছু stack-সাজানোর নির্দেশ বাদ দিয়ে):

LOAD_NAME       t
LOAD_SMALL_INT  0
COPY / COPY            (t আর 0 পরে আবার লাগবে, তাই কপি রাখা)
BINARY_OP       26 ([])     → t[0] পড়া: ভেতরের list
LOAD_SMALL_INT  1
BUILD_LIST      1           → [1]
BINARY_OP       13 (+=)     → list.__iadd__: জায়গায় বাড়ে
SWAP / SWAP
STORE_SUBSCR                → t[0] = ফল  ← এখানে TypeError

পুরোনো version-এ (3.10 বা তার আগে) একই ধাপগুলোর নাম ছিল BINARY_SUBSCR, INPLACE_ADD আর STORE_SUBSCR; 3.11 থেকে গাণিতিক কাজগুলো এক BINARY_OP-এ মিশেছে, আর 3.14-এ subscript পড়াও BINARY_OP-এর একটি রূপ। নাম যা-ই হোক, গঠন একই: পড়ো → জায়গায় যোগ করো → আবার লেখো. দ্বিতীয় ধাপ list-এর ক্ষেত্রে সফল এবং স্থায়ী; তৃতীয় ধাপ tuple-এর ঘরে লিখতে গিয়ে ব্যর্থ। কোনো “লেনদেন বাতিল” (rollback) ব্যবস্থা নেই, তাই error-এর আগেই হওয়া বদল থেকে যায়।

শেষে আবার লেখা কেন দরকার? কারণ += একটিই syntax, কিন্তু type-ভেদে দুই রকম কাজ। counts[0] += 1-এ int immutable, তাই __iadd__ নেই; + দিয়ে নতুন int বানিয়ে ঘরে বসাতেই হবে — শেষের STORE_SUBSCR ছাড়া এটা কাজই করত না। Compiler আগে থেকে জানে না ঘরে কোন type থাকবে (dynamic typing), তাই সবসময় শেষে লেখার নির্দেশ রাখে। List-এর __iadd__ self ফেরত দেয়, তাই সাধারণত এই লেখা নির্দোষ — একই object আবার একই ঘরে বসে। শুধু ঘরটি যখন লেখা-নিষিদ্ধ, তখনই ধাঁধাটি দেখা দেয়।

বাগ কি? Python-এর অফিসিয়াল Programming FAQ-এ ঠিক এই উদাহরণ নিয়ে একটি প্রশ্ন আছে, আর সেখানে এটিকে augmented assignment-এর সংজ্ঞার স্বাভাবিক ফল হিসেবে ব্যাখ্যা করা হয়েছে, বাগ হিসেবে নয়। “ঠিক” করতে হলে হয় +=-কে দুই ধাপের আগে লেখার অনুমতি যাচাই করতে হতো (যা সাধারণভাবে সম্ভব নয় — অনুমতি জানা যায় শুধু লেখার চেষ্টায়), নয়তো mutable type-এর +=-কে শেষের assignment থেকে মুক্ত করতে হতো (যা অন্য অনেক কোড ভাঙত)। তাই এটি ভাষার একটি নথিভুক্ত কোণ, আর ব্যবহারিক শিক্ষা সরল: tuple-এর ভেতরের mutable object বদলাতে সরাসরি method ডাকুন।

মূল উপলব্ধি: একটি লাইনের syntax এক হলেও ভেতরে কয়েকটি স্বাধীন ধাপ থাকতে পারে, আর মাঝপথে error হলে আগের ধাপের প্রভাব থেকে যায়। “পুরোটা হয়, নয়তো কিছুই হয় না” — এই নিশ্চয়তা (atomicity) প্রোগ্রামিং ভাষা সাধারণত দেয় না; ডেটাবেসের transaction ঠিক এই কারণেই আলাদা করে তৈরি করতে হয়।

অনুশীলন

বেশিরভাগ কাজে কাগজে লেবেল-ছবি আঁকাই আসল — তারপর কোড চালিয়ে মিলিয়ে নিন।

  1. Rebind না mutate? প্রতিটি লাইনের পর a কী হবে, চালানোর আগে লিখুন: a = [1], b = a, b += [2], b = b * 2, b.append(9), print(a, b).

    ছাপা হবে [1, 2] [1, 2, 1, 2, 9]. b += [2] list-এর in-place বদল, তাই a-ও [1, 2] পায়। b = b * 2 ডান পাশে একটি নতুন list বানায় ([1, 2, 1, 2]) আর b-কে সেখানে সরায় — এখান থেকে দুটি নাম আলাদা object দেখায়। তাই পরের append(9) শুধু b-এর নতুন list বদলায়। লক্ষ করুন b *= 2 লিখলে উত্তর ভিন্ন হতো — সেটি in-place, তখন a-ও বদলাত।

  2. কপির গভীরতা মাপুন: config = {"theme": "dark", "langs": ["bn", "en"]}. c = config.copy() করার পর c["theme"] = "light" আর c["langs"].append("ar") করলে config কী হবে? is দিয়ে কোন দুটি প্রশ্ন করলে আপনার উত্তর প্রমাণ হবে?

    config হবে {"theme": "dark", "langs": ["bn", "en", "ar"]}. c["theme"] = "light" হলো কপি-dict-এর একটি ঘরে rebinding — আসল dict-এ প্রভাব নেই। কিন্তু c["langs"] আর config["langs"] একই list, তাই append দুই দিকেই দেখা যায়। প্রমাণ: c is config → False (বাইরের dict আলাদা), c["langs"] is config["langs"] → True (ভেতরের list একই)। সমাধান: copy.deepcopy(config), অথবা শুধু ওই ঘরটি আলাদা করা — c["langs"] = list(config["langs"]).

  3. ফাংশনটি নিরাপদ করুন (কোড লিখুন): নিচের ফাংশনটি caller-এর list বদলে দেয়। একে এমনভাবে লিখুন, যাতে আসল list অক্ষত থাকে আর ফলাফল একই হয়।
    def top_three(scores):
        scores.sort(reverse=True)
        return scores[:3]

    সবচেয়ে সরল সমাধান: return sorted(scores, reverse=True)[:3]. sorted() একটি নতুন list বানায়, তাই caller-এর scores-এর ক্রম অক্ষত থাকে। আরেকটি পথ: heapq.nlargest(3, scores) (standard library-র heapq module থেকে), যা পুরো list না সাজিয়েই তিনটি বড় মান দেয়। মূল ফাংশনের সমস্যা দ্বিমুখী: সে caller-এর ক্রম নষ্ট করে, আবার ফলও ফেরত দেয় — ফলে caller বুঝতেই পারেন না যে তাঁর ডেটা বদলেছে। যাচাই করতে: ডাকার আগে original = scores.copy() রাখুন, ডাকার পর scores == original দেখুন।

  4. AI অডিট: যেকোনো AI-কে বলুন: “Python-এ ৫×৫ একটি দাবার মতো বোর্ড বানাও, প্রতিটি ঘরে '.' থাকবে, তারপর একটি ফাংশন লেখো যা বোর্ডের কপিতে একটি চাল খেলে দেখে।” উত্তরে দুটি জিনিস খুঁজুন: বোর্ড কীভাবে বানানো হয়েছে, আর কপি কত গভীর। এই পর্বের চেকলিস্ট মিলিয়ে দেখুন।

    ভালো উত্তরে বোর্ড হবে [["."] * 5 for _ in range(5)], আর কপি হবে [row[:] for row in board] বা copy.deepcopy(board). খারাপ উত্তরে দেখবেন [["."] * 5] * 5 (সব সারি এক), অথবা board.copy() / board[:] দিয়ে “কপি” — যাতে পরীক্ষামূলক চাল আসল বোর্ডে বসে যায়। যাচাই: কপিতে একটি ঘর বদলে আসল বোর্ড ছাপান, আর new[0] is board[0] দেখুন — True হলে ভেতরের স্তর ভাগাভাগি। AI কোড “চলে” বলেই সঠিক নয়; এই বাগ error দেয় না, ভুল ফল দেয়।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমcopy.deepcopy একটি list কপি করার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 🔎 memo-তে দেখা — এই list আগে কপি হয়েছে কি না
  2. 📦 একটি খালি নতুন list তৈরি
  3. 📝 memo-তে লেখা: মূল list-এর কপি হলো এই নতুন list
  4. 🔁 প্রতিটি উপাদানের জন্য আবার deepcopy ডেকে নতুন list-এ যোগ
  5. ✅ সম্পূর্ণ নতুন list ফেরত দেওয়া

Python-এ a = [1, 2], b = a, তারপর b = b + [3]. এখন print(a) কী ছাপবে?

  • [1, 2, 3]
  • TypeError
  • [1, 2]
  • [1, 2, [3]]

নিচের কোনটি Python-এ immutable type?

  • tuple আর frozenset
  • list আর dict
  • set আর bytearray
  • str আর dict

grid = [[0] * 3] * 3, তারপর grid[1][2] = 7. এখন grid কী?

  • [[0, 0, 0], [0, 0, 7], [0, 0, 0]]
  • IndexError
  • [[7, 0, 0], [7, 0, 0], [7, 0, 0]]
  • [[0, 0, 7], [0, 0, 7], [0, 0, 7]]

a = [[1, 2], [3, 4]] আর b = a.copy(). নিচের কোন জোড়া ফল সঠিক?

  • b is a → True, b[0] is a[0] → True
  • b is a → False, b[0] is a[0] → True
  • b is a → False, b[0] is a[0] → False
  • b is a → True, b[0] is a[0] → False

def f(x): x.append(0); x = [9] — এই ফাংশনকে nums = [1] দিয়ে f(nums) ডাকার পর nums কী?

  • [1]
  • [9]
  • [1, 0]
  • [1, 0, 9]

Python-এ t = ([],) তারপর t[0] += [1] চালালে কী ঘটে?

  • TypeError আসে, তবু t হয়ে যায় ([1],)
  • কোনো error ছাড়া t হয় ([1],)
  • TypeError আসে, আর t অপরিবর্তিত ([],) থাকে
  • t একটি list হয়ে যায়: [[1]]

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Assignment কখনো কপি করে না — b = a, ফাংশনে argument পাঠানো, return — সবই একই object-এর নতুন reference.
  2. Immutable (int, float, str, tuple, frozenset) ভাগ করা নিরাপদ; mutable (list, dict, set, সাধারণ object) ভাগ করলে এক নামের বদল সব নামে দেখা যায়।
  3. Rebinding (x = ...) শুধু নাম সরায়; mutation (method, x[i] = , list-এর +=) object বদলায়। ফাংশন caller-এর object বদলাতে পারে, নাম সরাতে পারে না।
  4. Shallow copy (.copy(), [:], list()) শুধু বাইরের বাক্স নতুন করে; nested ডেটায় copy.deepcopy — যা memo দিয়ে শেয়ার আর cycle সামলায়, কিন্তু খরচসাপেক্ষ।
  5. [[0]*n]*m নয়, [[0]*n for _ in range(m)]; সীমানায় defensive copy, আর যা বদলানোর কথা নয় তা immutable রাখুন।

পরের পর্বে: নাম কোথায় “বাস করে” আর কতক্ষণ বাঁচে — scope আর LEGB নিয়ম, *args আর **kwargs, closure, আর আজ যার ঝলক দেখলেন সেই mutable default argument-এর ফাঁদ, পুরো ব্যাখ্যা ও সমাধানসহ।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান