کدنویسی تمیز: فصل جدید،‌ توابع و متدها

Clean Coding: New Chapter, Functions and Methods

18 فروردین 1400
درسنامه درس 7 از سری کدنویسی تمیز
کدنویسی تمیز: فصل جدید،‌ توابع و متد ها (قسمت 07)

تا این قسمت دو فصل را گذرانده ایم که به ترتیب مربوط به «نام گذاری» و «ساختار کد و کامنت ها» بودند اما حالا به فصل سوم از سری آموزشی رسیده ایم که در رابطه با توابع و متدها و تمام مسائل پیرامون آن ها است. این فصل به طور خاص روی نوشتن توابع تمیز تمرکز می کند. یادتان باشد که متدها در واقع توابعی هستند که درون یک شیء قرار دارند و از نظر ساختاری تفاوتی بین متدها و توابع نیست بنابراین تمام موضوعاتی که در این فصل بررسی می شود برای هر دو خواهد بود.

تعریف و فراخوانی توابع

برای اینکه بتوانیم توابع یا متدهای تمیزی بنویسیم باید با دو حالت مختلف آن آشنا باشیم. به تابع ساده زیر توجه کنید:

function add(n1, n2) {

    return n1 + n2;

}

این تعریفِ تابع یک تابع جاوا اسکریپتی است که به آن function definition می گوییم و زمانی که بخواهیم آن را صدا بزنیم چنین چیزی را خواهیم داشت:

add(5, 13)

به این کد، «فراخوانی تابع» یا «صدا زدن تابع» گفته می شود که در انگلیسی function invocation نام  دارد. زمانی که از تمیز بودن توابع صحبت می کنیم منظورمان هر دو قسمت است؛ هم تعریف تابع و هم فراخوانی آن!

کمیت در تعداد پارامترها، کیفیت در خوانایی

برای تمیز نگه داشتن قسمت فراخوانی تابع باید تعداد پارامترهای آن را تا حد ممکن پایین نگه دارید. هر چه تعداد پارامترهای شما در تعریف تابع بیشتر باشد، هنگام صدا زدن آن باید مقادیر بیشتری را پاس بدهید و کد ناخواناتر می شود. بهترین حالت این است که توابع شما هیچ پارامتری نداشته باشند! چطور؟ مثلا اگر متدی به نام ()save را روی شیء user داشته باشیم، هیچ نیازی به پاس دادن پارامتر نیست:

user.save()

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

در قدم بعدی ممکن است یک پارامتر داشته باشید:

log(message)

درک این تابع نیز بسیار راحت است و فراخوانی آن نیز ساده می باشد. حالت بعدی دو پارامتر است:

point(10, 20)

درک این تابع نیز قابل قبول است چرا که تعداد پارامترها زیاد نیستند اما کمی مشکل تر از موارد قبلی است. چرا؟ به دلیل اینکه حالا ترتیب پارامترها مهم خواهد بود! البته برای ساخت یک نقطه (point) می توان حدس زد که به نقاط X و Y احتیاج داریم بنابراین هنوز هم درک آن برای ما مشکل ساز نیست. اگر به سه پارامتر برسیم چطور؟

calc(5, 10, 'subtract')

این تابع سه آرگومان را می گیرد: آرگومان های اول و دوم اعدادی هستند که باید وارد یک نوع عملیات ریاضی شوند و آرگومان سوم، نوع این عملیات ریاضی را مشخص می کند. subtract به معنی «تفریق» است. همانطور که حدس می زنید درک این تابع پیچیده تر شده است و نیاز به صرف زمان و مطالعه دارد. مثلا در حال حاضر معلوم نیست عملیات تفریق به چه صورت انجام می شود؛ آیا ۵ از ۱۰ کمی می شود (نتیجه ۵) یا ۱۰ از ۵ کم می شود (نتیجه ۵-)؟ در این حالت ترتیب آرگومان ها اصلا مشخص نیست و برای ما مشکل ایجاد می کند. در صورتی که می توانید از تعریف چنین توابعی دوری کنید چرا که مشکل ساز خواهند بود.

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

coords(10, 3, 9, 12)

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

کاهش تعداد پارامترها

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

function saveUser(email, password) {

  const user = {

    id: Math.random().toString(),

    email: email,

    password: password,

  };




  db.insert("users", user);

}




saveUser("test@email.com", "mypassword");

