کدنویسی تمیز در توابعکدنویسی تمیز در توابع

کدنویسی تمیز در توابع

نویسنده: جواد رسولی

دسته بندی: توسعه نرم افزار
10 دقیقه زمان مطالعه
۱۴۰۰/۰۳/۲۵
2 نظر
امتیاز 3.2 از 5

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

تعداد خطوط در توابع باید چقدر باشد؟

قطعه کد زیر از کتاب Clean Code نوشته رابرت مارتین (Robert C. Martin) را در نظر بگیرید:

public static String testableHtml(PageData pageData, 
    boolean includeSuiteSetup) throws Exception {
    WikiPage wikiPage = pageData.getWikiPage();
    StringBuffer buffer = new StringBuffer();
    if (pageData.hasAttribute("Test")) {
        if (includeSuiteSetup) {
            WikiPage suiteSetup = PageCrawlerImpl
                .getInheritedPage(SuiteResponder.SUITE_SETUP_NAME, wikiPage);
            if (suiteSetup != null) {
                WikiPagePath pagePath = suiteSetup.getPageCrawler()
                    .getFullPath(suiteSetup);
                String pagePathName = PathParser.render(pagePath);
                buffer.append("!include -setup .")
                    .append(pagePathName)
                    .append("\n");
            }
        }
        WikiPage setup = PageCrawlerImpl
            .getInheritedPage("SetUp", wikiPage);
        if (setup != null) {
            WikiPagePath setupPath = wikiPage
                .getPageCrawler()
                .getFullPath(setup);
            String setupPathName = PathParser.render(setupPath);
            buffer.append("!include -setup .")
                .append(setupPathName)
                .append("\n");
        }
    }

    buffer.append(pageData.getContent());
    if (pageData.hasAttribute("Test")) {
        WikiPage teardown = PageCrawlerImpl
            .getInheritedPage("TearDown", wikiPage);
        if (teardown != null) {
            WikiPagePath tearDownPath = wikiPage
                .getPageCrawler()
                .getFullPath(teardown);
            String tearDownPathName = PathParser.render(tearDownPath);
            buffer.append("\n").append("!include -teardown .")
                .append(tearDownPathName)
                .append("\n");
        }
        if (includeSuiteSetup) {
            WikiPage suiteTeardown = PageCrawlerImpl
                .getInheritedPage(SuiteResponder.SUITE_TEARDOWN_NAME,wikiPage);
            if (suiteTeardown != null) {
                WikiPagePath pagePath = suiteTeardown.getPageCrawler()
                    .getFullPath (suiteTeardown);
                String pagePathName = PathParser.render(pagePath);
                buffer.append("!include -teardown .").append(pagePathName)
                    .append("\n");
            }
        }
    }

    pageData.setContent(buffer.toString());
    return pageData.getHtml();
}

آیا هدف این قطعه کد به راحتی درک می‌شود؟ مطمئنا جواب خیر است. با شکستن این قطعه کد به چند روال ساده و کمی تغییر ساختار، می‌توان آن را در ۹ خط بازنویسی کرد؛ به نحوی که هدف از قطعه کد در کمترین زمان ممکن قابل درک باشد: 


public static String renderPageWithSetupsAndTeardowns(PageData pageData, 
    boolean isSuite) throws Exception {
    boolean isTestPage = pageData.hasAttribute("Test");
    if (isTestPage) {
        WikiPage testPage = pageData.getWikiPage();
        StringBuffer newPageContent = new StringBuffer();
        includeSetupPages(testPage, newPageContent, isSuite);
        newPageContent.append(pageData.getContent());
        includeTeardownPages(testPage, newPageContent, isSuite);
        pageData.setContent(newPageContent.toString());
    }
    return pageData.getHtml();
}

حال سوال این جا است که چه چیزی باعث می‌شود تابعی همانند تابع اول ناخوانا و تابعی مانند تابع بازنویسی‌شده، خوانا باشد؟ 

چه ویژگی‌هایی از توابع باعث خوانایی بیشتر کد می‌شوند؟

در کتاب Clean Code ویژگی‌هایی برای افزایش خوانایی کد مطرح شده که یکی از آن‌ها کم کردن تعداد خطوط تابع است. رابرت مارتین در این کتاب می‌گوید:
«من هیچ منبعی را نمی‌توانم ارائه دهم که در آن گفته شده باشد توابع با تعداد خطوط کم بهتر هستند. آن چه که می‌توانم بگویم این است که نزدیک به چهار دهه توابعی با اندازه‌های مختلف نوشته‌ام؛ توابعی با تعداد ۱۰۰ تا ۳۰۰ خط کد! همچنین توابعی با ۲۰ تا ۳۰ خط کد. این تجربیات همراه با آزمون و خطا، به من آموخته است که توابع با تعداد خطوط کم بهتر هستند.»

