در جلسه قبل به بررسی برخی از قوانین کدنویسی تمیز در توابع و متدها اشاره کردیم اما هنوز نکاتی باقی مانده است که باید در این جلسه بررسی کنیم.
در برنامه نویسی یک قانون بسیار مهم وجود دارد که به قانون DRY معروف است که مخفف Don't Repeat Yourself بوده و به معنی «حرفت را تکرار نکن» می باشد. نکته مهم این قانون در استفاده دوباره از کدها است و بر اساس آن نباید یک کد یکسان را داشته باشیم که چندین بار در قسمت های مختلف برنامه نوشته شده است. اگر کدهای تکراری داشته باشید، مدیریت و نگهداری از آن ها بسیار سخت می شود. تصور کنید که منطق خاصی برای ثبت داده در پایگاه داده را در ۳۰ قسمت مختلف از کدهای خود تکرار کرده اید اما حالا ساختار داده تغییر کرده است و این منطق نیز باید ویرایش شود. در این حالت باید ۳۰ بار کد خود را ویرایش کنید که به شدت آزار دهنده است و احتمال بروز خطا را به شدت بالا می برد.
من یکی از کدهای جلسه قبل را با کمی ویرایش برایتان آماده کرده ام. در این کد مثالی ساده از عدم تبعیت از DRY را مشاهده می کنید:
function createUser(email, password) { if (!inputIsValid(email, password)) { showErrorMessage('Invalid input!'); return; } saveUser(email, password); } function createSupportChannel(email) { if (!email || !email.includes('@')) { console.log('Invalid email - could not create channel'); } // ... } function inputIsValid(email, password) { return email && email.includes("@") && password && password.trim() !== ""; } function passwordIsValid(password) { return password && password.trim() !== ''; } function showErrorMessage(message) { console.log(message); } function saveUser(email, password) { const user = { email: email, password: password, }; database.insert(user); }
به تابع createSupportChannel از کد بالا توجه کنید. این تابع قرار است یک کانال پشتیبانی بین ما و کاربر ما ایجاد کند تا بتوانیم با هم در ارتباط باشیم (البته کدهای درون آن واقعی نیست و یک log ساده است). قبل از اینکه بخواهیم این کانال پشتیبانی را ایجاد کنیم باید ایمیل کاربر را اعتبارسنجی کنیم بنابرین به همان روش جلسه قبل وجود علامت @ را درون آن بررسی کرده ایم. عملیات اعتبارسنجی ایمیل و رمز عبور کار مخصوص تابعی به نام inputIsValid است. همچنین خود عملیات log کردن پیام نیز تکراری است چرا که ما تابعی به نام showErrorMessage را داشتیم که مسئول چاپ پیام خطا بود.
من در جلسه قبل برایتان توضیح دادم که یکی از بهترین روش های تشخیص عدم تبعیت از قاعده DRY این است که شما کدهایتان را از بخشی کپی کرده و در بخش دیگری پیست می کنید. باید به یاد داشته باشید که این تنها روش تشخیص کد نیست. مثلا در کد بالا ما کپی پیست نکرده ایم اما عملیات خاصی (اعتبارسنجی ایمیل) را تکرار کرده ایم بنابراین DRY به طور انحصاری به معنی تکرار کلمات نیست بلکه به معنی تکرار بی جهت یک منطق ساده نیز می باشد.
ما برای اصلاح این موضوع می توانیم ابتدا به جای استفاده از console.log از تابع showErrorMessage استفاده کنیم. همچنین تابع inputIsValid در حال حاضر هم ایمیل و هم رمز عبور را اعتبارسنجی می کند اما ما در تابع createSupportChannel تنها به اعتبارسنجی ایمیل نیاز داریم. بهترین راه حل برای این مشکل شکستن تابع InputIsValid به دو تابع جداگانه است تا ایمیل و رمز عبور را جداگانه بررسی کنیم. آیا اگر بخواهیم یک تابع جداگانه به نام emailIsValid بسازیم و سپس آن را درون تابع InputIsValid استفاده کنیم، مشکلی خواهد بود؟ مثلا:
function createUser(email, password) { if (!inputIsValid(email, password)) { showErrorMessage('Invalid input!'); return; } saveUser(email, password); } function createSupportChannel(email) { if (!emailIsValid(email)) { showErrorMessage('Invalid email - could not create channel'); } // ... } function inputIsValid(email, password) { return emailIsValid(email) && password && password.trim() !== ''; } function emailIsValid(email) { return email && email.includes('@'); } function showErrorMessage(message) { console.log(message); } function saveUser(email, password) { const user = { email: email, password: password, }; database.insert(user); }
مشکل این کد اینجاست که در تابع InputIsValid تفاوت سطوح کد را داریم؛ در یک طرف یک تابع (emailIsValid) قرار داشته و در طرف دیگرش به صورت دستی رمز عبور را چک کرده ایم. همانطور که گفتم بهتر در تمام توابع خودمان یک سطح خاص از کد را داشته باشیم بنابراین می توانیم کد بالا را به شکل زیر بازنویسی کنیم:
function createUser(email, password) { if (!inputIsValid(email, password)) { showErrorMessage('Invalid input!'); return; } saveUser(email, password); } function createSupportChannel(email) { if (!emailIsValid(email)) { showErrorMessage('Invalid email - could not create channel'); } // ... } function inputIsValid(email, password) { return emailIsValid(email) && passwordIsValid(password); } function emailIsValid(email) { return email && email.includes('@'); } function passwordIsValid(password) { return password && password.trim() !== ''; } function showErrorMessage(message) { console.log(message); } function saveUser(email, password) { const user = { email: email, password: password, }; database.insert(user); }
با انجام این کار برای تابع inputIsValid، دوباره دو سطح یکسان را در واحد آن خواهیم داشت و نیازی به انجام کار بیشتری نیست.
ما در بخش قبلی یک کد را بر اساس قاعده DRY بازنویسی کردیم اما نکته بسیار مهمی در رفتار با DRY وجود دارد که باید در این بخش بررسی شود. شما نباید به صورت کورکورانه و بدون منطق واقعی و عملی از این قوانین پیروی کنید. چرا؟ به دلیل اینکه شما می توانید این قوانین را بر هر کدی اعمال کنید، حتی کدهایی که خوانا هستند! قبل از شکستن کدها و ویرایش آن ها باید جدا از این قواعد از خودتان بپرسید آیا این کار واقعا به خوانا بودن کد کمک می کند یا خیر؟
اگر بدون منطق درست سعی کنید که از قوانین ذکر شده در این دوره تبعیت کنید با مشکل جدیدی روبرو می شوید: جزئی نویسی بیش از حد در کد! جزئی نویسی بیش از حد در کدها یعنی هر بخش از کد شما آنچنان ریز بوده و به صد ها قسمت تقسیم می شود که خوانایی آن زیر سوال می رود. همانطور که در طول این دوره توضیح دادم نوشتن کدهای جزئی و کوچک کار بسیار خوبی است و به خوانایی کمک می کند اما نباید بیش از حد به کار گرفته شود. من سه مثال از حالت هایی را برایتان می آورم که در آن ها شکستن کدها به بخش های کوچکتر پیشنهاد نمی شود:
من مثالی را برایتان آماده کرده ام که این موضوع را به خوبی نشان می دهد. به تابع saveUser از کد بالا توجه کنید:
// بقیه کدها function passwordIsValid(password) { return password && password.trim() !== ''; } function showErrorMessage(message) { console.log(message); } function saveUser(email, password) { const user = { email: email, password: password, }; database.insert(user); }
می توان گفت تابع saveUser دارای دو سطح جداگانه از کد است؛ ابتدا یک شیء user را درون آن ساخته ایم و سپس آن شیء را با تابعی به نام insert وارد پایگاه داده کرده ایم بنابراین می توان بخش ساخت کاربر را به یک تابع دیگر منتقل کنیم:
function createUser(email, password) { if (!inputIsValid(email, password)) { showErrorMessage('Invalid input!'); return; } saveUser(email, password); } function createSupportChannel(email) { if (!emailIsValid(email)) { showErrorMessage('Invalid email - could not create channel'); } // ... } function inputIsValid(email, password) { return emailIsValid(email) && passwordIsValid(password); } function emailIsValid(email) { return email && email.includes('@'); } function passwordIsValid(password) { return password && password.trim() !== ''; } function showErrorMessage(message) { console.log(message); } function saveUser(email, password) { const user = buildUser(email, password); database.insert(user); } function buildUser(email, password) { return { email: email, password: password, }; }
در این حالت محتویات saveUser در یک سطح قرار دارند و هر دو تابع هستند. از نظر من تقسیم کردن تابع saveUser به دو تابع کار بسیار بدی نیست اما ایده آل نیز نمی باشد. از نظر من این تقسیم بندی هیچ کمکی به تمیز بودن یا خوانایی کد نمی کند بنابراین شکستن آن به این شکل بی فایده است. اگر یادتان باشد من سه مورد از حالت هایی را توضیح دادم که در آن ها نیازی به شکستن کد نبود. ما در کد بالا دو حالت از این سه حالت را داریم: اولا فقط نام عملیات را تغییر داده ایم، یعنی قسمت const user را حذف کرده و به جای آن buildUser را نوشته ایم. در این عملیات چیزی به جز تغییر نامی ساده نداشتیم. دوما نام انتخابی ما (buildUser) به شدت به نام تابع اصلی (createUser) شباهت دارد و تفیکیک آن ها از هم برای یک فرد تازه وارد در تیم شما کمی سخت است. اگر بخواهیم هر کدی که به شکل بالا بود را بشکنیم، در انتهای کار کدهای ناخوانای زیادی را خواهیم داشت چرا که هر تابع خودش به ده تابع دیگر تقسیم شده و خواندن آن ها بسیار سخت خواهد شد.
اگر بنا به دلایلی حتما می خواهید کد saveUser را بشکنید تا سطوح مختلفی از کد را درون یک تابع نداشته باشید، می توانید از کلاس ها استفاده کنید. به مثال زیر دقت کنید:
function createUser(email, password) { if (!inputIsValid(email, password)) { showErrorMessage('Invalid input!'); return; } saveUser(email, password); } function createSupportChannel(email) { if (!emailIsValid(email)) { showErrorMessage('Invalid email - could not create channel'); } // ... } function inputIsValid(email, password) { return emailIsValid(email) && passwordIsValid(password); } function emailIsValid(email) { return email && email.includes('@'); } function passwordIsValid(password) { return password && password.trim() !== ''; } function showErrorMessage(message) { console.log(message); } class User { constructor(email, password) { this.email = email; this.password = password; } save() { database.insert(this); } } function saveUser(email, password) { const user = new User(email, password); user.save(); } function buildUser(email, password) { return { email: email, password: password, }; }
من در این کد کلاس جدیدی به نام User را ایجاد کرده ام که ایمیل و رمز عبور را گرفته و در خصوصیت هایی به نام email و password ذخیره می کند. درون این کلاس متد save مسئول ذخیره نمونه فعلی از کلاس (یک کاربر) در پایگاه داده است. متد saveUser ایمیل و رمز عبور را گرفته و سپس نمونه جدیدی از کلاس را می سازد تا متد save آن را ذخیره کند. در نهایت متد buildUser را نیز داریم که مسئول ساخت شیء کاربر است (اطلاعات کاربر). استفاده از این روش بسیار بهتر از روش قبلی است و اگر اصرار به شکستن saveUser دارید من این روش را پیشنهاد می کنم، گرچه در این روش بهتر است بقیه کدها (مانند اعتبارسنجی) را نیز درون کلاس قرار بدهید تا برنامه نویسی به طور کامل شیء گرا شود.
یکی دیگر از قوانین نوشتن کد تمیز این است که شما باید تلاش کنید تا حد ممکن توابع خود را خالص یا pure نگه دارید. pure functions یا توابع خالص (یا توابع محض) توابعی هستند که در ازای یک ورودی مشخص، یک خروجی مشخص را تولید می کنند یا به عبارتی هر ورودی مشخص، همیشه یک خروجی مشخص را دارد. تابع زیر یک مثال ساده از توابع خالص است:
function generateId(userName) { const id = 'id_' + userName; return id; }
این تابع رشته _id را به ابتدای userName چسبانده و آن را به ما پاس می دهد بنابراین هر مقداری را که به آن بدهیم دقیقا همان را به علاوه رشته _id پس می گیریم. مثلا اگر هزار بار رشته Amir را به این تابع پاس بدهم، هزار بار مقدار id_Amir را دریافت می کنم. به این دسته از توابع، توابع خالص می گوییم.
از طرفی اگر بخواهیم برای توابع غیر خالص مثال بزنیم، تابع زیر یک نمونه خوب خواهد بود:
function generateId(userName) { const id = userName + Math.random().toString(); return id; }
توابع ناخالص توابعی هستند که به ازای یک ورودی خاص، پاسخ متفاوتی تولید می کنند. به طور مثال در تابع بالا با استفاده از تابع Math.random هر بار یک عدد تصادفی را ایجاد کرده و به username می چسبانیم. وجود توابع غیر خالص در کد چیز بدی نیست بلکه غیر قابل پرهیز است و در هر برنامه ای بالاخره به چنین کدهایی نیز نیاز خواهیم داشت. مسئله اینجاست که توابع غیر خالص در حد کمتری قابل پیش بینی هستند بنابراین ترجیح داده می شود که فقط در هنگام نیاز به سمت توابع غیر خالص بروید و تا حد ممکن توابع خود را به صورت خالص بنویسید.
نکته بسیار مهم اینجاست که توابع خالص هیچ «عارضه» یا side effect ای ندارند. برای درک اینکه عارضه یا side effect چیست باید به مثال زیر توجه کنید:
function createUser(email, password) { const user = new User(email, password); return user; }
تابع بالا یک نمونه از کلاس User را ساخته و سپس این کاربر ساخته شده را برمی گرداند. این تابع یک تابع خالص است و عارضه ندارد چرا که هر بار آن را صدا بزنیم یک کاربر خاص (با ایمیل و رمز عبور خاص) ساخته خواهد شد. حالا فرض کنید تابع بالا به چنین شکلی نوشته شده باشد:
function createUser(email, password) { const user = new User(email, password); startSession(user); return user; }
تابع startSession یک session جدید برای کاربرمان ما ایجاد می کند. طبیعتا ساخت یک session جدید وضعیت کلی برنامه ما را تغییر می دهد (مثلا کاربری که session دارد می تواند به قسمت های خاصی از برنامه دسترسی داشته باشد). عوارض تابع، عملیات هایی هستند که با ورودی و خروجی تابع کاری ندارند بلکه وضعیت کلی یا state برنامه را تغییر می دهند. به طور مثال در کد بالا startSession متغیری خارج از تابع createUser را تغییر خواهد داد بنابراین متوجه می شویم که محدود به این تابع نیست و وضعیت برنامه ما را تغییر می دهد.
در صورتی که تابع شما در ازای یک ورودی خاص همیشه یک خروجی ثابت را برگرداند اما عارضه داشته باشد، دیگر تابع خالص محسوب نمی شود. دو شرط برای خالص بودن توابع وجود دارد: اول اینکه به ازای یک ورودی خاص، یک خروجی خاص و ثابت داشته باشیم. دوم اینکه تابع ما هیچ عارضه ای نداشته باشد. در نظر داشته باشید که وجود عوارض به خودی خود بد نیست، بلکه غیر قابل پرهیز است. مثلا نمایش متنی به کاربر (مانند log کردن) یا ارسال درخواست های HTTP یا ایجاد session همگی عارضه محسوب می شوند چرا که وضعیت کلی برنامه (state برنامه) را تغییر می دهند. وظیفه شما به عنوان توسعه دهنده، دوری کردن از عارضه های غیر قابل پیش بینی است. به طور مثال تابعی که برایتان مثال زدم، عارضه دارد اما عارضه آن قابل پیش بینی است:
function createUser(email, password) { const user = new User(email, password); startSession(user); return user; }
ما می دانیم که ساخت یک کاربر و ایجاد نشست (session) برای آن به چه شکل است و از نظر منطقی کاملا صحیح است در حالی که عارضه موجود در تابع زیر غیر قابل پیش بینی می باشد:
let lastAssignedId; function generateId(userName) { const id = 'id_' + userName; lastAssignedId = id; return id; }
این تابع شباهت بسیاری به تابع قبلی ما دارد که مسئول تولید id بود اما تفاوت آن در اینجاست که ما id تولید شده را به یک متغیر سراسری به نام lastAssignedId نیز داده ایم! شاید بگویید هر کدی که بنویسیم، توسط خود ما نوشته شده است بنابراین قابل پیش بینی است اما منظور من از قابل پیش بینی بودن عارضه ها این نیست! اگر کسی بخواهد تابعی به نام generateId (به معنی «آیدی بساز») را صدا بزند، انتظار نخواهد داشت که چنین تابعی، آیدی تولید شده را در یک متغیر سراسری ذخیره کرده و بعدا با آن کاری انجام بدهد. با این حساب منظور ما از قابل پیش بینی بودن، قابل پیش بینی بودن توسط افرادی است که تازه کد شما را می خوانند. همچنین یادتان باشد که کد بالا کار می کند و از نظر اجرا مشکلی ندارد بلکه بحث ما درباره تمیز بودن آن است.
یک راه حل ساده این است که نام تابع را به generateUnsedId یا generateAndTrackId تغییر بدهید تا کسی که آن را می بیند متوجه شود این تابع علاوه بر تولید آیدی، آن را تحت نظر می گیرید. هر نامی که برای توابع خود انتخاب می کنید باید نشان دهنده عوارض جانبی آن تابع یا متد باشد نه اینکه توسعه دهندگان دیگر را غافل گیر کند. به چند نام تابع زیر توجه کنید:
بنابراین در رابطه با عوارض توابع باید به دو نکته مهم توجه کنیم: اولا اگر تابع شما عارضه دارد حتما نامی را انتخاب کنید که وجود این عارضه را توضیح دهد. دوما در صورتی که نمی خواهید نام تابع را تغییر بدهید، باید عارضه را به تابع دیگری منتقل کنید.
من کدی را برای شما آماده کرده ام که دارای مشکلاتی در زمینه عارضه توابع است. از شما می خواهم بر اساس چیزی که در این جلسه برایتان توضیح داده ام آن را تصحیح نمایید:
function connectDatabase() { const didConnect = database.connect(); if (didConnect) { return true; } else { console.log('Could not connect to database!'); return false; } } function determineSupportAgent(ticket) { if (ticket.requestType === 'unknown') { return findStandardAgent(); } return findAgentByRequestType(ticket.requestType); } function isValid(email, password) { if (!email.includes('@') || password.length < 7) { console.log('Invalid input!'); return false; } return true; }
این تمرین شما است بنابراین سعی کنید خودتان بدون نگاه کردن به پاسخ من آن را حل کنید.
برای حل این کد از اولین تابع، یعنی connectDatabase، شروع می کنیم. زمانی که نام تابعی connectDatabase (به پایگاه داده متصل شو) باشد به راحتی متوجه می شویم که کار آن اتصال به پایگاه داده است بنابراین یک عارضه محسوب می شود. اگر به کدهای درون این تابع نگاه کنید دو عارضه متفاوت را می بینید. اولین عارضه اتصال به پایگاه داده است (database.connect) اما دومین عارضه، دستور console.log است چرا که یک پیام را در کنسول چاپ می کند.
ما باید در اینجا تصمیم بگیریم که آیا این عارضه جایش در connectDatabase است یا باید تغییر کند. از طرفی می توان ادعا کرد که اگر وظیفه تابعی اتصال به پایگاه داده باشد، احتمالا می رود که مدیریت خطا نیز در آن اتفاق بیفتد بنابراین این کد هیچ مشکلی ندارد. از طرف دیگر می توان ادعا کرد که نام تابع فقط اتصال به پایگاه داده را مشخص می کند و حرفی از مدیریت خطا نمی زند بنابراین کسی که این تابع را صدا می زند (بدون اینکه محتوایش را ببیند) نمی تواند متوجه ارسال پیام از سمت این تابع شود. من فقط در حال توضیح دادن حالت های مختلف این موضوع هستم و نمی توانم پاسخ قطعی را به شما بدهم چرا که پاسخ قطعی بر اساس ساختار برنامه شما و همچنین سلیقه شما تعیین می شود.
در صورتی که می خواهید تفسیر دوم از این تابع را داشته باشید، باید قسمت چاپ پیام را به تابع دیگری منتقل کنید:
function initApp() { try { connectDatabase(); } catch (error) { console.log(error.message); // showErrorMessage(...) } } function connectDatabase() { const didConnect = database.connect(); if (!didConnect) { throw new Error('Could not connect!'); } }
در مرحله بعدی به تابع دوم می رسیم که determineSupportAgent نام دارد. این تابع یک پشتیبان را برای تیکت پشتیبانی ما پیدا می کند تا با کاربر در تماس باشد. با نگاهی ساده به این کد متوجه می شویم که احتمالا هیچ عارضه ای وجود ندارد. متد سوم، متد isValid می باشد که دارای عارضه است و این عارضه غیر قابل پیش بینی نیز می باشد. اگر نام تابعی isValid باشد ما انتظار داریم که true یا false را دریافت کنیم اما این تابع console.log را نیز دارد. راحت ترین روش این است که اصلا پیامی را در این بخش نشان ندهیم تا تمام کدهای این چالش به شکل زیر در بیاید:
function initApp() { try { connectDatabase(); } catch (error) { console.log(error.message); // showErrorMessage(...) } } function connectDatabase() { const didConnect = database.connect(); if (!didConnect) { throw new Error('Could not connect!'); } } function determineSupportAgent(ticket) { if (ticket.requestType === 'unknown') { return findStandardAgent(); } return findAgentByRequestType(ticket.requestType); } function isValid(email, password) { return email.includes('@') && password.length >= 7; }
اما در صورتی که حتما می خواهید پیامی نیز نمایش داده شود بهتر است آن عملیات را در تابع مادر خود انجام بدهید. مثلا در تابعی به نام createUser (ساخت کاربر) ایمیل و رمز عبور او را چک کرده و در صورتی که معتبر نبود، پیام خطا را به کاربر نمایش دهید.
در این قسمت، به پرسشهای تخصصی شما دربارهی محتوای مقاله پاسخ داده نمیشود. سوالات خود را اینجا بپرسید.