این کد ایمیل و رمز عبور کاربری را گرفته و کاربر را در پایگاه داده ذخیره می کند. مسئله اینجاست که تابع ما دو پارامتر دارد بنابراین باید ترتیب ایمیل و رمز عبور را بدانیم. با اینکه کد بالا به هیچ عنوان کد بدی نیست اما به خاطر سپردن ترتیب پارامترها نکته ای منفی حساب می شود. چطور؟ در تابع بالا هم شیء کاربر را ایجاد کرده ایم و هم آن را در پایگاه داده ذخیره کرده ایم. ما می توانیم این عملیات را با استفاده از دو تابع انجام بدهیم تا این تابع فقط یک کاربر را ذخیره کند:

function saveUser(user) {

  db.insert("users", user);

}




saveUser(newUser);

یعنی ساخت شیء کاربر را به یک تابع دیگر بسپاریم. روش دیگر و حتی بهتری که داریم استفاده از کلاس ها است تا چنین کدی را به دست بیاوریم:

class User {

  constructor(email, password) {

    this.email = email;

    this.password = password;

    this.id = Math.random().toString();

  }




  save() {

    db.insert("users", this);

  }

}




const user = new User("test@email.com", "mypassword");

user.save();

همانطور که می بینید در این مثال متد save بدون نیاز به هیچ آرگومانی صدا زده می شود بنابراین از نظر خوانده شدن بسیار تمیزتر و بهتر است. طبیعتا راه های دیگری نیز وجود دارد. به طور مثال می توانیم تابعی عادی داشته باشیم و یک شیء را که حاوی email و password است را به عنوان آرگومان دریافت کنیم.

البته این مسئله به وضعیت کدهای شما بستگی دارد. به طور مثال به کد زیر توجه کنید:

function log(message) {

  console.log(message);

}




log('Hi there!');

این کد مسئول log کردن یک مقدار خاص است و از نظر ظاهری هیچ مشکلی ندارد. از نظر من نوشتن این کد با استفاده از کلاس ها کار جالبی نیست چرا که تعداد خطوط کد بیشتر می شود و نیازی نیست برای کاری ساده، کدهایی پیچیده بنویسیم:

class Message {

  constructor(message) {

    this.message = message;

  }




  log() {

    console.log(this.message);

  }

}




const msg = new Message('Hi!');

msg.log();

همانطور که در این کد می بینید هم زحمت تعریف کلاس و هم زحمت استفاده از آن بسیار بیشتر از یک تابع معمولی است.

به عنوان قانونی کلی می توان در نظر داشت که اگر توابع در هنگام فراخوانی مشخص نمی شوند یعنی امکان ارتقاء کدها وجود دارد. به طور مثال:

log('hi there', false);

در اینجا مشخص است که log می خواهد یک مقدار خاص را log کند بنابراین درک آرگومان اول ساده است ما آرگومان دوم (false) از کجا آمده و معنی آن چیست؟ برای درک این کد مجبور هستیم به سورس کد مراجعه کنیم تا متوجه بشویم که آرگومان دوم چه کاری را انجام می دهد:

function log(message, isError) {

  if (isError) {

    console.error(message);

  } else {

    console.log(message);

  }

}




log("hello there", false);

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

function log(message) {

  console.log(message);

}




function logError(message) {

  console.error(message);

}




log("hello there");

logError("something went wrong");

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

مسئله دیگر حالت هایی است که در آن ها با تعداد مقادیر زیادی روبرو هستیم و نمی توانیم آن ها را کمتر کنیم یا تابع را به توابع کوچکتری بشکنیم. به مثال زیر توجه کنید:

class User {

  constructor(name, age, email) {

    this.name = name;

    this.age = age;

    this.email = email;

  }

}




const user = new User("Amir", 25, "amir@email.com");

هر کاربر نام، سن و ایمیل خاصی دارد و ما نمی توانیم این مقادیر را به مقادیر کوچکتری بشکنیم بنابراین باید به دنبال راه بهتری باشیم. باز هم می گویم که کد بالا کد بدی محسوب نمی شود اما هنوز جای ارتقاء دارد چرا که ترتیب سن و ایمیل و نام در هنگام صدا زدن آن مشخص نیست. از شما می خواهم که خودتان فکر کرده و راه حلی را برای این مشکل پیدا کنید. من قبلا در همین مقاله به راه حل اشاره کرده بودم؛ به جای اینکه مقادیر را به صورت جداگانه پاس بدهیم می توانیم آن ها را در قالب یک شیء واحد ارسال کنیم!

class User {

  constructor(userData) {

    this.name = userData.name;

    this.age = userData.age;

    this.email = userData.email;

  }

}




const user = new User({ name: "Amir", age: 25, email: "amir@email.com" });