یک تابع خوب باید چند خط باشد؟

وقتی صحبت از کاهش تعداد خطوط در توابع به میان می‌آید، پرسش اصلی این است که یک تابع خوب باید چند خط باشد؟ ۵۰ خط؟ ۲۵ خط؟ ۵ خط؟ ۱ خط؟ 
در دهه ۸۰ گفته می‌شد برای نمایش یک تابع نباید نیاز به اسکرول افقی یا عمودی صفحه داشته باشیم و تابع نباید از یک صفحه تجاوز کند. البته این گفته برای زمانی بود که مانیتورها ۲۴ خط ۸۰ کاراکتری بودند که ویرایشگرها نیز از ۴ خط آن استفاده می‌کردند؛ یعنی هر تابع نباید از۲۰ خط تجاوز می‌کرد. البته ۲۰ خطی که هر یک شامل کمتر از ۸۰ کاراکتر می‌شدند. 
امروزه با انتخاب یک فونت مناسب و یک مانیتور نسبتا بزرگ، می‌توان در هر خط ۱۵۰ کاراکتر را جا داد. همچنین ۱۰۰ خط یا بیشتر را می‌توان در صفحه بدون اسکرول جا داد. با این استدلال، توابع ما نباید از ۱۵۰ کاراکتر در یک خط تجاوز کند. همچنین تعداد خط در تابع نباید بیشتر از ۱۰۰ خط باشد اما به گفته رابرت مارتین، برنامه‌نویسی باید به گونه‌ای باشد که تنها در موارد نادر توابعی با بیش از 20 خط به کار ببریم.

چگونه می‌توان تعداد خطوط توابع را کاهش داد؟

برای کاهش تعداد خطوط یک تابع، باید قسمت‌هایی از کد را استخراج و در قالب یک تابع جدید پیاده‌سازی کنیم. در این باره دستورالعمل‌های زیادی وجود دارد. یکی از دستورالعمل‌ها، کد را بر اساس استفاده مجدد مورد بررسی قرار می‌دهد و می‌گوید:
«اگر در کدهای شما قطعه کدی قابل استفاده مجدد باشد، باید استخراج شود و در یک تابع جداگانه قرار بگیرد؛ در غیر این‌ صورت باید inline باشد.»
دستورالعمل دیگری نیز در این زمینه وجود دارد که می‌گوید:
«هر تابع یا روال فقط و فقط باید یک کار را انجام دهد.»
استیو مک کانل (Steve Mcconnell) در کتاب Code Complete و رابرت مارتین در کتاب Clean Code (دو منبع معتبر در زمینه کد نویسی به بهترین شیوه) و همچنین مارتین فاولر (Martin Fowler) در وبلاگ خود، این دستورالعمل را تایید کرده‌اند و روش‌هایی را برای رسیدن به این هدف ارائه داده‌اند.
دوباره به کد اول نگاه کنید. در این قطعه کد به وضوح معلوم است که تابع بیشتر از یک کار را انجام می‌دهد. ایجاد بافر، واکشی صفحات، تولید HTML، جست‌وجو در متن و ... از جمله کارهایی است که این تابع انجام می‌دهد.
اما این دستورالعمل یک مشکل دارد: تشخیص این که تابع یک کار انجام می‌دهد یا خیر، سخت است.

آیا تابع ما یک کار انجام می‌دهد؟

کد زیر را که اصلاح‌شده کد قبلی است در نظر بگیرید:


public static String renderPageWithSetupsAndTeardowns(PageData pageData, 
    boolean isSuite) throws Exception {
    boolean isTestPage = pageData.hasAttribute("Test");
    if (isTestPage) {
        WikiPage testPage = pageData.getWikiPage();
        StringBuffer newPageContent = new StringBuffer();
        includeSetupPages(testPage, newPageContent, isSuite);
        newPageContent.append(pageData.getContent());
        includeTeardownPages(testPage, newPageContent, isSuite);
        pageData.setContent(newPageContent.toString());
    }
    return pageData.getHtml();
}

