نکتهای که وجود دارد این است که بسیاری از برنامه نویسان جاوا معتقدند که جاوا Call By Value است و عدهای دیگر نیز معتقدند که جاوا Call By Reference است. اینکه جاوا را چه مینامند اصلا اهمیتی ندارد، بلکه مهم این است که شما رفتار جاوا را بدانید که در هر وضعیت چگونه عمل میکند.
نگاهی کوتاه به مفاهیم گذشته
قبل از اینکه وارد بحث اصلی شویم، بهتر است نگاهی کوتاه به مفاهیم گذشته کنیم. اطلاع و تسلط داشتن کامل به مفاهیمی که در گذشته آموزش داده شده است، بسیار مهم و لازم برای فهمیدن و درک مطالب این جلسه است. پس با دقت مطالعه کنید.
همانطور که میدانید در جاوا دو نوع داده وجود دارد که عبارت هستند از دادههای اولیه (Primitive Data Type) و دادههای ارجاعی (Reference Data Type). دادههای اولیه (Primitive Data Type) هشت نوع بودند: byte, short, int, long, char, float, double, boolea. این هشت نوع داده به صورت پیشفرض در جاوا تعریف شدهاند و استفاده از آنها در یک مثال ساده به صورت زیر است:
package ir.zoomit; public class MainApp { public static void main(String[] args) { int integerNumber = 10; char character = 'A'; boolean b = true; } }
با استفاده از Primitive Data Typeها میتوانیم دادههای پیچیدهتری را بسازیم. به عنوان مثال کلاس String که برای کار با رشتهها استفاده میشود، در دل خودش از دادهی کارکتر (char) استفاده کرده است. String یک کلاس است، بنابراین جزء دادههای ارجاعی (Reference Data Type) به حساب میآید. به عبارت دیگر تمام کلاسها که از روی آنها اشیائی ایجاد میشود، جزء دادههای ارجاعی هستند و فقط و فقط آن هشت دادهای که در بالا گفته شد جزء دادههای پایه به حساب میآیند.
چند نکته در مورد دادههای پایه و ارجاعی
دقیقا منظور از ارجاع یا Reference چیست؟ به طور کلی برای استفاده از دادههای ارجاعی، حتما باید ابتدا از روی آنها یک آبجکت ساخت. به صورت زیر:
package ir.zoomit; public class MainApp { public static void main(String[] args) { Person p = new Person(); // Object Creation OR Instantiation } } class Person { }
در کد بالا دو کلاس وجود دارد. یکی کلاس اصلی (MainApp) که در آن متد معروف main پیادهسازی شده است و دیگری کلاس Person که هیچ پیاده سازی ندارد. همانطور که مشاهده میکنید در داخل متد main یک آبجکت از روی کلاس Person ساختهایم.
در بسیاری از کتابها و منابع آموزشی به اشتباه گفته میشود که p در کد بالا شی یا آبجکت است. در صورتی که کاملا اشتباه است. ما در آموزشهای قبلی هم در مورد این موضوع صحبت کردیم، اما بهتر است دوباره نگاهی سطح پایینتر به این موضوع داشته باشیم.
همانطور که میدانید برای ساختن یک شی از روی یک کلاس، باید آن کلاس را new کنیم. هنگامی که با استفاده از عملگر new اقدام به ساخت یک شی از روی یک کلاس میکنیم، عملگر new در واقع دو کار را انجام میدهد. ابتدا یک شی جدید در حافظهی Heap ایجاد میکند و سپس یک Reference یا ارجاعی از آن شی ساخته شده را برمیگرداند. یعنی ما با استفاده از آن ارجاع، میتوانیم به شی ساخته شده در حافظه دسترسی داشته باشیم. برای اینکه بتوانیم با استفاده از ارجاع به شیئی در حافظه دسترسی داشته باشیم، باید ارجاع را در یک متغیری ذخیره کنیم. این متغیر در کد بالا، p است. بنابراین p شی نیست، بلکه یک Reference یا ارجاعی به شی در حافظه است.
نکتهی دیگری که باید از آن اطلاع داشته باشید و قبلا هم به آن اشاره شده است، این است که در تکه کد زیر:
Person p = new Person();
متغیر p در حافظهی Stack (استک) ایجاد شده است و آبجکت یا شی در حافظهی Heap «هیپ».
برای درک بهتر مسئلهی فوق، به تصویر زیر توجه کنید:
تصویر فوق به صورت دقیق این مسئله را روشن میکند، بنابراین با دقت توجه کنید. در این عکس در یک متُدی با نام Method1 دو داده از نوع دادههای اولیه یا Primitive Data Type تعریف کرده است و بعد هم از روی یک کلاسی با نام Class1 آبجکتی ایجاد کرده است. به Line1 (گوشهی بالا سمت چپ تصویر) توجه کنید. وقتی که در برنامه یک داده از نوع عدد صحیح تعریف شده است، در حافظهی Stack این متغیر ایجاد و مقداردهی شده است. توجه کنید که Primitive Data Typeها همانطور که از نامشان پیداست، دادههای اولیه هستند و نمیتوانیم آنها را new کنیم. new کردن فقط مختص کلاسها است که از روی آنها اشیائی ایجاد میشود. بنابراین وقتی در برنامه دادهای از نوع دادههای اولیه تعریف میکنیم، آن داده در حافظهی Stack ذخیره و مقداردهی میشود. اگر با درس Data structure یا ساختمان دادهها آشنایی داشته باشید، مبحثی است با نام Stack یا پُشته که در آن اصطلاح LIFO را برای Stack در نظر گرفتهاند که مخفف: Last In First Out است. یعنی اینکه آخرین دادهای که وارد Stack میشود، اولین دادهای است که از آن خارج میشود. اگر به تصویر فوق نیز نگاه کنید، استک را همانند یک لیوان کشیده است که یک سَر آن بسته و یک سَر دیگر باز است. دادهها وقتی وارد استک میشوند، روی یکدیگر قرار میگیرند. اگر به خط بعدی برنامه نگاه کنید، دوباره دادهای از نوع دادههای اولیه تعریف و مقداردهی شده است. حالا به Line2 توجه کنید. همانطور که مشاهده میکنید، در استک متغیر y روی متغیر i قرار گرفته است. پس تا اینجای کار، متغیر y آخرین متغیر یا دادهای است که وارد Stack شده است، پس اولین متغیر یا دادهای است که از استک خارج میشود.
حالا به سراغ ادامهی کد میرویم. در ادامه برنامه میخواهد از روی یک کلاس، آبجکتی در حافظه ایجاد کند و ارجاع یا Reference آن آبجکت را در متغیری با نام cls1 ذخیره کند. حالا به Line3 توجه کنید. متغیر cls1 در حافظهی Stack ایجاد شده است و همانطور که در تصویر نیز مشاهده میکنید، در مقابل آن و در داخل پرانتز عبارت (ref) را نوشته است که منظور همان Reference یا ارجاع است. باز هم به تصویر دقت کنید. در Line3 حافظهی Heap را هم کشیده است و متغیر cls1 در حال اشاره کردن به آبجکتی است که در Heap ایجاد شده است. پس با توضیحات فوق باید مفهوم Reference یا ارجاع را کاملا درک کرده باشید.
توضیحات در مورد مفهوم ارجاع یا Reference به طور کلی گفته شد. اما در اینجا قصد داریم به یک نکته اشاره کنیم تا یک سوءتفاهم را برطرف کنیم.
همانطور که قبلا هم در آموزشها اشاره شده است، در جاوا موجودی با نام زباله روب یا Garbage Collector وجود دارد. وظیفهی GC پاکسازی حافظهی Heap است. به عبارت دیگر Garbage Collector هر از چندگاهی به حافظهی Heap سر میزند و اشیاء به اصطلاح مُرده را پاک و حافظه را آزاد میکند. نکتهی بسیار مهم دقیقا همین جا است که Garbage Collector فقط و فقط حافظهی Heap را پاکسازی میکند. اگر به تصویر فوق نگاه کنید، در بخش exiting method، بعد از پایان برنامه حافظهی Stack خالی شده است، اما در حافظهی Heap آبجکت ساخته شده همچنان وجود دارد. نکته اینجا است که در تمام زبانهای برنامه نویسی (چه زبانی مثل جاوا که Garbage Collector دارد و به صورت خودکار حافظهی Heap را پاکسازی میکند، و چه زبانهایی مثل ++C که پاکسازی اشیاء نیز بر عهدهی برنامه نویس است)، حافظهی Stack به صورت خودکار پاک میشود و پاکسازی حافظهی Stack اصلا ربطی به وجود Garbage Collector ندارد و در تمام زبانها این کار به صورت خودکار انجام میشود. به این دلیل به نکتهی بالا پرداخته شد که در بعضی از کتابها و منابع آموزشی، برای توضیح نحوهی کار Garbage Collector، مثالی همانند مثال فوق میآورند و میگویند مثلا y، i یا cls1 توسط Garbage Collector پاک میشوند که کاملا غلط و اشتباه است.
ارسال پارامتر به متُد
متُدها نشان دهندهی رفتار یک برنامه هستند. وقتی یک متُد را تعریف میکنیم، برای آن با توجه به کاری که قرار است در برنامه انجام دهیم، پیاده سازیهای مختلفی در نظر میگیریم. به کد زیر توجه کنید:
package ir.zoomit; public class MainApp { public static void main(String[] args) { Person p = new Person(); // Object Creation OR Instantiation p.show(); // Method Invocation } } class Person { public void show() { System.out.println("Method Invocation"); } }
در بالا یک برنامهی بسیار ساده نوشتهایم. ابتدا یک متُد با نام ()show در کلاس Person نوشتهایم که این متُد پیاده سازی بسیار سادهای دارد (در حد چاپ کردن یک رشته در خروجی استاندارد). در کلاس اصلی در داخل متد main، ابتدا آبجکتی از روی کلاس Person ایجاد کردهایم و سپس توسط آن آبجکت، متُد موجود در کلاس Person را فراخوانی کردهایم.
متُدی که در بالا تعریف کردهایم، یک متُد بدون پارامتر است. حالا اگر بخواهیم یک متُد با پارامتر تعریف کنیم، باید متغیریهایی را در داخل پرانتزهای باز و بستهی جلوی نام متد، تعریف کنیم. یک متد میتواند صفر یا بیش از صفر پارامتر داشته باشد. به کد زیر توجه کنید:
package ir.zoomit; public class MainApp { public static void main(String[] args) { Person p = new Person(); // Object Creation OR Instantiation p.sum(5, 10); // Method Invocation } } class Person { public void sum(int a, int b) { int sum = 0; sum = a + b; System.out.println(sum); } }
در برنامهی فوق، ابتدا در داخل کلاس Person متُدی با نام sum تعریف کردهایم که میخواهیم این متُد دو عدد را دریافت کند و سپس آن دو عدد را جمع کند و در خروجی استاندارد چاپ کند. اگر دقت کنید متُد ()sum در داخل پرانتزهای باز و بستهی جلویش، دو عدد صحیح تعریف شده است. این دو عدد پارامترهای متُد ()sum هستند. چند نکته در مورد پارامترهای متُد:
اگر قرار است متُد بیش از یک پارامتر داشته باشد (همانند متُد فوق)، باید با استفاده از علامت «,» متغیرها را از یکدیگر جدا کنید. اما اگر یک متغیر است، نیازی به این کار نیست.
نکتهی دوم این است که اگر میخواهیم متُدی را فراخوانی کنیم که دارای پارامتر است، در هنگام فراخوانی متُد، حتما باید برای پارامترهای متُد، دادههایی را از همان نوعی که در متُد تعریف شدهاند در نظر بگیریم. در غیر این صورت با خطای کامپایل مواجه میشویم. اگر به کد بالا دقت کنید، در متُد main هنگام فراخوانی متُد ()sum، دو عدد ۵ و ۱۰ را برای پارامترهای آن در نظر گرفتهایم. اگر آن دو عدد را در نظر نمیگرفتیم، با خطای کامپایل مواجه میشدیم. پس در برنامهی بالا ما یک متُد تعریف کردهایم که دارای دو پارامتر است و هنگام فراخوانی آن متُد (Method Invocation)، دو داده برای پارامترهای آن در نظر گرفتهایم.
Parameter Passing: A Deeper Look
حالا میخواهیم نگاهی دقیقتر به ارسال پارامتر به متُدها داشته باشیم و وارد موضوعی شویم که تمام مطالبی که تا قبل از این گفته شد، برای فهمیدن این موضوع لازم است.
در حالت کلی ارسال پارامتر به یک متُد، در سه صورت انجام میشود که عبارت هستند از:
- Call By Value
- Call By Pointer
- Call By Reference
در زبان ++C مفهومی است با نام اشارهگر یا Pointer که در زبان جاوا وجود ندارد. البته طراحان زبان جاوا ارجاعها یا Referenceها را نوع خاص و محدود شدهی اشارهگر در جاوا میدانند. نکتهی مهمی که باید به آن توجه کنید این است که با توجه به این موضوع که در زبان جاوا اشارهگر وجود ندارد، درست نیست که برای جاوا از مفهوم Call By Pointer استفاده کنیم. اما چرا ما در اینجا برای آموزش جاوا این سه اصطلاح را نوشتیم؟ این سه مُدل یک مفهوم کلی است و شاید بهتر باشد با زبانهای دیگری مثل ++C و #C نیز کار کنید تا درک درستی از هرکدام از این مفاهیم پیدا کنید، زیرا عنوان این سه مُدل شفاف و گویا نیست و فهمدین و درک آنها نیازمند تجربه است. به عنوان مثال مُدلی که در ++C به آن Call By Reference میگویند، اصلا در جاوا وجود ندارد و در مقابل مُدلی که در جاوا معمولا به آن Call By Reference میگویند، در ++C با عنوان Call By Pointer شناخته میشود. بنابراین اگر کسی از شما بپرسد که Call By Reference چیست، باید سوالش را دقیقتر و کاملتر کند که منظورش در کدام زبان برنامه نویسی است.
حالا اجازه دهید با مثال کار خود را پیش ببریم. ابتدا توضیح Call By Value. به کد زیر توجه کنید:
package ir.zoomit; public class MainApp { public static void main(String[] args) { int a = 5; int b = 10; System.out.println("Before: " + "a=" + a + ", " + "b=" + b); badSwap(a, b); System.out.println("After: " + "a=" + a + ", " + "b=" + b); } private static void badSwap(int a, int b) { int temp = 0; temp = a; a = b; b = temp;
System.out.println("In badSwap() Method: " + "a=" + a + ", " + "b=" + b); } }
در برنامهی بالا یک کلاس بیشتر تعریف نکردهایم که همان کلاس اصلی است. اما در داخل این کلاس دو متُد وجود دارد. یکی متُد main که نقطهی شروع هر برنامهی جاوا است و دیگری متُد ()badSwap که پیاده سازی آن به این شکل است که دو پارامتر به عنوان ورودی دریافت میکند و سپس مقادیر آنها را با یکدیگر عوض میکند. یعنی بعد از اجرای متُد ()badSwap، مقدار متغیر a که ۵ است باید ۱۰ شود و مقدار b که ۱۰ است، ۵. در پیاده سازی متُد main قبل از اجرا و بعد از اجرای متُد ()badSwap مقادیر متغیرهای a و b را در خروجی استاندارد چاپ کردهایم تا متوجه تغییرات (جا به جایی مقادیر متغیرها) شویم. همچنین در داخل متُد ()badSwap نیز مقادیر a و b را چاپ کردهایم.
حالا قبل از اجرای برنامه، سعی کنید فقط با نگاه کردن به کدها، برنامه را روی یک تکه کاغذ یا ذهن خود Trace و اجرا کرده و خروجی برنامه را پیدا کنید.
بعد از اینکه خودتان برنامه را به صورت دستی Trace کردید، برنامه را اجرا کنید. بعد از اجرای برنامهی فوق، با خروجی زیر مواجه میشوید:
Before: a=5, b=10 In badSwap() method: a=10, b=5 After: a=5, b=10
همانطور که در خروجی فوق مشاهده میکنید، مقادیر a و b قبل و بعد از اجرای متُد هیچ تغییری نکرده است و فقط تغییرات در داخل متُد ()badSwap است. یعنی جاوا در این قسمت رفتار Call By Value از خودش نشان داده است. اما علت تغییر نکردن مقادیر چیست؟ بسیار ساده است. هنگامی که در داخل متُد main، متُد ()badSwap را فراخوانی و مقادیر a و b را به آن ارسال کردهایم، جاوا در حقیقت خود اصل مقادیر را به متُد ارسال نکرده است، بلکه کُپی از دادهها را به متُد ارسال کرده است. بنابراین در داخل متُد ()badSwap هر اتفاقی که بیفتد، روی کُپی دادهها تغییرات ایجاد میشود و اصل دادهها هیچ تغییری نمیکنند. به هم دلیل است که وقتی مقادیر a و b را در داخل متُد ()badSwap در کنسول چاپ میکنیم، مقادیرشان تغییر کرده است، اما بعد از آن خیر.
حالا اجازه دهید در مورد Call By Reference که شبیه Call By Pointer در ++C است صحبت کنیم.
به کد زیر توجه کنید:
package ir.zoomit; public class MainApp { public static void main(String[] args) { } } class Person { private String str1; private String str2; public String getStr1() { return str1; } public void setStr1(String str1) { this.str1 = str1; } public String getStr2() { return str2; } public void setStr2(String str2) { this.str2 = str2; } }
کد فوق بسیار ساده است. دو کلاس داریم، یکی کلاس اصلی (MainApp) و دیگری کلاس Person. کلاس Person دارای دو ویژگی یا Property یا فیلد است. طبق آموزشهای قبل فیلدهای کلاس Person متغیرهای str1 و str2 که از جنس کلاس String هستند. در این کلاس چهار متُد تعریف شده است. به دلیل اینکه Encapsulation رُخ داده است، متُدهای getter و setter برای دسترسی به فیلدهای کلاس تعریف شده است.
حالا در ادامه میخواهیم در کلاس اصلی و در متُد main از روی کلاس Person یک آبجکت ایجاد کنیم و با استفاده از شیئی که در دست داریم، فیلدهای کلاس Person که str1 و str2 هستند را مقداردهی کنیم. پس باید کُدمان را به صورت زیر کامل کنیم:
package ir.zoomit; public class MainApp { public static void main(String[] args) { // Object Creation OR Instantiation Person a = new Person(); Person b = new Person(); a.setStr1("Java"); b.setStr2("C++"); System.out.println( "Before: " + "str1=" + a.getStr1() + ", " + "str2=" + b.getStr2()); swapNames(a, b); System.out.println( "After: " + "str1=" + a.getStr1() + ", " + "str2=" + b.getStr2()); } static void swapNames(Person a, Person b) { String tmp = a.getStr1(); a.setStr1(b.getStr2()); b.setStr2(tmp); } } class Person { private String str1; private String str2; public String getStr1() { return str1; } public void setStr1(String str1) { this.str1 = str1; } public String getStr2() { return str2; } public void setStr2(String str2) { this.str2 = str2; } }
برنامه بسیار ساده است. دقیقا همان جا به جایی است که در بخش قبل برای اعداد انجام دادیم، اینجا برای رشتهها انجام دادهایم و میخواهیم امتحان کنیم که آیا مقادیر دو رشتهی str1 و str2 با یکدیگر عوض میشوند یا خیر؟ باز هم پیشنهاد میکنیم برنامه را قبل از اجرا در ذهن خود Trace کنید و به خروجی آن برسید.
حالا اگر برنامه را اجرا کنیم، با خروجی زیر مواجه میشویم:
Before: str1=Java, str2=C++ After: str1=C++, str2=Java
همانطور که مشاهده میکنید این بار مقادیر متغیرها با یکدیگر عوض شد. علت چیست؟ همانطور که گفته شد، دادههای ارجاعی (Reference Data Type)، به طور مستقیم خودِ داده را نگه داری نمیکنند، بلکه ارجاعی به آن داده در حافظه هستند. در کد بالا متغیرهای a و b در متُد ()swapNames به همان جایی در حافظه ارجاع میدهند (اشاره میکنند) که متغیرهای a و b در متُد main به آن اشاره میکنند. بنابراین وقتی با کمک متغیرهای a و b در متُد ()swapNames محتوای (State) یک آبجکت را تغییر دهیم، محتوای a و b در متُد main نیز تغییر میکند. بنابراین ما در جاوا به کمک ارجاعهایی که به به صورت پارامتر به متُدها پاس میشوند، میتوانیم محتوا یا وضعیت یا State اشیاء را تغییر دهیم. نکتهای که وجود دارد این است که هویت یا Identity اشیاء را نمیتوانیم تغییر بدهیم. به عبارت دیگر پارامترهایی که به متُد پاس میشوند، در انتهای فراخوانی متُد، هویتشان حفظ میشود. به عنوان مثال متغیرهای a و b در متُد main، پس از پایان متُد، هویتشان تغییر نمیکند و همچنان به همان جایی در حافظه اشاره میکنند که قبلا اشاره میکردند. اما بعد از اجرای متُد، وضعیتشان، حالتشان یا State آنها تغییر کرده است.