حالا اگر به این کد نگاه کنید متوجه تفاوت زیاد آن می شوید. در عمل هر دو کد یک کار را انجام می دهند و از نظر سرعت و بهینه بودن هیچ تفاوتی ندارند اما این کد بسیار خواناتر است.

یک مثال دیگر را نیز برایتان آماده کرده ام. متد زیر مسئول مقایسه مقادیر مختلف است و به ما گزارش می دهد که یک مقدار از مقادیر دیگر کوچک تر یا بزرگ تر و ... است:

function compare(a, b, comparator) {

  if (comparator === 'equal') {

    return a === b;

  } else if (comparator === 'not equal') {

    return a !== b;

  } else if (comparator === 'greater') {

    return a > b;

  } else if (comparator === 'smaller') {

    return a < b;

  }

}




const isSmaller = compare(3, 5, 'smaller');

const isEqual = compare(3, 5, 'equal');

این تابع مسئول مقایسه دو عدد است بنابراین راهی برای شکستن آن به توابع کوچک تر نداریم. تنها راه حلی که برایمان باقی می ماند این است که تعداد پارامترهای این تابع را کمتر کنیم تا دقیقا مشخص شود مقادیر پاس داده شده چه مقادیری هستند. مثلا اگر اعداد ۳ و ۵ را مانند بالا پاس بدهیم و smaller را نیز به عنوان آرگومان سوم پاس داده باشیم، تابع به چه صورتی کار مقایسه را انجام می دهد؟ آیا ۵ را با ۳ مقایسه می کند یا ۳ را با ۵ مقایسه می کند؟ این مسئله در نتیجه نهایی تاثیر بزرگی دارد؛ به طور مثال ۵ از ۳ کوچک تر نیست اما ۳ از ۵ کوچک تر است.

سعی کنید خودتان این کار را انجام بدهید و سپس پاسخ خود را با مثال زیر مقایسه کنید:

function compare(comparisonData) {

  const { first, second, comparator } = comparisonData;

  if (comparator === 'equal') {

    return first === second;

  } else if (comparator === 'not equal') {

    return first !== second;

  } else if (comparator === 'greater') {

    return first > second;

  } else if (comparator === 'smaller') {

    return first < second;

  }

}




const isSmaller = compare({ first: 3, second: 5, comparator: 'smaller' });

const isSmaller = compare({ comparator: 'equal', first: 3, second: 5 });

با انجام این کار به راحتی می فهمیم که عدد اول (first) و عدد دوم (second) کدام اعداد هستند و مقایسه به چه صورتی انجام می شود.

تعداد پارامترهای نامحدود

همانطور که می دانید ما می توانیم در بسیاری از زبان های برنامه نویسی مانند جاوا اسکریپت و پایتون تعداد پارامترهای نامحدودی را داشته باشیم. در جاوا اسکریپت این کار را با نوشتن علامت سه نقطه در کنار نام پارامتر انجام می دهیم. به مثال زیر توجه کنید:

function sumUp(...numbers) {

  let sum = 0;

  for (const number of numbers) {

    sum += number;

  }

  return sum;

}




const total = sumUp(10, 19, -3, 22, 5, 100);

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

const total = sumUp([10, 19, -3, 22, 5, 100]);

اما من شخصا ترجیحی برای آن ندارم و به نظرم همان حالت اول و پاس دادن عادی اعداد مناسب است چرا که حتی ترتیب اعداد پاس داده شده هیچ اهمیتی ندارد.

پارامترهای خروجی

پارامترهای خروجی پارامترهایی هستند که برای ورودی و پردازش داده گرفته نمی شوند بلکه به صورت یک مقدار خروجی برگردانده می شوند. یعنی چه؟ فرض کنید تابعی به نام createId داشته باشیم که مسئول ساخت یک ID برای یک کاربر باشد:

createId(user)

user در اینجا باید پارامتر ورودی باشد، یعنی تابع ما شیء user (کاربر) را گرفته و برایش یک ID تولید کرده و سپس آن ID را برایمان برگرداند اما در برخی اوقات ممکن است تابعی داشته باشیم که User در آن پارامتر خروجی باشد! یعنی چه؟ یعنی تابع شیء user را گرفته و یک فیلد id را نیز به آن اضافه می کند و نهایتا کل شیء user جدید را برایمان برمی گرداند. مثال:

function createId(user) {

  user.id = "u1";

}




const user = { name: "Amir" };

createId(user);




console.log(user);

