چگونه null برنگردانیم؟

برنامهنویسان جاوا، چه تازهکار و چه باتجربه، عادتهای مشکلزایی دارند و یکی از آنها بازگرداندن null از متدهاست. در این مقاله شرایطی که منجر به بازگرداندن null میشود را شرح داده و با ارائۀ مثال، راهحلهایی برای اجتناب از آن ارائه خواهیم کرد.
چند دهه است که Null بهترین دوست و بدترین دشمن برنامهنویسان است. در مورد برنامهنویسان جاوا هم شرایط همینطور است. در حالت کلی، ممکن است برای حل بعضی از مشکلات مجبور باشیم null برگردانیم اما تعداد دفعاتی که واقعا مجبور به این کار هستیم بسیار کمتر از مقداری است که هماکنون در کدنویسی مرتکب میشویم.
تصور کنید یک سیستم با حجم محاسبات بالا که لزوما باید بسیار سریع کار کند را برنامهنویسی میکنید. در شرایطی که متد مقدار مناسبی برای برگرداندن پیدا نکند، دو راه داریم:
- روش اول: null برگردانیم، که بسیار ساده و سریع است.
- روش دوم:Exception پرتاب کنیم، که مستلزم دریافت کل stack trace و ساخت یک نمونه از Exception است.
اگر درگیر محاسبات حجم بالا نیستید و برگرداندن null توجیه فنی ندارد، بهتر است این کار را نکنید. چرا که بازگرداندن null از متدها امکان رویاوریی با NullPointerException را افزایش میدهد و این Exception می تواند اجرای نرمافزار را متوقف کند.
در بیشتر موارد null به یکی از سه دلیل زیر برگردانده میشود:
- باید مجموعهای (مثلا یک List) از اشیا را پیدا کنیم و آنها را برگردانیم و برنامهنویس در شرایطی که هیچ شی مناسبی پیدا نمیشود null برمیگرداند.
- حالت دوم با یک مثال بهتر شرح داده میشود. فرض کنید متدی باید پیادهسازی شود که کار آن این است: یک فایل را بخواند و اگر یک property در آن فایل نوشته شده باشد، مقدار آن را برگرداند، اما لزوما فایل حاوی آن property نیست. در این حالت برنامهنویس یا مقدار را برمیگرداند یا null و زمانی که null برگردانده شده است، این مفهوم را منتقل میکند: «مقدار درستی پیدا نشد، هر چند خطایی هم رخ نداده است»
- مواردی که null یکی از حالات خاصی است که یک متغیر میتواند داشته باشد.
اگر از کارایی برگرداندن null در شرایط نیاز به محاسبات سریع بگذریم، هر کدام از سه حالت فوق راهحلهای بهتری دارند که برنامهنویس را مجبور به سروکار داشتن با null نمیکند. مهم است متدهایی که مینویسیم را طوری طراحی کنیم که شخص استفادهکننده، در مورد شیای که دریافت خواهد کرد بلاتکلیف نباشد. در ادامه سه حالت مذکور بررسی شده و با ذکر مثال راهحلهایی پیشنهاد میشود.
حالت اول: لیست بدون عضو
زمانی که متد باید یک لیست یا Collection برگرداند، اگر عضو (یا اعضای) مورد نظر پیدا نشود، برگرداندن مقدا null، کار مرسومی به نظر میرسد. به عنوان مثال در کد زیر یک سرویس که کار آن گرفتن لیستی از Userها از پایگاهداده است، پیادهسازی شده. (برای حفظ اختصار برخی از متدها حذف شدهاند)
public class UserService { public List<User> getUsers() { User[] usersFromDb = getUsersFromDatabase(); if (usersFromDb == null) { // No users found in database return null; } else { return Arrays.asList(usersFromDb); } } } UserServer service = new UserService(); List<Users> users = service.getUsers(); if (users != null) { for (User user: users) { System.out.println("User found: " + user.getName()); } }
در طراحی فوق تصمیم گرفتهایم در شرایطی که هیچ Userای در پایگاهداده پیدا نکردیم null برگردانیم و در نتیجه کاربر مجبور است قبل از پیمایش آنچه به دستش رسیده، چک کند با null مواجه است یا خیر.
اما اگر در این شرایط یک لیست خالی برگردانیم، کاربر به سادگی و بدون نیاز به یک بررسی اضافه میتوانست لیست خالی را مثل یک لیست معمولی بپیماید. اگر لیست خالی باشد، طبیعتا از هر عملیاتی مربوط به اعضا عبور خواهد کرد.
public class UserService { public List<User> getUsers() { User[] usersFromDb = getUsersFromDatabase(); if (usersFromDb == null) { // No users found in database return Collections.emptyList(); } else { return Arrays.asList(usersFromDb); } } } UserServer service = new UserService(); List<Users> users = service.getUsers(); for (User user: users) { System.out.println("User found: " + user.getName()); }
در کد فوق تصمیم گرفتیم یک لیست خالیِ غیرقابل تغییر (immutable) برگردانیم. اقدام به تغییر این لیست موجب خطا میشود. تا زمانی که مستندات کد حاوی این نکته باشد، راه حل، قابل قبول است.
اما اگر ممکن است لیست تغییر کند، میتوانیم یک لیست خالی قابل ویرایش نیز برگردانیم. مثال را بررسی کنید:
public List<User> getUsers() { User[] usersFromDb = getUsersFromDatabase(); if (usersFromDb == null) { // No users found in database return new ArrayList<>(); // A mutable list } else { return Arrays.asList(usersFromDb); } }
در حالت کلی وقتی میخواهیم این مفهوم را منتقل کنیم که عضو مناسب پیدا نشد یا موجود نیست باید بر اساس قانون زیر عمل کنیم:
برای اعلام این که عضوی پیدا نشد، یک Collection خالی مانند لیست، صف یا Set برگردانید.
رعایت این قانون نه تنها باعث میشود نیاز به چک کردن null حذف شود، بلکه کدهایی که نوشتهاید عملکردی قابل پیشبینی خواهند داشت و کاربر میتواند همیشه از بابت دریافت یک Collection مطمئن باشد.
مقدار اختیاری (Optional)
گاهی هدف از بازگرداندن null این است که بگوییم خطایی در اجرا رخ نداده اما یک مقدار که وجود آن اختیاری بوده موجود نیست. به عنوان مثال دریافت یک پارامتر از یک آدرس وب میتواند این گونه باشد. در بسیاری از موارد، ذکرِ مقادیر اختیاریاند و در صورتی که قید نشوند، یک مقدار پیشفرض برای آنها در نظر گرفته میشود. عدم ذکر آنها نیز به هیچ عنوان خطا در نظر گرفته نمیشود.
در چنین مواردی، طراحی کد میتواند به این شکل باشد: اگر پارامتر ذکر شده باشد مقدار آن و در غیر این صورت null برگردانیم. مثال را بررسی کنید:
public class UserListUrl { private final String url; public UserListUrl(String url) { this.url = url; } public String getSortingValue() { if (urlContainsSortParameter(url)) { return extractSortParameter(url); } else { return null; } } } UserService userService = new UserService(); UserListUrl url = new UserListUrl("https://localhost/api/v2/users"); String sortingParam = url.getSortingValue(); if (sortingParam != null) { UserSorter sorter = UserSorter.fromParameter(sortingParam); return userService.getUsers(sorter); } else { return userService.getUsers(); }
در مثال فوق منظور از Sorting Value مقداری است که نوع مرتبسازی از بابت صعودی یا نزولی بودن را مشخص خواهد کرد. اگر هیچ مقداری ذکر نشود، متدِ getSortingValue اشارهگری از نوع null برمیگرداند و کاربرِ متد باید آمادگی رویارویی با آن را داشته باشد. همانطور که مشاهده میکنید، از شکل تعریف تابع (method signature) به هیچ وجه نمیتوانیم متوجه شویم حاصل فراخوانی این متد ممکن است null باشد و برای دانستن آن، حتما باید به اسناد کد مراجعه کنیم. در بسیاری از شرایط اسناد مناسبی ارائه نمیشود و اصولا برای تمام متدهایی که از کتابخانههای مختلف استفاده میکنیم هم هر بار به اسناد مراجعه نمیکنیم.
به جای طراحی فوق میتوانیم یک شی از نوع Optional برگردانیم. کاربر در این حالت نیز مجبور است بود و نبود مقدار در داخل Optional را بررسی کند اما نقطۀ قوت طراحی جدید این است که نیاز به بررسی را صریح و در داخل کد ذکر کردهایم. علاوه بر این، کلاس Optional روشهای خوشدستتری برای مدیریت شرایطی که یک null در داخل آن ارسال شده است ارائه میکند. به مثال توجه کنید:
public class UserListUrl { private final String url; public UserListUrl(String url) { this.url = url; } public Optional<String> getSortingValue() { if (urlContainsSortParameter(url)) { return Optional.of(extractSortParameter(url)); } else { return Optional.empty(); } } } UserService userService = new UserService(); UserListUrl url = new UserListUrl("https://localhost/api/v2/users"); Optional<String> sortingParam = url.getSortingValue(); if (sortingParam.isPresent()) { UserSorter sorter = UserSorter.fromParameter(sortingParam.get()); return userService.getUsers(sorter); } else { return userService.getUsers(); }
کد فوق تقریبا مانند حالتی است که null بودن «مقدار» بازگرداندهشده را بررسی میکردیم اما با این تفاوت که در طراحی جدید، کاربر نمیتواند بدون استفاده از متد get به «مقدار» دسترسی داشته باشد. متد get مستعد پرتاب NoSuchElementException است و برنامهنویس را مجبور به اندیشیدن تدبیری در این رابطه خواهد کرد.
گفتیم کلاس Optional امکانات مناسبی در اختیار قرار میدهد. یکی از این تسهیلات متدی به نام ifPresentElse است که مثال زیر نحوۀ استفاده از آن را نشان داده است:
sortingParam.ifPresentOrElse( param -> System.out.println("Parameter is :" + param), () -> System.out.println("No parameter supplied.") );
شلوغی تکهکد فوق بسیار کمتر از حالتی است که null بودن را با ساختار if بررسی میکنیم.
اگر فقط در شرایطی باید عکسالعملی نشان داده شود که مقداری غیر از null برگردانده شده، کد میتواند باز هم شستهرفتهتر نوشته شود:
sortingParam.ifPresent( param -> System.out.println("Parameter is :" + param) );
به صورت خلاصه باید گفت در شرایطی که بود و نبود یک مقدار، هر دو منطقی و ممکن است، استفاده از یک شی از نوع Optional نه تنها کاربر را مجبور به بررسی موضوع میکند، بلکه راههای سادهتری برای اعمال نظر در اختیار او قرار میگیرد. در نتیجه قانون دوم را به شکل زیر بیان میکنیم.
اگر وجود مقداری که باید برگردانده شود اختیاری است آن را در یک شی از نوع Optional قرار دهید.
null به عنوان یکی از مقادیر متغیر
آخرین مورد استفادۀ رایج null، حالتی است که به عنوان یکی از مقادیر معقول برای متغیر در نظر گرفته میشود. به مثال توجه کنید:
فرض کنید کلاسی به نام CommandFactory برای تولید دستور داریم. کاربران این کلاس میتوانند به صورت دورهای، از آن یک Command دریافت و اجرا کنند. در صورتی که هنگام درخواست، دستوری برای عرضه وجود نداشته باشد، کاربر یک ثانیه منتظر مانده و بعد دوباره درخواست یک دستور جدید میکند. در شرایطی که دستوری برای اجرا وجود نداشته باشد میتوان null برگرداند و کاربر باید مقادیر مختلف متغییر را به شکل زیر بررسی کرده و عکس العمل متناسب نشان دهد:
public interface Command { public void execute(); } public class ReadCommand implements Command { @Override public void execute() { System.out.println("Read"); } } public class WriteCommand implements Command { @Override public void execute() { System.out.println("Write"); } } public class CommandFactory { public Command getCommand() { if (shouldRead()) { return new ReadCommand(); } else if (shouldWrite()) { return new WriteCommand(); } else { return null; } } } CommandFactory factory = new CommandFactory(); while (true) { Command command = factory.getCommand(); if (command != null) { command.execute(); } else { Thread.sleep(1000); } }
از آن جایی که امکان دارد CommandFactory اشارهگری به null برگرداند، کاربر لزوما باید مقدار دریافتی را چک کرده و در مواقعی، اجرای برنامه را به مدت یک ثانیه متوقف کند. در نتیجه برای استفاده از CommandFactory نیاز به یک سری شروط منطقی دارد. میتوانیم مشکل را با تولید یک Null‑Object یا Special‑case Object حل کنیم. شیای که null را نمایندگی میکند، منطقِ رویارویی با null را دربرخواهد گرفت. در مثال ما، «منطق»، توقف برنامه به مدت یک ثانیه است. برای اجرای الگوی طراحی Null-Object، کلاسی به نام SleepCommand ساخته و به شکل زیر به کار میبریم:
public class SleepCommand implements Command { @Override public void execute() { Thread.sleep(1000); } } public class CommandFactory { public Command getCommand() { if (shouldRead()) { return new ReadCommand(); } else if (shouldWrite()) { return new WriteCommand(); } else { return new SleepCommand(); } } } CommandFactory factory = new CommandFactory(); while (true) { Command command = factory.getCommand(); command.execute(); }
در بخش اول نوشته گفتیم ارسال یک Collection خالی باعث میشود حالات مختلف آن (پر یا خالی بودن) به شکل واحدی مدیریت شود و نیاز به استفاده از شرطهای منطقی از بین میرود. استفاده از null‑Object هم اثر مشابهی دارد.
استفاده از الگوی گفتهشده همیشه امکانپذیر نیست. گاهی تصمیم مناسب جهت رویارویی با null لزوما باید توسط کاربر گرفته شود. در چنین مواقعی نیز پیشنهاد میشود مقدار در یک کلاس Optional بازگردانده شود تا احتمال مواجهه با null به صورت صریح اعلام شود.
قانون سوم به شکل زیر بیان می گردد:
به جای بازگرداندن null، در مواقع امکان null‑Object برگردانید.