به نظر شما این کد یک کار را انجام می‌دهد؟ قبل از پاسخ دادن به این سوال باید وظایف تابع را بررسی کنیم. این تابع با پردازش pageData قابل تست بودن صفحه را بررسی می‌کند و در صورتی که صفحه از نوع قابل تست باشد، تنظیمات مربوط به آن را اضافه و محتوای جدید را ارسال می‌کند. به نظر می‌رسد وظیفه تشخیص قابل تست بودن صفحه، یک سطح از انتزاع است (تکه کدی که تشخیص می‌دهد صفحه قابل تست است یا خیر) و اعمال تنظیمات یک سطح دیگر (تکه کدی که تنظیمات صفحه قابل تست را اعمال می‌کند). پس تابع را به شکل زیر refactor می‌کنیم:


public static String renderPageWithSetupsAndTeardowns(
    PageData pageData, boolean isSuite) throws Exception {
    if (isTestPage(pageData))
        includeSetupAndTeardownPages(pageData, isSuite);
    return pageData.getHtml();
}

می‌توان این سوال را مطرح کرد که آیا واقعا این تابع یک کار را انجام می‌دهد؟ باید گفت بله ولی همچنین می‌توان این سناریو را مطرح کرد که تابع نه یک کار، بلکه سه کار زیر را انجام می‌دهد:

  • تشخیص این که صفحه یک صفحه تست است.
  • اگر چنین است، تنظیمات را اعمال می‌کند.
  • واکشی HTML

حالا این تابع یک کار انجام می‌دهد یا سه کار؟ نکته این جا است که این سه مرحله در واقع برای انجام یک کار (یک انتزاع) تحت عنوان «RenderPageWithSetupsAndTeardowns» در کنار هم هستند.
پس اگر تابعی با یک نام، فقط یک مرحله از کار را در گام‌های مختلف انجام دهد، می‌توان گفت که آن تابع یک کار را انجام می‌دهد. 
در کد قبلی به صراحت معلوم بود تابع چندین کار را تحت یک نام انجام می‌دهد. حتی در کد اخیر نیز معلوم شد که دو سطح از انتزاع را انجام می‌دهد اما در تکه کد آخر، شکستن تابع به بخش کوچکتر سخت است. شاید بتوان گفت ما می‌توانیم دستور شرطی موجود در کد را استخراج و در قالب نام «includeSetupsAndTeardownsIfTestPage» به عنوان تابعی که یک کار را انجام می‌دهد، پیاده کنیم ولی واقعیت این است که این ساده‌سازی کد، تغییری در سطح انتزاع ایجاد نمی‌کند.

تشخیص کار و استخراج آن به شکل تابع

روش‌هایی برای فهمیدن این که تابع یک کار را انجام می‌دهد یا خیر وجود دارد. در این جا مواردی را ذکر می‌کنیم که به عنوان خط قرمز در پیاده‌سازی تابع شناخته می‌شوند. در صورتی که این موارد در توابع پیاده‌سازی شده وجود داشت، باید در این موضوع که تابع ما یک کار را انجام می‌دهد، شک کنیم.

انتزاع چند سطحی دستورات در تابع

یکی از راه‌های تشخیص این که تابع یک کار انجام می‌دهد یا خیر، این است که در تابع پیاده‌سازی شده باید دستورات در یک سطح از انتزاع قرار داشته باشند. کد قبلی را در نظر بگیرید. واضح است که این قانون رعایت نشده است. مثلا getHtml()از سطح بالایی از انتزاع برخوردار است که در کنار دستوراتی با سطح انتزاع متوسط همچون pagePathName = PathParser.render(pagePath) و دستور سطح پایینی همچون append("\n") قرار گرفته است.
«ترکیب سطوح مختلف از انتزاع داخل تابع، همواره باعث سردرگمی می‌شود.»
در واقع با جداسازی سطوح انتزاع می‌خواهیم توابعی را پیاده‌سازی کنیم که در ابتدا فقط مفاهیم ضروری را بیان می‌کند و در صورت نیاز، می‌تواند وارد جزئیات شود.

کامنت‌ها و خطوط جداساز در تابع

گاهی اوقات هنگام نوشتن تابع، از خطوط خالی یا کامنت برای جدا کردن کارهای مختلف استفاده می‌کنیم. این جداسازی‌ها به ما می‌گوید که تابع بیش از یک کار انجام می‌دهد و باید اصلاح شود. معمولا بخشی که توسط خطوط خالی یا کامنت جدا شده، گزینه خوبی برای جداسازی است.

دستورات کنترلی تو در تو

دستورات کنترلی تو در تو از جمله دستورالعمل‌های تکرار مانند for و while و ... در سه سطح یا حتی دستورات شرطی دو سطحی، نمایانگر این مسئله هستند که تابع از سطح انتزاع پایینی برخوردار است و باید اصلاح شود.