همانطور که می بینید این تابع یک فیلد جدید به نام id را به شیء user اضافه می کند. انجام این کار یعنی استفاده از پارامترهای خروجی، و به دلیل ایجاد گمراهی برای دیگر توسعه دهندگان پارامترهای خروجی باید تا حد ممکن کنار گذاشته شوند. گرچه برخی از اوقات به دلیل کار با برخی از فریم ورک ها و کتابخانه ها نمی توانیم آن ها را صد در صد کنار بگذاریم. اگر نمی توانید از پارامترهای خروجی دوری کنید حداقل باید آن ها را بشناسید و نام توابع خود را به صورتی انتخاب کنید که نشان دهنده این موضوع باشند. به طور مثال به جای createId (ساخت شناسه) از addId (اضافه کردن شناسه) استفاده کنید.

حالت بهتر (در صورتی که باید از پارامترهای خروجی استفاده کنید) این است که همین کد را به شکل شیء گرا بنویسیم:

class User {

  constructor(name) {

    this.name = name;

  }




  addId() {

    this.id = "u1";

  }

}




const customer = new User("Amir");

customer.addId();

console.log(customer);

در این روش خوانایی کد بیشتر می شود چرا که حداقل می دانیم هنوز شیء customer را داریم و با صدا زدن addId احتمالا یک id به این شیء اضافه شده است. در چنین روشی، ویرایش شدن شیء اصلی کاملا مشخص است و به اندازه حالت قبلی بد نیست.

بدنه تابع

همانطور که گفتم زمانی که صحبت از نوشتن کدهای تمیز در توابع می شود، می توانیم هر تابع را به دو قسمت function definition و function invocation (تعریف و فراخوانی تابع) تقسیم کنیم. یکی از مهم ترین قوانینی که در رابطه با توابع تمیز وجود دارد، طول آن ها یا همان حجم آن ها است. تابع زیر را در نظر بگیرید:

function renderContent(renderInformation) {

  const element = renderInformation.element;

  if (element === 'script' || element === 'SCRIPT') {

    throw new Error('Invalid element.');

  }




  let partialOpeningTag = '<' + element;




  const attributes = renderInformation.attributes;




  for (const attribute of attributes) {

    partialOpeningTag =

      partialOpeningTag + ' ' + attribute.name + '="' + attribute.value + '"';

  }




  const openingTag = partialOpeningTag + '>';




  const closingTag = '</' + element + '>';

  const content = renderInformation.content;




  const template = openingTag + content + closingTag;




  const rootElement = renderInformation.root;




  rootElement.innerHTML = template;

}

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

اگر وقت گذاشته باشید حتما متوجه می شوید که درک این تابع آزار دهنده است چرا که بسیار طولانی است. کار تابع نمایش یا render کردن عناصر HTML در صفحات HTML است. اگر بخواهم به صورت خلاصه نحوه کارش را برایتان توضیح بدهم اینطور خواهد بود که ما شیء ای به نام renderInformation را به صورت یک پارامتر دریافت می کنیم و از آن خصوصیت element را جدا می کنیم. این خصوصیت، نام عنصر HTML ما را خواهد داشت. در ابتدا بررسی می کنیم تا عنصر مورد نظر script نباشد چرا که عنصر script باعث اجرای کدهای جاوا اسکریپت در صفحات ما شده و اجرای آن ها به صورت خودکار بسیار خطرناک است. در مرحله بعدی attribute های HTML را نیز از همان شیء renderInformation استخراج کرده و به عنصر خودمان می چسبانیم. در مرحله بعدی محتوای درون تگ (content در کد بالا) را درون تگ قرار داده و نهایتا آن را وارد HTML می کنیم.

اگر بخواهیم منصفانه به این تابع نگاه کنیم باید گفت که قوانین نام گذاری در آن به خوبی رعایت شده است و همچنین formatting یا فاصله گذاری ها نیز به درستی انجام شده اند اما این تابع هنوز هم به اندازه کافی تمیز نیست. اگر توسعه دهنده دیگری نگاهی به این تابع بیندازد باز هم همه چیز برایش شلوغ و در هم ریخته خواهد بود. چرا؟ به دلیل اینکه حجم کدهای درون تابع زیاد است بنابراین خواندن آن ها و درکشان زمانی نسبتا طولانی خواهد برد. اگر بخواهیم این تابع را به صورت تمیز بنویسیم باید آن را به توابع دیگر بشکنیم:

function renderContent(renderInformation) {

  const element = renderInformation.element;

  const rootElement = renderInformation.root;




  validateElementType(element);




  const content = createRenderableContent(renderInformation);




  renderOnRoot(rootElement, content);

}

