মালিকানা (Ownership) কী?
Ownership হলো নিয়মের একটি সেট যা একটি রাস্ট প্রোগ্রাম কীভাবে মেমরি পরিচালনা (manage) করে তা নিয়ন্ত্রণ করে। সমস্ত প্রোগ্রামকে চলার সময় কম্পিউটারের মেমরি ব্যবহারের পদ্ধতি পরিচালনা করতে হয়। কিছু ভাষায় গার্বেজ কালেকশন (garbage collection) থাকে যা প্রোগ্রাম চলার সময় নিয়মিতভাবে অব্যবহৃত মেমরি খুঁজে বের করে; অন্য ভাষাগুলোতে, প্রোগ্রামারকে অবশ্যই স্পষ্টভাবে মেমরি বরাদ্দ (allocate) এবং মুক্ত (free) করতে হয়। রাস্ট তৃতীয় একটি পদ্ধতি ব্যবহার করে: মেমরি একটি মালিকানা সিস্টেমের (system of ownership) মাধ্যমে পরিচালিত হয়, যেখানে কম্পাইলার কিছু নিয়ম পরীক্ষা করে। যদি কোনো নিয়ম লঙ্ঘন করা হয়, প্রোগ্রামটি কম্পাইল হবে না। মালিকানার কোনো বৈশিষ্ট্যই আপনার প্রোগ্রাম চলার সময় এটিকে ধীর করবে না।
যেহেতু অনেক প্রোগ্রামারের জন্য মালিকানা একটি নতুন ধারণা, তাই এতে অভ্যস্ত হতে কিছুটা সময় লাগে। ভালো খবর হলো, আপনি রাস্ট এবং মালিকানা সিস্টেমের নিয়মগুলোর সাথে যত বেশি অভিজ্ঞ হবেন, তত সহজে আপনি স্বাভাবিকভাবেই নিরাপদ এবং কার্যকর কোড তৈরি করতে পারবেন। চেষ্টা চালিয়ে যান!
যখন আপনি মালিকানা বুঝতে পারবেন, তখন রাস্টকে স্বতন্ত্র করে তোলা বৈশিষ্ট্যগুলো বোঝার জন্য আপনার একটি শক্ত ভিত্তি তৈরি হবে। এই অধ্যায়ে, আপনি একটি খুব সাধারণ ডেটা স্ট্রাকচার—স্ট্রিং—এর উপর ভিত্তি করে কিছু উদাহরণের মাধ্যমে মালিকানা শিখবেন।
স্ট্যাক (Stack) এবং হীপ (Heap)
অনেক প্রোগ্রামিং ভাষায় আপনাকে স্ট্যাক এবং হীপ নিয়ে খুব বেশি ভাবতে হয় না। কিন্তু রাস্টের মতো একটি সিস্টেমস প্রোগ্রামিং ভাষায়, কোনো মান (value) স্ট্যাকে নাকি হীপে আছে, তা ভাষার আচরণকে প্রভাবিত করে এবং আপনাকে কেন নির্দিষ্ট সিদ্ধান্ত নিতে হবে তা নির্ধারণ করে। এই অধ্যায়ের পরে মালিকানার কিছু অংশ স্ট্যাক এবং হীপের সাথে সম্পর্কিত করে বর্ণনা করা হবে, তাই প্রস্তুতির জন্য এখানে একটি সংক্ষিপ্ত ব্যাখ্যা দেওয়া হলো।
স্ট্যাক এবং হীপ উভয়ই মেমরির অংশ যা আপনার কোড রানটাইমে ব্যবহার করতে পারে, তবে তাদের গঠন ভিন্ন। স্ট্যাক মানগুলোকে যে ক্রমে পায় সেই ক্রমে সংরক্ষণ করে এবং ঠিক তার বিপরীত ক্রমে মানগুলো সরিয়ে দেয়। একে লাস্ট ইন, ফার্স্ট আউট (last in, first out) বলা হয়। একটি প্লেটের স্ট্যাকের কথা ভাবুন: যখন আপনি আরও প্লেট যোগ করেন, তখন আপনি সেগুলোকে গাদার উপরে রাখেন, এবং যখন আপনার একটি প্লেট দরকার হয়, তখন আপনি উপর থেকে একটি তুলে নেন। মাঝখান থেকে বা নিচ থেকে প্লেট যোগ করা বা সরানো ঠিকভাবে কাজ করবে না! ডেটা যোগ করাকে বলা হয় পুশিং অনটু দ্য স্ট্যাক (pushing onto the stack), এবং ডেটা সরানোকে বলা হয় পপিং অফ দ্য স্ট্যাক (popping off the stack)। স্ট্যাকে সংরক্ষিত সমস্ত ডেটার একটি পরিচিত, নির্দিষ্ট আকার (known, fixed size) থাকতে হবে। কম্পাইলের সময় অজানা আকারের ডেটা বা যে ডেটার আকার পরিবর্তন হতে পারে, তা অবশ্যই হীপে সংরক্ষণ করতে হবে।
হীপ কম গোছানো: যখন আপনি হীপে ডেটা রাখেন, তখন আপনি নির্দিষ্ট পরিমাণ জায়গা চান। মেমরি অ্যালোকেটর (memory allocator) হীপে একটি যথেষ্ট বড় খালি জায়গা খুঁজে বের করে, এটিকে ব্যবহৃত হিসেবে চিহ্নিত করে এবং একটি পয়েন্টার (pointer) ফেরত দেয়, যা সেই অবস্থানের ঠিকানা। এই প্রক্রিয়াটিকে অ্যালোকেটিং অন দ্য হীপ (allocating on the heap) বলা হয় এবং কখনও কখনও সংক্ষেপে শুধু অ্যালোকেটিং (allocating) বলা হয় (স্ট্যাকে মান push করাকে allocating হিসাবে বিবেচনা করা হয় না)। যেহেতু হীপের পয়েন্টারটির একটি পরিচিত, নির্দিষ্ট আকার রয়েছে, তাই আপনি পয়েন্টারটি স্ট্যাকে সংরক্ষণ করতে পারেন, কিন্তু যখন আপনার আসল ডেটা প্রয়োজন হবে, তখন আপনাকে সেই পয়েন্টারটি অনুসরণ করতে হবে। একটি রেস্তোরাঁয় বসার কথা ভাবুন। যখন আপনি প্রবেশ করেন, আপনি আপনার দলের সদস্য সংখ্যা বলেন, এবং হোস্ট এমন একটি খালি টেবিল খুঁজে বের করে যেখানে সবাই বসতে পারে এবং আপনাকে সেখানে নিয়ে যায়। যদি আপনার দলের কেউ দেরিতে আসে, তবে সে আপনাকে খুঁজে বের করার জন্য জিজ্ঞাসা করতে পারে যে আপনাকে কোথায় বসানো হয়েছে।
স্ট্যাকে push করা হীপে allocate করার চেয়ে দ্রুত, কারণ অ্যালোকেটরকে নতুন ডেটা সংরক্ষণের জন্য জায়গা খুঁজতে হয় না; সেই অবস্থানটি সবসময় স্ট্যাকের শীর্ষে থাকে। তুলনামূলকভাবে, হীপে জায়গা allocate করতে বেশি কাজ করতে হয় কারণ অ্যালোকেটরকে প্রথমে ডেটা রাখার জন্য যথেষ্ট বড় একটি জায়গা খুঁজে বের করতে হবে এবং তারপরে পরবর্তী allocation-এর জন্য হিসাব রাখতে হবে।
হীপে ডেটা অ্যাক্সেস করা সাধারণত স্ট্যাকের ডেটা অ্যাক্সেস করার চেয়ে ধীর, কারণ সেখানে পৌঁছানোর জন্য আপনাকে একটি পয়েন্টার অনুসরণ করতে হয়। আধুনিক প্রসেসরগুলো দ্রুত কাজ করে যদি তারা মেমরিতে কম লাফালাফি করে। উপমাটি চালিয়ে গেলে, একটি রেস্তোরাঁর সার্ভারের কথা ভাবুন जो অনেক টেবিল থেকে অর্ডার নিচ্ছে। পরবর্তী টেবিলে যাওয়ার আগে একটি টেবিলের সমস্ত অর্ডার নেওয়া সবচেয়ে কার্যকর। টেবিল A থেকে একটি অর্ডার নেওয়া, তারপর টেবিল B থেকে একটি অর্ডার, তারপর আবার A থেকে একটি, এবং তারপর আবার B থেকে একটি নেওয়া অনেক ধীর প্রক্রিয়া হবে। একইভাবে, একটি প্রসেসর সাধারণত তার কাজ ভালোভাবে করতে পারে যদি এটি কাছাকাছি থাকা ডেটার উপর কাজ করে (যেমনটি স্ট্যাকে থাকে) বরং দূরে থাকা ডেটার (যেমনটি হীপে থাকতে পারে) চেয়ে।
যখন আপনার কোড একটি ফাংশন কল করে, তখন ফাংশনে পাস করা মানগুলো (সম্ভাব্যভাবে, হীপের ডেটার পয়েন্টার সহ) এবং ফাংশনের লোকাল ভ্যারিয়েবলগুলো স্ট্যাকে push করা হয়। ফাংশন শেষ হয়ে গেলে, সেই মানগুলো স্ট্যাক থেকে pop করা হয়।
কোডের কোন অংশ হীপের কোন ডেটা ব্যবহার করছে তার হিসাব রাখা, হীপের ডুপ্লিকেট ডেটার পরিমাণ কমানো, এবং অব্যবহৃত ডেটা পরিষ্কার করা যাতে আপনার জায়গার অভাব না হয়—এই সমস্ত সমস্যার সমাধান মালিকানা করে। একবার আপনি মালিকানা বুঝে গেলে, আপনাকে স্ট্যাক এবং হীপ নিয়ে খুব বেশি ভাবতে হবে না, তবে মালিকানার মূল উদ্দেশ্য যে হীপের ডেটা পরিচালনা করা, তা জানলে এটি কেন এভাবে কাজ করে তা বুঝতে সাহায্য করতে পারে।
মালিকানার নিয়ম (Ownership Rules)
প্রথমে, আসুন মালিকানার নিয়মগুলো দেখে নেওয়া যাক। উদাহরণগুলো নিয়ে কাজ করার সময় এই নিয়মগুলো মনে রাখবেন:
- রাস্টে প্রতিটি মানের (value) একজন মালিক (owner) থাকে।
- একবারে কেবল একজনই মালিক থাকতে পারে।
- যখন মালিক স্কোপের (scope) বাইরে চলে যায়, তখন মানটি ড্রপ (dropped) হয়ে যাবে।
ভ্যারিয়েবলের স্কোপ (Variable Scope)
এখন যেহেতু আমরা রাস্টের প্রাথমিক সিনট্যাক্স পার করে এসেছি, আমরা উদাহরণগুলোতে আর সম্পূর্ণ fn main() {
কোড অন্তর্ভুক্ত করব না। তাই, আপনি যদি অনুসরণ করেন, তবে নিশ্চিত করুন যে আপনি নিম্নলিখিত উদাহরণগুলো একটি main
ফাংশনের ভিতরে নিজে থেকেই রেখেছেন। ফলস্বরূপ, আমাদের উদাহরণগুলো আরও সংক্ষিপ্ত হবে, যা আমাদের মূল বিবরণের উপর মনোযোগ দিতে সাহায্য করবে।
মালিকানার প্রথম উদাহরণ হিসেবে, আমরা কিছু ভ্যারিয়েবলের স্কোপ (scope) দেখব। একটি স্কোপ হলো প্রোগ্রামের সেই পরিসর যার মধ্যে একটি আইটেম বৈধ (valid) থাকে। নিচের ভ্যারিয়েবলটি বিবেচনা করুন:
#![allow(unused)] fn main() { let s = "hello"; }
s
ভ্যারিয়েবলটি একটি স্ট্রিং লিটারেলকে (string literal) নির্দেশ করে, যেখানে স্ট্রিংয়ের মানটি আমাদের প্রোগ্রামের টেক্সটে হার্ডকোড করা আছে। ভ্যারিয়েবলটি যে মুহূর্তে ঘোষণা করা হয়, সেই মুহূর্ত থেকে বর্তমান স্কোপের শেষ পর্যন্ত বৈধ থাকে। তালিকা ৪-১ এমন একটি প্রোগ্রাম দেখাচ্ছে যেখানে কমেন্টের মাধ্যমে s
ভ্যারিয়েবলটি কোথায় বৈধ থাকবে তা চিহ্নিত করা হয়েছে।
fn main() { { // s is not valid here, since it's not yet declared let s = "hello"; // s is valid from this point forward // do stuff with s } // this scope is now over, and s is no longer valid }
অন্য কথায়, এখানে দুটি গুরুত্বপূর্ণ সময় রয়েছে:
- যখন
s
স্কোপের মধ্যে আসে, তখন এটি বৈধ। - এটি স্কোপের বাইরে না যাওয়া পর্যন্ত বৈধ থাকে।
এই মুহূর্তে, স্কোপ এবং ভ্যারিয়েবল কখন বৈধ থাকে, তার মধ্যে সম্পর্কটি অন্যান্য প্রোগ্রামিং ভাষার মতোই। এখন আমরা String
টাইপটি প্রবর্তন করে এই ধারণার উপর ভিত্তি করে আরও কিছু তৈরি করব।
String
টাইপ
মালিকানার নিয়মগুলো ব্যাখ্যা করার জন্য, আমাদের এমন একটি ডেটা টাইপ প্রয়োজন যা অধ্যায় ৩-এর "ডেটা টাইপস" বিভাগে আলোচনা করা টাইপগুলোর চেয়ে বেশি জটিল। পূর্বে আলোচনা করা টাইপগুলোর আকার নির্দিষ্ট থাকে, এগুলো স্ট্যাকে সংরক্ষণ করা যায় এবং স্কোপ শেষ হলে স্ট্যাক থেকে পপ করা যায়, এবং কোডের অন্য কোনো অংশে একই মান ভিন্ন স্কোপে ব্যবহার করার প্রয়োজন হলে দ্রুত ও সহজভাবে একটি নতুন, স্বাধীন ইনস্ট্যান্স তৈরি করা যায়। কিন্তু আমরা এমন ডেটা দেখতে চাই যা হীপে সংরক্ষিত হয় এবং রাস্ট কীভাবে সেই ডেটা পরিষ্কার করার সময় জানে তা অন্বেষণ করতে চাই, এবং String
টাইপটি এর একটি চমৎকার উদাহরণ।
আমরা String
-এর সেই অংশগুলোর উপর মনোযোগ দেব যা মালিকানার সাথে সম্পর্কিত। এই দিকগুলো অন্যান্য জটিল ডেটা টাইপের ক্ষেত্রেও প্রযোজ্য, তা স্ট্যান্ডার্ড লাইব্রেরি দ্বারা সরবরাহ করা হোক বা আপনার নিজের তৈরি করা হোক। আমরা অধ্যায় ৮-এ String
নিয়ে আরও গভীরভাবে আলোচনা করব।
আমরা ইতিমধ্যে স্ট্রিং লিটারেল দেখেছি, যেখানে একটি স্ট্রিং মান আমাদের প্রোগ্রামে হার্ডকোড করা থাকে। স্ট্রিং লিটারেলগুলো সুবিধাজনক, কিন্তু আমরা যে সমস্ত পরিস্থিতিতে টেক্সট ব্যবহার করতে চাই তার জন্য উপযুক্ত নয়। একটি কারণ হলো সেগুলো অপরিবর্তনীয় (immutable)। আরেকটি কারণ হলো, কোড লেখার সময় প্রতিটি স্ট্রিংয়ের মান জানা সম্ভব নাও হতে পারে: উদাহরণস্বরূপ, যদি আমরা ব্যবহারকারীর ইনপুট নিয়ে তা সংরক্ষণ করতে চাই? এই ধরনের পরিস্থিতির জন্য, রাস্টের দ্বিতীয় একটি স্ট্রিং টাইপ আছে, String
। এই টাইপটি হীপে বরাদ্দ করা ডেটা পরিচালনা করে এবং তাই কম্পাইলের সময় অজানা পরিমাণ টেক্সট সংরক্ষণ করতে সক্ষম। আপনি from
ফাংশন ব্যবহার করে একটি স্ট্রিং লিটারেল থেকে String
তৈরি করতে পারেন, যেমন:
#![allow(unused)] fn main() { let s = String::from("hello"); }
ডাবল কোলন ::
অপারেটরটি আমাদের এই নির্দিষ্ট from
ফাংশনটিকে String
টাইপের অধীনে নেমস্পেস করতে দেয়, string_from
-এর মতো কোনো নাম ব্যবহার করার পরিবর্তে। আমরা এই সিনট্যাক্স সম্পর্কে অধ্যায় ৫-এর "মেথড সিনট্যাক্স" বিভাগে এবং অধ্যায় ৭-এর "মডিউল ট্রি-তে একটি আইটেম রেফার করার জন্য পাথ" বিভাগে মডিউলসহ নেমস্পেসিং নিয়ে আলোচনা করার সময় আরও জানব।
এই ধরনের স্ট্রিং পরিবর্তন (mutated) করা যেতে পারে:
fn main() { let mut s = String::from("hello"); s.push_str(", world!"); // push_str() appends a literal to a String println!("{s}"); // this will print `hello, world!` }
তাহলে, এখানে পার্থক্যটা কী? কেন String
পরিবর্তন করা যায় কিন্তু লিটারেলগুলো যায় না? পার্থক্যটি হলো এই দুটি টাইপ কীভাবে মেমরি নিয়ে কাজ করে।
মেমরি এবং অ্যালোকেশন (Memory and Allocation)
একটি স্ট্রিং লিটারেলের ক্ষেত্রে, আমরা কম্পাইলের সময় বিষয়বস্তু জানি, তাই টেক্সটটি সরাসরি চূড়ান্ত এক্সিকিউটেবলে হার্ডকোড করা থাকে। এই কারণেই স্ট্রিং লিটারেলগুলো দ্রুত এবং কার্যকর। কিন্তু এই বৈশিষ্ট্যগুলো শুধুমাত্র স্ট্রিং লিটারেলের অপরিবর্তনীয়তা (immutability) থেকে আসে। দুর্ভাগ্যবশত, আমরা প্রতিটি টেক্সট, যার আকার কম্পাইলের সময় অজানা এবং প্রোগ্রাম চলার সময় আকার পরিবর্তন হতে পারে, তার জন্য বাইনারিতে মেমরির একটি অংশ রাখতে পারি না।
String
টাইপের সাথে, একটি পরিবর্তনযোগ্য (mutable), প্রসারণযোগ্য (growable) টেক্সট সমর্থন করার জন্য, আমাদের হীপে একটি পরিমাণ মেমরি allocate করতে হবে, যা কম্পাইলের সময় অজানা, বিষয়বস্তু ধারণ করার জন্য। এর মানে হলো:
- রানটাইমে মেমরি অ্যালোকেটরের কাছ থেকে মেমরির জন্য অনুরোধ করতে হবে।
- আমাদের
String
নিয়ে কাজ শেষ হলে এই মেমরিটি অ্যালোকেটরকে ফেরত দেওয়ার একটি উপায় প্রয়োজন।
প্রথম অংশটি আমরা করি: যখন আমরা String::from
কল করি, তখন এর ইমপ্লিমেন্টেশন প্রয়োজনীয় মেমরির জন্য অনুরোধ করে। এটি প্রোগ্রামিং ভাষাগুলোতে প্রায় সর্বজনীন।
তবে, দ্বিতীয় অংশটি ভিন্ন। গার্বেজ কালেক্টর (GC) সহ ভাষাগুলোতে, GC সেই মেমরির ট্র্যাক রাখে এবং পরিষ্কার করে যা আর ব্যবহৃত হচ্ছে না, এবং আমাদের এটি নিয়ে ভাবতে হবে না। GC ছাড়া বেশিরভাগ ভাষায়, কখন মেমরি আর ব্যবহৃত হচ্ছে না তা চিহ্নিত করা এবং এটি স্পষ্টভাবে মুক্ত (free) করার জন্য কোড কল করা আমাদের দায়িত্ব, ঠিক যেমনটি আমরা এটি অনুরোধ করার জন্য করেছিলাম। ঐতিহাসিকভাবে এটি সঠিকভাবে করা একটি কঠিন প্রোগ্রামিং সমস্যা। যদি আমরা ভুলে যাই, আমরা মেমরি নষ্ট করব। যদি আমরা এটি খুব তাড়াতাড়ি করি, আমাদের একটি অবৈধ ভ্যারিয়েবল থাকবে। যদি আমরা এটি দুবার করি, সেটাও একটি বাগ। আমাদের ঠিক একটি allocate
-এর সাথে ঠিক একটি free
যুক্ত করতে হবে।
রাস্ট একটি ভিন্ন পথ নেয়: যে ভ্যারিয়েবলটির মালিকানায় মেমরিটি থাকে, সেটি স্কোপের বাইরে চলে গেলে মেমরি স্বয়ংক্রিয়ভাবে ফেরত দেওয়া হয়। এখানে তালিকা ৪-১ থেকে আমাদের স্কোপের উদাহরণের একটি সংস্করণ রয়েছে যা স্ট্রিং লিটারেলের পরিবর্তে একটি String
ব্যবহার করে:
fn main() { { let s = String::from("hello"); // s is valid from this point forward // do stuff with s } // this scope is now over, and s is no // longer valid }
আমাদের String
-এর প্রয়োজনীয় মেমরি অ্যালোকেটরকে ফেরত দেওয়ার একটি স্বাভাবিক মুহূর্ত আছে: যখন s
স্কোপের বাইরে চলে যায়। যখন একটি ভ্যারিয়েবল স্কোপের বাইরে যায়, রাস্ট আমাদের জন্য একটি বিশেষ ফাংশন কল করে। এই ফাংশনটিকে বলা হয় drop
, এবং এখানেই String
-এর লেখক মেমরি ফেরত দেওয়ার কোড রাখতে পারেন। রাস্ট স্বয়ংক্রিয়ভাবে কার্লি ব্র্যাকেট বন্ধ করার সময় drop
কল করে।
দ্রষ্টব্য: C++ এ, একটি আইটেমের জীবনকালের শেষে রিসোর্স ডিঅ্যালোকেট করার এই প্যাটার্নটিকে কখনও কখনও রিসোর্স অ্যাকুইজিশন ইজ ইনিশিয়ালাইজেশন (RAII) বলা হয়। আপনি যদি RAII প্যাটার্ন ব্যবহার করে থাকেন তবে রাস্টের
drop
ফাংশনটি আপনার কাছে পরিচিত মনে হবে।
এই প্যাটার্নটি রাস্ট কোড লেখার পদ্ধতিতে গভীর প্রভাব ফেলে। এটি এখন সহজ মনে হতে পারে, কিন্তু যখন আমরা হীপে বরাদ্দ করা ডেটা একাধিক ভ্যারিয়েবল ব্যবহার করতে চাই, তখন আরও জটিল পরিস্থিতিতে কোডের আচরণ অপ্রত্যাশিত হতে পারে। আসুন এখন সেই পরিস্থিতিগুলোর কয়েকটি অন্বেষণ করি।
Move এর মাধ্যমে ভ্যারিয়েবল এবং ডেটার মিথস্ক্রিয়া
রাস্টে একাধিক ভ্যারিয়েবল একই ডেটার সাথে বিভিন্ন উপায়ে মিথস্ক্রিয়া করতে পারে। আসুন তালিকা ৪-২-এ একটি পূর্ণসংখ্যা (integer) ব্যবহার করে একটি উদাহরণ দেখি।
fn main() { let x = 5; let y = x; }
আমরা সম্ভবত অনুমান করতে পারি এটি কী করছে: "x
-এ 5
মানটি বাইন্ড কর; তারপর x
-এর মানের একটি কপি তৈরি কর এবং এটি y
-কে বাইন্ড কর।" এখন আমাদের দুটি ভ্যারিয়েবল আছে, x
এবং y
, এবং উভয়ই 5
-এর সমান। এটি আসলেই ঘটছে, কারণ পূর্ণসংখ্যাগুলো একটি জানা, নির্দিষ্ট আকারের সরল মান, এবং এই দুটি 5
মান স্ট্যাকে পুশ করা হয়।
এখন String
সংস্করণটি দেখা যাক:
fn main() { let s1 = String::from("hello"); let s2 = s1; }
এটি দেখতে খুব একই রকম, তাই আমরা ধরে নিতে পারি যে এটি কাজ করার পদ্ধতিও একই হবে: অর্থাৎ, দ্বিতীয় লাইনটি s1
-এর মানের একটি কপি তৈরি করবে এবং এটিকে s2
-তে বাইন্ড করবে। কিন্তু এটি ঠিক যা ঘটে তা নয়।
String
-এর আড়ালে কী ঘটছে তা দেখতে চিত্র ৪-১ দেখুন। একটি String
তিনটি অংশ নিয়ে গঠিত, যা বাম দিকে দেখানো হয়েছে: স্ট্রিংয়ের বিষয়বস্তু ধারণকারী মেমরির একটি পয়েন্টার, একটি দৈর্ঘ্য (length) এবং একটি ধারণক্ষমতা (capacity)। এই ডেটার গ্রুপটি স্ট্যাকে সংরক্ষণ করা হয়। ডানদিকে হীপে থাকা মেমরি রয়েছে যা বিষয়বস্তু ধারণ করে।
চিত্র ৪-১: s1
-এ বাইন্ড করা "hello"
মান ধারণকারী একটি String
-এর মেমরিতে উপস্থাপনা
দৈর্ঘ্য হলো String
-এর বিষয়বস্তু বর্তমানে কত বাইট মেমরি ব্যবহার করছে। ধারণক্ষমতা হলো String
অ্যালোকেটরের কাছ থেকে মোট কত বাইট মেমরি পেয়েছে। দৈর্ঘ্য এবং ধারণক্ষমতার মধ্যে পার্থক্য গুরুত্বপূর্ণ, কিন্তু এই প্রসঙ্গে নয়, তাই আপাতত, ধারণক্ষমতা উপেক্ষা করা ঠিক আছে।
যখন আমরা s1
-কে s2
-তে অ্যাসাইন করি, তখন String
ডেটা কপি করা হয়, যার অর্থ আমরা স্ট্যাকে থাকা পয়েন্টার, দৈর্ঘ্য এবং ধারণক্ষমতা কপি করি। আমরা পয়েন্টারটি যে হীপের ডেটাকে নির্দেশ করে তা কপি করি না। অন্য কথায়, মেমরিতে ডেটার উপস্থাপনা চিত্র ৪-২-এর মতো দেখায়।
চিত্র ৪-২: s2
ভ্যারিয়েবলের মেমরিতে উপস্থাপনা যা s1
-এর পয়েন্টার, দৈর্ঘ্য এবং ধারণক্ষমতার একটি কপি ধারণ করে
উপস্থাপনাটি চিত্র ৪-৩ এর মতো দেখায় না, যা মেমরির চিত্র হতো যদি রাস্ট হীপের ডেটাও কপি করত। যদি রাস্ট এটি করত, তবে s2 = s1
অপারেশনটি রানটাইম পারফরম্যান্সের দিক থেকে খুব ব্যয়বহুল হতে পারত যদি হীপের ডেটা বড় হতো।
চিত্র ৪-৩: s2 = s1
কী করতে পারে তার আরেকটি সম্ভাবনা যদি রাস্ট হীপের ডেটাও কপি করত
আগে, আমরা বলেছিলাম যে যখন একটি ভ্যারিয়েবল স্কোপের বাইরে চলে যায়, তখন রাস্ট স্বয়ংক্রিয়ভাবে drop
ফাংশন কল করে এবং সেই ভ্যারিয়েবলের জন্য হীপ মেমরি পরিষ্কার করে। কিন্তু চিত্র ৪-২ দেখাচ্ছে যে উভয় ডেটা পয়েন্টার একই অবস্থানে নির্দেশ করছে। এটি একটি সমস্যা: যখন s2
এবং s1
স্কোপের বাইরে চলে যাবে, তারা উভয়ই একই মেমরি মুক্ত করার চেষ্টা করবে। এটি একটি ডাবল ফ্রি (double free) ত্রুটি হিসাবে পরিচিত এবং এটি আমরা আগে উল্লেখ করা মেমরি সুরক্ষা বাগগুলোর মধ্যে একটি। দুবার মেমরি মুক্ত করা মেমরি করাপশনের কারণ হতে পারে, যা সম্ভাব্যভাবে নিরাপত্তা দুর্বলতার কারণ হতে পারে।
মেমরি সুরক্ষা নিশ্চিত করার জন্য, let s2 = s1;
লাইনের পরে, রাস্ট s1
-কে আর বৈধ বলে মনে করে না। অতএব, s1
স্কোপের বাইরে চলে গেলে রাস্টকে কিছুই মুক্ত করতে হবে না। s2
তৈরি হওয়ার পরে s1
ব্যবহার করার চেষ্টা করলে কী হয় তা দেখুন; এটি কাজ করবে না:
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{s1}, world!");
}```
আপনি এই ধরনের একটি ত্রুটি পাবেন কারণ রাস্ট আপনাকে অবৈধ রেফারেন্স ব্যবহার করতে বাধা দেয়:
```console
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:15
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 | let s2 = s1;
| -- value moved here
4 |
5 | println!("{s1}, world!");
| ^^^^ value borrowed here after move
|
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
|
3 | let s2 = s1.clone();
| ++++++++
For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
আপনি যদি অন্য ভাষায় কাজ করার সময় শ্যালো কপি (shallow copy) এবং ডিপ কপি (deep copy) শব্দগুলো শুনে থাকেন, তবে ডেটা কপি না করে পয়েন্টার, দৈর্ঘ্য এবং ধারণক্ষমতা কপি করার ধারণাটি সম্ভবত একটি শ্যালো কপির মতো শোনাচ্ছে। কিন্তু যেহেতু রাস্ট প্রথম ভ্যারিয়েবলটিকেও অবৈধ করে দেয়, তাই একে শ্যালো কপি না বলে মুভ (move) বলা হয়। এই উদাহরণে, আমরা বলব যে s1
কে s2
তে মুভ করা হয়েছে। সুতরাং, যা আসলে ঘটে তা চিত্র ৪-৪-এ দেখানো হয়েছে।
চিত্র ৪-৪: s1
অবৈধ হওয়ার পর মেমরিতে উপস্থাপনা
এটি আমাদের সমস্যার সমাধান করে! শুধুমাত্র s2
বৈধ হওয়ায়, যখন এটি স্কোপের বাইরে চলে যাবে তখন এটি একাই মেমরি মুক্ত করবে, এবং আমাদের কাজ শেষ।
এছাড়াও, এর মধ্যে একটি ডিজাইন পছন্দ নিহিত রয়েছে: রাস্ট কখনও স্বয়ংক্রিয়ভাবে আপনার ডেটার "ডিপ" কপি তৈরি করবে না। অতএব, যেকোনো স্বয়ংক্রিয় কপি করাকে রানটাইম পারফরম্যান্সের দিক থেকে সাশ্রয়ী বলে ধরে নেওয়া যেতে পারে।
স্কোপ এবং অ্যাসাইনমেন্ট (Scope and Assignment)
এর বিপরীতটিও স্কোপিং, মালিকানা এবং drop
ফাংশনের মাধ্যমে মেমরি মুক্ত হওয়ার সম্পর্কের জন্য সত্য। যখন আপনি একটি বিদ্যমান ভ্যারিয়েবলে একটি সম্পূর্ণ নতুন মান অ্যাসাইন করেন, তখন রাস্ট drop
কল করবে এবং মূল মানের মেমরি অবিলম্বে মুক্ত করবে। উদাহরণস্বরূপ, এই কোডটি বিবেচনা করুন:
fn main() { let mut s = String::from("hello"); s = String::from("ahoy"); println!("{s}, world!"); }
আমরা প্রথমে একটি ভ্যারিয়েবল s
ঘোষণা করি এবং এটিকে "hello"
মান সহ একটি String
-এ বাইন্ড করি। তারপরে আমরা অবিলম্বে "ahoy"
মান সহ একটি নতুন String
তৈরি করি এবং এটিকে s
-এ অ্যাসাইন করি। এই মুহূর্তে, হীপের মূল মানটিকে কিছুই নির্দেশ করছে না।
চিত্র ৪-৫: মূল মানটি সম্পূর্ণরূপে প্রতিস্থাপিত হওয়ার পরে মেমরিতে উপস্থাপনা।
মূল স্ট্রিংটি তাই অবিলম্বে স্কোপের বাইরে চলে যায়। রাস্ট এটির উপর drop
ফাংশন চালাবে এবং এর মেমরি সঙ্গে সঙ্গে মুক্ত হয়ে যাবে। যখন আমরা শেষে মানটি প্রিন্ট করব, তখন এটি "ahoy, world!"
হবে।
Clone এর মাধ্যমে ভ্যারিয়েবল এবং ডেটার মিথস্ক্রিয়া
যদি আমরা String
-এর হীপ ডেটা গভীরভাবে কপি করতে চাই, শুধু স্ট্যাক ডেটা নয়, আমরা clone
নামে একটি সাধারণ মেথড ব্যবহার করতে পারি। আমরা অধ্যায় ৫-এ মেথড সিনট্যাক্স নিয়ে আলোচনা করব, কিন্তু যেহেতু মেথডগুলো অনেক প্রোগ্রামিং ভাষায় একটি সাধারণ বৈশিষ্ট্য, আপনি সম্ভবত সেগুলি আগে দেখেছেন।
এখানে clone
মেথডের একটি উদাহরণ দেওয়া হল:
fn main() { let s1 = String::from("hello"); let s2 = s1.clone(); println!("s1 = {s1}, s2 = {s2}"); }
এটি ঠিকঠাক কাজ করে এবং স্পষ্টভাবে চিত্র ৪-৩-এ দেখানো আচরণ তৈরি করে, যেখানে হীপ ডেটা সত্যিই কপি করা হয়।
যখন আপনি clone
-এর একটি কল দেখেন, আপনি জানেন যে কিছু নির্বিচারে কোড কার্যকর করা হচ্ছে এবং সেই কোড ব্যয়বহুল হতে পারে। এটি একটি চাক্ষুষ সূচক যে কিছু ভিন্ন ঘটছে।
শুধুমাত্র-স্ট্যাক ডেটা: কপি (Copy)
আরেকটি জটিলতা আছে যা আমরা এখনো আলোচনা করিনি। পূর্ণসংখ্যা ব্যবহার করা এই কোডটি—যার একটি অংশ তালিকা ৪-২-এ দেখানো হয়েছিল—কাজ করে এবং বৈধ:
fn main() { let x = 5; let y = x; println!("x = {x}, y = {y}"); }
কিন্তু এই কোডটি আমরা যা শিখেছি তার সাথে সাংঘর্ষিক বলে মনে হচ্ছে: আমাদের clone
-এর কোনো কল নেই, কিন্তু x
এখনও বৈধ এবং y
-তে মুভ করা হয়নি।
এর কারণ হলো, পূর্ণসংখ্যার মতো টাইপগুলো যাদের কম্পাইলের সময় একটি নির্দিষ্ট আকার থাকে, সেগুলো সম্পূর্ণরূপে স্ট্যাকে সংরক্ষিত হয়, তাই আসল মানগুলোর কপি তৈরি করা দ্রুত হয়। এর মানে হলো, y
ভ্যারিয়েবল তৈরি করার পরে x
-কে বৈধ থাকা থেকে বিরত রাখার কোনো কারণ নেই। অন্য কথায়, এখানে ডিপ এবং শ্যালো কপি করার মধ্যে কোনো পার্থক্য নেই, তাই clone
কল করা স্বাভাবিক শ্যালো কপি করার থেকে ভিন্ন কিছু করত না, এবং আমরা এটি বাদ দিতে পারি।
রাস্টের একটি বিশেষ টীকা আছে যার নাম Copy
ট্রেইট (trait) যা আমরা স্ট্যাকে সংরক্ষিত টাইপগুলোর উপর রাখতে পারি, যেমন পূর্ণসংখ্যাগুলো (আমরা অধ্যায় ১০-এ ট্রেইট সম্পর্কে আরও কথা বলব)। যদি একটি টাইপ Copy
ট্রেইট ইমপ্লিমেন্ট করে, তবে এটি ব্যবহারকারী ভ্যারিয়েবলগুলো মুভ হয় না, বরং সহজভাবে কপি করা হয়, যা তাদের অন্য ভ্যারিয়েবলে অ্যাসাইনমেন্টের পরেও বৈধ রাখে।
রাস্ট আমাদের কোনো টাইপকে Copy
দিয়ে টীকা দিতে দেবে না যদি সেই টাইপ বা এর কোনো অংশ, Drop
ট্রেইট ইমপ্লিমেন্ট করে থাকে। যদি মানটি স্কোপের বাইরে চলে গেলে টাইপটির জন্য বিশেষ কিছু ঘটার প্রয়োজন হয় এবং আমরা সেই টাইপে Copy
টীকা যোগ করি, আমরা একটি কম্পাইল-টাইম ত্রুটি পাব। আপনার টাইপে Copy
ট্রেইট ইমপ্লিমেন্ট করার জন্য Copy
টীকা কীভাবে যোগ করবেন তা জানতে, পরিশিষ্ট C-এর "ডিরাইভেবল ট্রেইটস" দেখুন।
তাহলে, কোন টাইপগুলো Copy
ট্রেইট ইমপ্লিমেন্ট করে? আপনি নিশ্চিত হতে প্রদত্ত টাইপের ডকুমেন্টেশন দেখতে পারেন, কিন্তু একটি সাধারণ নিয়ম হিসাবে, যেকোনো সরল স্কেলার মানের গ্রুপ Copy
ইমপ্লিমেন্ট করতে পারে, এবং যা কিছু অ্যালোকেশন প্রয়োজন বা কোনো ধরনের রিসোর্স, তা Copy
ইমপ্লিমেন্ট করতে পারে না। এখানে কিছু টাইপ রয়েছে যা Copy
ইমপ্লিমেন্ট করে:
- সমস্ত পূর্ণসংখ্যার টাইপ, যেমন
u32
। - বুলিয়ান টাইপ,
bool
,true
এবংfalse
মান সহ। - সমস্ত ফ্লোটিং-পয়েন্ট টাইপ, যেমন
f64
। - ক্যারেক্টার টাইপ,
char
। - টাপল (Tuples), যদি তারা শুধুমাত্র এমন টাইপ ধারণ করে যা
Copy
ইমপ্লিমেন্ট করে। উদাহরণস্বরূপ,(i32, i32)
Copy
ইমপ্লিমেন্ট করে, কিন্তু(i32, String)
করে না।
মালিকানা এবং ফাংশন (Ownership and Functions)
একটি ফাংশনে মান পাস করার পদ্ধতি একটি ভ্যারিয়েবলে মান অ্যাসাইন করার মতোই। একটি ফাংশনে একটি ভ্যারিয়েবল পাস করা মুভ বা কপি করবে, ঠিক যেমন অ্যাসাইনমেন্ট করে। তালিকা ৪-৩-এ কিছু টীকাসহ একটি উদাহরণ রয়েছে যা দেখায় কোথায় ভ্যারিয়েবলগুলো স্কোপের ভিতরে এবং বাইরে যায়।
fn main() { let s = String::from("hello"); // s comes into scope takes_ownership(s); // s's value moves into the function... // ... and so is no longer valid here let x = 5; // x comes into scope makes_copy(x); // Because i32 implements the Copy trait, // x does NOT move into the function, // so it's okay to use x afterward. } // Here, x goes out of scope, then s. However, because s's value was moved, // nothing special happens. fn takes_ownership(some_string: String) { // some_string comes into scope println!("{some_string}"); } // Here, some_string goes out of scope and `drop` is called. The backing // memory is freed. fn makes_copy(some_integer: i32) { // some_integer comes into scope println!("{some_integer}"); } // Here, some_integer goes out of scope. Nothing special happens.
যদি আমরা takes_ownership
কল করার পরে s
ব্যবহার করার চেষ্টা করি, রাস্ট একটি কম্পাইল-টাইম ত্রুটি দেবে। এই স্ট্যাটিক চেকগুলো আমাদের ভুল থেকে রক্ষা করে। main
-এ কোড যোগ করে s
এবং x
ব্যবহার করে দেখুন কোথায় আপনি সেগুলি ব্যবহার করতে পারেন এবং কোথায় মালিকানার নিয়ম আপনাকে তা করতে বাধা দেয়।
রিটার্ন ভ্যালু এবং স্কোপ (Return Values and Scope)
মান ফেরত দেওয়াও মালিকানা হস্তান্তর করতে পারে। তালিকা ৪-৪ এমন একটি ফাংশনের উদাহরণ দেখাচ্ছে যা কিছু মান ফেরত দেয়, তালিকা ৪-৩-এর মতো একই টীকাসহ।
fn main() { let s1 = gives_ownership(); // gives_ownership moves its return // value into s1 let s2 = String::from("hello"); // s2 comes into scope let s3 = takes_and_gives_back(s2); // s2 is moved into // takes_and_gives_back, which also // moves its return value into s3 } // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing // happens. s1 goes out of scope and is dropped. fn gives_ownership() -> String { // gives_ownership will move its // return value into the function // that calls it let some_string = String::from("yours"); // some_string comes into scope some_string // some_string is returned and // moves out to the calling // function } // This function takes a String and returns a String. fn takes_and_gives_back(a_string: String) -> String { // a_string comes into // scope a_string // a_string is returned and moves out to the calling function }
একটি ভ্যারিয়েবলের মালিকানা প্রতিবার একই প্যাটার্ন অনুসরণ করে: অন্য একটি ভ্যারিয়েবলে একটি মান অ্যাসাইন করা এটিকে মুভ করে। যখন একটি ভ্যারিয়েবল যা হীপে ডেটা অন্তর্ভুক্ত করে স্কোপের বাইরে যায়, তখন মানটি drop
দ্বারা পরিষ্কার করা হবে যদি না ডেটার মালিকানা অন্য ভ্যারিয়েবলে মুভ করা হয়ে থাকে।
যদিও এটি কাজ করে, প্রতিটি ফাংশনের সাথে মালিকানা নেওয়া এবং তারপর মালিকানা ফেরত দেওয়া কিছুটা ক্লান্তিকর। কী হবে যদি আমরা একটি ফাংশনকে একটি মান ব্যবহার করতে দিতে চাই কিন্তু মালিকানা নিতে না চাই? এটা বেশ বিরক্তিকর যে আমরা যা কিছু পাস করি তা আমাদের আবার ফেরত পাঠাতে হবে যদি আমরা এটি আবার ব্যবহার করতে চাই, ফাংশনের বডি থেকে প্রাপ্ত কোনো ডেটা ছাড়াও যা আমরা ফেরত দিতে চাই।
রাস্ট আমাদের একটি টাপল (tuple) ব্যবহার করে একাধিক মান ফেরত দেওয়ার অনুমতি দেয়, যেমন তালিকা ৪-৫-এ দেখানো হয়েছে।
fn main() { let s1 = String::from("hello"); let (s2, len) = calculate_length(s1); println!("The length of '{s2}' is {len}."); } fn calculate_length(s: String) -> (String, usize) { let length = s.len(); // len() returns the length of a String (s, length) }
কিন্তু এটি একটি সাধারণ ধারণার জন্য অনেক বেশি আনুষ্ঠানিকতা এবং অনেক কাজ। ভাগ্যক্রমে, রাস্টের একটি বৈশিষ্ট্য আছে যা মালিকানা হস্তান্তর না করে একটি মান ব্যবহার করার জন্য, যার নাম রেফারেন্স (references)।