وجود پارامتر بولی (boolean) در تابع flag argument

استفاده از پارامتر بولی (boolean) در توابع کاری نادرست است. در این گونه توابع به صراحت می‌توان گفت که تابع بیش از یک کار انجام می‌دهد. یعنی در صورتی که پارامتر true ارسال شد، یک کار و در غیر این صورت یک کار دیگر انجام می‌دهد. این گونه توابع در خوانایی نیز دچار مشکل هستند. برای مثال فراخوانی متد getFormTemplate(true) برای خوانندگان کد، کمی گیج‌کننده به نظر می‌رسد. با این حال شاید با دیدن امضاء متد به شکل getFormTemplate(bool withHeadre) کمک‌کننده باشد، ولی کافی نیست. بهترین راه‌ حل برای این تابع، پیاده‌سازی دو تابع با نام‌های getFormTemplateWithHeader و getFormTemplateWithoutHeader است. این دو تابع مشکل خوانایی در کد را بر طرف می‌کنند و با یک پیاده‌سازی خوب در تابع، مطمئنا یک کار را انجام خواهند داد.

تابع با عوارض جانبی (side effects)

کد زیر را در نظر بگیرید:


public class UserValidator {
    private Cryptographer cryptographer;

    public boolean checkPassword(String userName, String password) {
        User user = UserGateway.findByName(userName);
        if (user != User.NULL) {
            String codedPhrase = user.getPhraseEncodedByPassword();
            String phrase = cryptographer.decrypt(codedPhrase, password);
            if ("Valid Password".equals(phrase)) {
                Session.initialize();
                return true;
            }
        }
        return false;
    }
}

این تابع قرار است صحت کلمه عبور و نام کاربری را بررسی کند. در صورتی که کلمه عبور و نام کاربری صحیح باشد، مقدار true در غیر این صورت مقدار false را برگشت خواهد داد اما این کد در قسمت‌های دیگر باعث side effect خواهد شد.
فراخوانی Session.initialize()باعث این کار خواهد شد. همان گونه که از نام تابع می‌توان برداشت کرد، این تابع قرار است صحت نام کاربری و کلمه عبور را بررسی کند و در نام تابع ذکر نشده است که قرار است علاوه بر این Session را نیز وهله‌سازی کند. در نتیجه خطوطی که باعث side effect می‌شوند، باید جدا شوند. اگر به هر دلیلی این جداسازی میسر نبود (در یک پیاده‌سازی خوب به ندرت اتفاق می‌افتد)، حداقل با انتخاب نام مناسب برای تابع، این موضوع باید به صراحت ذکر شود. مثلا در این قطعه کد نام checkPasswordAndInitializeSession برای تابع مناسب به نظر می‌رسد؛ فارغ از این که قانون «انجام یک کار» را با این تابع نقض کرده‌ایم.

انجام هم‌زمان یک عمل (command) و برگشت یک نتیجه (query)

انجام همزمان یک command و query توسط تابع، علاوه بر این که قانون «انجام یک کار توسط یک تابع» را نقض می‌کند، باعث سردرگمی نیز خواهد شد. در واقع تابع یا برای تغییر وضعیت objectها پیاده‌سازی می‌شود یا برای ارائه اطلاعات درباره آن‌ها.
امضاء تابع زیر را در نظر بگیرید:


public bool set(String attribute, String value);

این تابع در بدنه خود این وظیفه را پیاده‌سازی کرده است: در صورتی که attribute وجود داشت مقدار آن را به value تنظیم و مقدار true برگشت می‌دهد و در غیر این صورت، مقدار false را برگشت خواهد داد. این تابع باعث به وجود آمدن کد عجیب زیر خواهد شد:


if (set("username", "unclebob"))...

کسی که از محتوای تابع set باخبر نیست از این کد چه برداشتی خواهد داشت؟

  • برداشت ۱: اگر خصوصیت username با موفقیت به unclebob تنظیم شد.
  • برداشت ۲: اگر نام unclebob قبلا به خصوصیت username تنظیم شده باشد.
  • برداشت ۳: اگر خصوصیت username وجود داشت و مقدار آن به unclebob تنظیم شد.

برای حل این مشکل ما می‌توانیم نام تابع را به setAndCheckIfExists تغییر دهیم ولی این موضوع کمک زیادی نخواهد کرد. راه‌ حل واقعی، جداسازی توابع command از توابع query (command query separation) به شکل زیر است:


if (attributeExists("username")) {
    setAttribute("username", "unclebob");
    ...
}

نکته‌ای در مورد command query separation