اگر به جای تابع قبلی، این تابع را به یک توسعه دهنده بدهیم، بسیار راحت تر متوجه کارکرد تابع خواهد شد. در این تابع شیء renderInformation را گرفته ایم و سپس element یا عنصر مورد نظر را از آن جدا کرده ایم. در مرحله بعدی root یا عنصر اصلی صفحه HTML خود را نیز به دست آورده ایم. در مرحله بعدی تابعی به نام validateElementType داریم که به معنی «اعتبارسنجی نوع عنصر» است بنابراین کارش مشخص است. توابع بعدی نیز createRenderableContent (ساخت محتوای قابل نمایش) و renderOnRoot (نمایش روی عنصر ریشه ای یا root) هستند که تنها با خواندن نامشان متوجه کارکردشان می شوید. درک این تابع به مراتب ساده تر از تابع قبلی است.

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

function renderContent(renderInformation) {

  const element = renderInformation.element;

  const rootElement = renderInformation.root;




  validateElementType(element);




  const content = createRenderableContent(renderInformation);




  renderOnRoot(rootElement, content);

}




function validateElementType(element) {

  if (element === 'script' || element === 'SCRIPT') {

    throw new Error('Invalid element.');

  }

}




function createRenderableContent(renderInformation) {

  const tags = createTags(

    renderInformation.element,

    renderInformation.attributes

  );

  const template = tags.opening + renderInformation.content + tags.closing;

  return template;

}




function renderOnRoot(root, template) {

  root.innerHTML = template;

}




function createTags(element, attributes) {

  const attributeList = generateAttributesList(attributes);

  const openingTag = buildTag({

    element: element,

    attributes: attributeList,

    isOpening: true,

  });

  const closingTag = buildTag({

    element: element,

    isOpening: false,

  });




  return { opening: openingTag, closing: closingTag };

}




function generateAttributesList(attributes) {

  let attributeList = '';

  for (const attribute of attributes) {

    attributeList = `${attributeList} ${attribute.name}="${attribute.value}"`;

  }




  return attributeList;

}




function buildTag(tagInformation) {

  const element = tagInformation.element;

  const attributes = tagInformation.attributes;

  const isOpeningTag = tagInformation.isOpening;




  let tag;

  if (isOpeningTag) {

    tag = '<' + element + attributes + '>';

  } else {

    tag = '</' + element + '>';

  }




  return tag;

}

با اینکه حجم این کدها در کل زیاد است اما هر کدام از توابع از حدود خاص خودش تجاوز نمی کند. مسئله اصلی اینجاست که توسعه دهنده فرضی ما می خواهد بداند تابع renderContent چه وظیفه ای دارد. اگر کدها را بدین شکل بنویسیم، توسعه دهنده با یک نگاه می تواند متوجه نحوه کار renderContent شود و معمولا هم نیازی به بررسی توابع دیگر نیست، گرچه این امکان نیز وجود دارد و هر کدام از توابع زیردست نیز ساده و قابل درک هستند. در بسیاری از اوقات توسعه دهندگان به همین شکل کار می کنند و جزئیات بسیاری از توابع برایشان مهم نیست. شکستن توابع بزرگ به توابع کوچکتر به این موضوع کمک می کند.

سطوح عملیات

قانون کلی در مورد کارکرد توابع بدین صورت است که هر تابع باید کار خاصی را انجام بدهد. احتمالا می پرسید «کار» را چطور تعریف می کنیم؟ منظور ما از «کار» عملیاتی سطح بالا است. یعنی چه؟ به طور مثل اعتبارسنجی ایمیل و ذخیره کردن کاربر در پایگاه داده از نظر انتزاع دو عملیات سطح بالا هستند اما بررسی وجود علامت @ در ایمیل عملیاتی سطح پایین است یا به زبان ساده بیش از حد جزئی است. در عملیات های سطح بالا ما خبری از نحوه انجام عملیات نداریم و به دنبال چگونگی جزئیات نیستیم بلکه تنها نتیجه کار را می دانیم. مثلا در ذخیره کاربر در پایگاه داده فقط می دانیم که پایگاه داده کاربر را دریافت کرده و ذخیره می کند اما از نظر سطح پایین ابتدا کاربر به درایور پایگاه داده پاس داده می شود و این درایور با سرور پایگاه داده ارتباط برقرار می کند و الی آخر.

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

function emailIsValid(email) {

    return email.includes('@');

}

در مثال بالا تابعی به نام emailIsValid را داریم که به معنی «ایمیل معتبر است» می باشد. طبیعتا این یک کد ساده است که فقط برای مثال استفاده شده است و هیچ گاه اعتبارسنجی ایمیل ها بدین صورت ساده نیست. ما در این کد وجود علامت @ در ایمیل را بررسی می کنیم که یک کد سطح پایین است. این عملیت سطح پایین، یک سطح پایین تر از سطح «ایمیل معتبر است» می باشد. با اینکه این کد سطح پایین است اما به دلیل نامی که برای تابع گذاشته ایم، معنا پیدا می کند. تابع includes که یکی از توابع جاوا اسکریپتی پیش ساخته است معنی خاصی ندارد و وجود یک یا چند کاراکتر خاص در یک رشته را بررسی می کند. این ما هستیم که به عنوان توسعه دهنده به این تابع معنای خاصی می دهیم. بنابراین کد سطح پایین بد نیست و امکان ندارد بدون نوشتن کد سطح پایین برنامه ای را راه اندازی کنیم اما باید قانونی که در مثال بالا برایتان آوردم را به خاطر بسپارید تا کدهایتان تمیز باشد.

در عین حال اگر تابعی به نام saveUser داشتیم که عملیات اعتبارسنجی و ذخیره کاربر در پایگاه داده را در سطح پایین انجام می داد، کدی بسیار ناخوانا داشتیم و قانون ذکر شده را رعایت نمی کردیم. saveUser شامل تمام مراحلی می شود که به ذخیره کاربر ختم می شوند (دریافت اطلاعات، اعتبارسنجی، ذخیره، گزارش دهی) بنابراین سطحِ نامِ تابع (saveUser) سطح بالا است و تفاوت بسیار زیادی با عملیات های سطح پایینی دارد که درونش انجام می شوند.

نکته نهایی در رابطه با سطوح مختلف عملیات این است که نباید سطوح مختلف عملیاتی را با یکدیگر ترکیب کنید. به مثال زیر توجه کنید:

if (!email.includes('@')) {

    console.log('invalid email');

} else {

    const user = new User(email);

    user.save();

}

در اینجا کد سطح پایینی مانند email.includes را در کنار کد سطح بالایی مانند user.save قرار داده ایم که از نظر خوانایی و تمیز بودن کد اصلا بهینه نیست و توسعه دهندگان دیگر باید برای درک آن وقت بگذارند. این در حالی است که می توانستیم کد بالا را به صورت زیر بنویسیم:

if (!isEmail(email)) {

    showError('invalid email');

} else {

    saveNewUser(email);

}

در این حالت تمام عملیات ها در یک سطح هستند (همه سطح بالا هستند) بنابراین خواندن آن برای دیگران سریع تر و راحت تر است. زمانی که چندین سطح مختلف از کد را به صورت یکجا داشته باشیم ذهن خواننده برای خواندن این کد نیاز به دنده عوض کردن دارد و باید بین این سطوح جا به جا شود. در مثال ساده ای مانند مثال بالا این امر آنچنان مشخص نمی شود اما اگر کدهایی طولانی داشته باشید آنگاه اذیت خواهید شد. شما می توانید این موضوع را در تابع اصلی ما ببینید:

function renderContent(renderInformation) {

  const element = renderInformation.element;

  if (element === 'script' || element === 'SCRIPT') {

    throw new Error('Invalid element.');

  }




  let partialOpeningTag = '<' + element;




  const attributes = renderInformation.attributes;




  for (const attribute of attributes) {

    partialOpeningTag =

      partialOpeningTag + ' ' + attribute.name + '="' + attribute.value + '"';

  }




  const openingTag = partialOpeningTag + '>';




  const closingTag = '</' + element + '>';

  const content = renderInformation.content;




  const template = openingTag + content + closingTag;




  const rootElement = renderInformation.root;




  rootElement.innerHTML = template;

}

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

function renderContent(renderInformation) {

  const element = renderInformation.element;

  const rootElement = renderInformation.root;




  validateElementType(element);




  const content = createRenderableContent(renderInformation);




  renderOnRoot(rootElement, content);

}

در این مثال نام تابع renderContent است که یک نام با سطح بسیار بالا است اما تمام عملیات های انجام شده در آن یک سطح از این نام پایین تر هستند بنابراین فاصله زیادی بین آن ها نیست.

یادگیری کامل این مبحث انتزاعی برای همه آسان نیست و با تجربه به دست می آید اما فکر کردن به آن ارزش دارد. حتی ممکن است پس از سال های سال کدنویسی و تجربه باز هم توابعی بنویسید که با قوانین توضیح داده شده در این دوره همخوانی نداشته باشند اما مهم این است که تا حد ممکن تلاش کنید و نسبت به این قوانین آگاهی داشته باشید.

چه زمانی توابع را بکشنیم؟