اشاره شد توابع command نباید نتیجه‌ای را بازگشت دهند و این وظیفه توابع query است. در این حالت، پاسخ به این سوال از اهمیت فراوانی برخوردار است: در صورتی که تابع از نوع command به هر دلیلی نتوانست کار خود را انجام دهد چه؟ نباید به تابع صداکننده اطلاع داده شود که کار با موفقیت انجام شد یا خیر؟
اصولا بازگشت کد خطا در توابع command باعث بروز مشکلاتی می‌شود و باید از این کار اجتناب کرد. از جمله مشکلات می‌توان به دو مورد زیر اشاره کرد:

  • اول: به وجود آمدن دستورات شرطی تو در تو در محلی که تابع صدا زده شده است.
  • دوم: حل بلافاصله مشکل توسط صدازننده، در صورتی که تابع اصلی دچار مشکل شده است.

if (deletePage(page) == E_OK) 
{
    if (registry.deleteReference(page.name) == E_OK) 
    {
        if (configKeys.deleteKey(page.name.makeKey()) == E_OK)
            logger.log("page deleted");
        else
            logger.log("configKey not deleted");
    }
    else 
        logger.log("deleteReference from registry failed");
} 
else 
{
    logger.log("delete failed");
    return E_ERROR;
}

اما راه حل چیست؟ در قطعه کد بالا اگر هر کدام از توابع از نوع command به جای بازگشت کد خطا یک استثناء تولید کنند، به راحتی می‌توان کد را به شکل زیر تغییر داد:


try {
    deletePage(page);
    registry.deleteReference(page.name);
    configKeys.deleteKey(page.name.makeKey());
}
catch (Exception e) {
    logger.log(e.getMessage());
}

نکته‌ای در مورد کد بالا وجود دارد: بهتر است بدنه try و catch به صورت تابع پیاده‌سازی شوند. به شکل زیر:


public void delete(Page page) {
    try 
    {
        deletePageAndAllReferences(page);
    }
    catch (Exception e) {
        logError(e);
    }
}
private void deletePageAndAllReferences(Page page) {
    deletePage(page);
    registry.deleteReference(page.name);
    configKeys.deleteKey(page.name.makeKey());
}

private void logError(Exception e) {
    logger.log(e.getMessage());
}

جداسازی دستورات با پردازش طبیعی از دستورات با پردازش خطادار، باعث کاهش پیچیدگی خواهد شد.

صرف زمان طولانی برای فهمیدن وظیفه تابع

مارتین فاولر در وبلاگ خود می‌گوید: اگر شما برای فهمیدن این که قسمتی از کد چه‌ کاری را انجام می‌دهد زمان صرف کنید، یعنی در تابع مشکلی وجود دارد که باید حل شود. وجود دستوراتی با سطوح انتزاع مختلف و دستورات شرطی یا ساختارهای کنترلی تو در تو، می‌تواند باعث ایجاد پیچیدگی در تابع شود. اگر قسمت‌های مختلف برنامه را با انتخاب نام مناسب به عنوان یک تابع مستقل پیاده‌سازی کنید، هنگام مرور دوباره آن‌ها بلافاصله بعد از دیدن نام تابع متوجه می‌شوید که هدف از این تابع چه بوده است و چه‌ کاری را انجام می‌دهد.

جمع‌بندی

هیچکدام از دستورالعمل‌هایی که ذکر کردیم، درباره تعداد خطوط تابع صحبت نمی‌کنند؛ یعنی هیچکدام نگفته‌اند تابع باید در ۱۰ خط یا ۵ خط پیاده‌سازی شود. تابع یک خطی زیر را در نظر بگیرید:


return level4 != null ? GetResources().Where(r => (r.Level2 == (int)level2) && (r.Level3 == (int)level3) && (r.Level4 == (int)level4)).ToList() : level3 != null ? GetResources().Where(r => (r.Level2 == (int)level2) && (r.Level3 == (int)level3)).ToList() : level2 != null ? GetResources().Where(r => (r.Level2 == (int)level2)).ToList() : GetAllResourceList();

آیا این تابع به خاطر این که در یک خط پیاده‌سازی شده است، تابع خوبی به حساب می‌آید؟ مسلما خیر. پس می‌توان نتیجه گرفت اگر شرایط ذکرشده را رعایت کنیم،‌ تعداد خطوط در توابع مهم نیست. هر چند باید به این نکته نیز اشاره کرد که با رعایت قوانین ذکرشده، تعداد خطوط در توابع به صورت خودکار کاهش می‌یابد و معمولا بیشتر از ۱۲ خط نخواهد بود.