با توجه به مباحث توضیح داده شده در این قسمت، با سوالی مهم روبرو می شویم: چه زمانی توابع را شکسته و چه زمانی آن ها را ادغام کنیم؟ من دو قانون کلی را در این رابطه به شما نشان می دهم اما یادتان باشد که این قوانین کلی هستند و ممکن است بسته به شرایط و نیاز های شما تغییر کنند.

قانون اول: کدهایی که روی عملیات یکسانی کار می کنند را ادغام کنید. به طور مثال اگر دو تابع به نام های user.setAge و user.setName داشتید که به ترتیب سن کاربر و نام کاربر را به روز رسانی می کنند، می توانید هر دو را در تابعی به نام user.update ادغام کنید؛ یعنی می توانید توابع setAge و setName را حذف کرده و تابع جدیدی به نام update بنویسید که کار به روز رسانی را انجام دهد. مثال:

user.update({age: 25, name: "Amir"})

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

if (!email.includes('@')) {

    console.log('invalid email');

} else {

    const user = new User(email);

    user.save();

}

در این کد تابعی مانند user.save ساده و مشخص است اما تابعی مانند email.includes نیاز به تفسیر بیشتر دارد. به طور مثال اگر به جای @ از دامنه ایمیل استفاده کرده بودیم بدین معنی بود که سایت ما فقط ایمیل های دامنه خاصی را قبول می کند. این همان مسئله سطوح کدها است و توضیح دادم که کدهای سطح پایینی مانند includes به صورت پیش فرض نمی توانند معنی خاصی داشته باشند بلکه باید تفسیر شوند تا هدفشان را بفهمیم.

چالش و تمرین

تا این قسمت از بحث چندین مثال مختلف را به شما نشان دادیم بنابراین حالا زمان حل تمرین رسیده است. من کد ساده ای را برای شما آماده کرده ام:

function createUser(email, password) {

  if (!email || !email.includes("@") || !password || password.trim() === "") {

    console.log("invalid input!");

    return;

  }




  const user = {

    email: email,

    password: password,

  };




  database.insert(user);

}

این تابع مسئول ساخت یک کاربر است و دو پارامتر دارد. من با تعداد پارامترها هیچ مشکلی ندارم بنابراین مشغول بهینه سازی آن نشوید. اگر نگاه ساده ای به این تابع بیندازید متوجه می شوید که به دلیل کوتاه بودنش، ناخوانا نیست اما در عین حال اصلا تمیز و استاندارد نوشته نشده است. چرا؟ به دلیل اینکه تابع در حال انجام چندین کار مختلف است و سطوح مختلفی از کد را نیز داریم. اگر به بدنه این تابع توجه کنید متوجه تفاوت زیادی بین سطح نام تابع و سطح کدهای درون آن خواهید شد. مثلا بررسی برای وجود @ در آدرس ایمیل به صورت خودکار به معنی اعتبارسنجی نیست و دیگران که کد ما را می خوانند باید دست به تفسیر بزنند تا متوجه این موضوع بشوند. همچنین console.log در این کد لزوما به معنای نمایش خطا نیست بلکه باید از روی متن log شده تفسیر کنیم که قصد نویسنده کد نمایش خطا به کاربر بوده است. تمام این ها در حالی است که database.insert نیازی به تفسیر و تامل ندارد و کاری که می کند کاملا مشخص است. من از شما می خواهم که کد بالا را به شکل استاندارد و خوانا ویرایش کنید و سپس می توانید به پاسخ من نگاه کنید.

در قدم اول می بینیم که سطوح مختلفی از کد را داریم و اولین نمونه از آن کدهای سطح پایین اعتبارسنجی هستند بنابراین بهتر است آن ها را جدا کنیم و به تابع خودشان منتقل کنیم:

function createUser(email, password) {

  if (!inputIsValid(email, password)) {

    console.log("invalid input!");

    return;

  }




  const user = {

    email: email,

    password: password,

  };




  database.insert(user);

}




function inputIsValid(email, password) {

  return email && email.includes("@") && password && password.trim() !== "";

}

همانطور که می بینید من تابعی به نام inputIsValid را تعریف کرده ام که ایمیل و رمز عبور را گرفته و عملیات اعتبارسنجی را روی آن انجام می دهد. حالا تمام کدهای درون inputIsValid سطحی یکسان دارند و یک سطح پایین تر از نام تابع (inputIsValid) هستند. یکی دیگر از عملیات های سطح پایین در تابع createUser استفاده از console.log است بنابراین می توانیم آن را نیز خارج کرده و در تابع خودش قرار بدهیم:

function createUser(email, password) {

  if (!inputIsValid(email, password)) {

    showErrorMessage("input is invalid!");

    return;

  }




  const user = {

    email: email,

    password: password,

  };




  database.insert(user);

}




function inputIsValid(email, password) {

  return email && email.includes("@") && password && password.trim() !== "";

}




function showErrorMessage(message) {

  console.log(message);

}

مزیت انجام این کار علاوه بر یکسان کردن سطح کدها در تابع createUser دو مورد است:

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

در انتها به کد database.insert نگاهی می اندازیم. ممکن است شما بگویید که این کد در سطح یکسانی نسبت به دیگر کدهای تابع قرار دارد بنابراین نیازی به استخراج آن نیست و حرفتان کاملا درست خواهد بود اما در چنین موارد ریزی وارد سلیقه می شویم؛ یعنی ممکن است فرد دیگری بگوید database.insert سطح بالاتر از showErrorMessage است و حرف او نیز صحیح خواهد بود بنابراین انتخاب بر عهده خودتان است. من تصمیم می گیرم که این کد را نیز بشکنم:

function createUser(email, password) {

  if (!inputIsValid(email, password)) {

    showErrorMessage("input is invalid!");

    return;

  }




  saveUser(email, password);

}




function inputIsValid(email, password) {

  return email && email.includes("@") && password && password.trim() !== "";

}




function showErrorMessage(message) {

  console.log(message);

}




function saveUser(email, password) {

  const user = {

    email: email,

    password: password,

  };




  database.insert(user);

}

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

function createUser(email, password) {

  validateInput(email, password);




  saveUser(email, password);

}




function validateInput(email, password) {

  if (!inputIsValid(email, password)) {

    throw new Error("Invalid Input!");

  }

}




function inputIsValid(email, password) {

  return email && email.includes("@") && password && password.trim() !== "";

}




function showErrorMessage(message) {

  console.log(message);

}




function saveUser(email, password) {

  const user = {

    email: email,

    password: password,

  };




  database.insert(user);

}

همانطور که می بینید ما تابع جدیدی به نام validateInput را تعریف کرده ایم که مسئول پرتاب یک خطا است. شاید بگویید با این خطا چه کار خواهیم کرد؟ من این کد را در مقیاس بزرگ تری قرار می دهم تا درک این مسئله برایتان ساده تر شود:

function handleCreateUserRequest(request) {

  try {

    createUser('test@test.com', 'testers');

  } catch (error) {

    showErrorMessage(error.message);

  }

}




function createUser(email, password) {

  validateInput(email, password);




  saveUser(email, password);

}




function validateInput(email, password) {

  if (!inputIsValid(email, password)) {

    throw new Error('Invalid input!');

  }

}




function inputIsValid(email, password) {

  return email && email.includes('@') && password && password.trim() !== '';

}




function showErrorMessage(message) {

  console.log(message);

}




function saveUser(email, password) {

  const user = {

    email: email,

    password: password,

  };




  database.insert(user);

}

تابع handleCreateUserRequest جایی است که تابع createUser را صدا می زنیم بنابراین آن را در یک بلوک try & catch قرار داده ایم تا خطای احتمالی پرتاب شده را دریافت کرده و متن آن را به showErrorMessage پاس بدهیم.

قاعده DRY

یکی دیگر از مزایای اصلی شکستن توابع به واحد های کوچک تر این است که می توانید از آن ها در قسمت های دیگر برنامه خودتان نیز استفاده کنید. در برنامه نویسی یک قانون بسیار مهم وجود دارد که به قانون DRY معروف است که مخفف Don't Repeat Yourself بوده و به معنی «حرفت را تکرار نکن» می باشد. نکته مهم این قانون در استفاده دوباره است و بر اساس آن نباید یک کد یکسان را داشته باشیم که چندین بار در قسمت های مختلف برنامه نوشته شده است.

اگر کدهای تکراری داشته باشید، مدیریت و نگهداری از آن ها بسیار سخت می شود. تصور کنید که منطق خاصی برای ثبت داده در پایگاه داده را در ۳۰ قسمت مختلف از کدهای خود تکرار کرده اید اما حالا ساختار داده تغییر کرده است و این منطق نیز باید ویرایش شود. در این حالت باید ۳۰ بار کد خود را ویرایش کنید که به شدت آزار دهنده است و احتمال بروز خطا را به شدت بالا می برد.

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

تمام فصل‌های سری ترتیبی که روکسو برای مطالعه‌ی دروس سری کدنویسی تمیز توصیه می‌کند:
نویسنده شوید
دیدگاه‌های شما

در این قسمت، به پرسش‌های تخصصی شما درباره‌ی محتوای مقاله پاسخ داده نمی‌شود. سوالات خود را اینجا بپرسید.