فصل 5توابع رده‌بالا (Higher-Order)

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

استاد یوان‌ما, کتاب برنامه‌نویسی

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

آنتونی ریچارد هور, سخنرانی دریافت جایزه‌ی تورینگ ۱۹۸۰
Letters from different scripts

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

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

let total = 0, count = 1;
while (count <= 10) {
  total += count;
  count += 1;
}
console.log(total);

دومین برنامه از دو تابع استفاده می‌کند و فقط یک خط دارد:

console.log(sum(range(1, 10)));

کدام یک بیشتر احتمال دارد باگ داشته باشد؟

اگر اندازه‌ی تعریف توابع sum و range را هم به حساب بیاوریم، برنامه دوم نیز برنامه‌ای بزرگ محسوب می‌شود – حتی بزرگ‌تر از برنامه اول. اما هنوز، من ادعا می‌کنم که این برنامه با احتمال بیشتری درست کار خواهد کرد.

با احتمال بیشتری درست کار خواهد کرد زیرا راه‌ حل آن با واژگانی بیان شده است که با حل مسئله ارتباط معنایی دارند. جمع بستن (sum) یک بازه (range) از اعداد، ربطی به حلقه‌ها و شمارنده‌ها ندارد؛ بلکه مربوط به بازه‌ها و عمل جمع می‌باشد.

در تعریف این واژگان (توابع sum و range) همچنان از حلقه‌ها، شمارنده‌ها و دیگر جزئیات فرعی استفاده خواهد شد. اما به دلیل اینکه آن‌ها به جای بیان برنامه به عنوان یک کل، مفاهیم ساده‌تری را نشان می‌دهند، آسان‌تر سامان می‌یابند.

انتزاع

در فضای برنامه‌نویسی، این گونه واژگان را عموما انتزاع‌ها abstractions می‌گویند. انتزاع‌ها جزئیات را مخفی می‌کنند و به ما این امکان را می‌دهند که درباره‌ی مسئله‌ها در سطح بالاتری (انتزاع بیشتر) گفتگو کنیم.

به عنوان یک تشبیه، می‌توان این دو طرز تهیه‌ی سوپ نخود را با هم مقایسه کرد. اولین مورد به صورت زیر خواهد بود:

یک فنجان نخود خشک برای هر نفر درون یک ظرف بریزید. به آن آب اضافه کنید تا همه‌ی نخود‌ها را در بر بگیرد. اجازه بدهید نخود‌ها حداقل 12 ساعت در آب بمانند. بعد نخود‌ها را از آب در آورده درون یک قابلمه قرار دهید. برای هر نفر 4 لیوان آب اضافه کنید. روی قابلمه را پوشانده و بگذارید برای 2 ساعت روی گاز باشد. برای هر نفر نیمی از پیاز را برداشته آن را تکه تکه کنید و به نخود‌ها اضافه نمایید. برای هر نفر یک ساقه‌ی کرفس بردارید. با چاقو قطعه قطعه کنید و به نخود‌ها اضافه کنید. برای هر نفر یک هویج در نظر گرفته، تکه تکه کرده با چاقو! و به نخود‌ها اضافه کنید. بگذارید 10 دقیقه دیگر بپزد.

طرز تهیه‌ی دوم:

برای هر نفر: یک لیوان نخود خشک، نیمی از یک پیاز تکه تکه شده، یک ساقه کرفس، و یک هویج.

نخود‌ها را 12 ساعت بخیسانید. 2 ساعت در 4 لیوان آب (برای یک نفر) روی گاز آهسته بجوشانید. سبزیجات را تکه تکه کرده و اضافه کنید. برای 10 دقیقه دیگر پخته شود.

دستور دوم کوتاهتر و توضیح ساده‌تری داشت. اما برای فهم آن باید چند واژه‌ی مرتبط با آشپزی را یاد داشته باشید – خیساندن، جوشاندن، ریز ریز کردن و فکر کنم سبزیجات.

هنگام برنامه‌نویسی، نمی‌توانیم فرض کنیم همه‌ی واژگانی که نیاز داریم وجود داشته و در واژه‌نامه منتظر ما باشند. بنابراین، ممکن است به دام الگوی موجود در طرز تهیه‌ی اول بیفتیم – کارکردن روی قدم‌های دقیقی که کامپیوتر باید اجرا کند، یکی پس از دیگری، بدون توجه به مفاهیم سطح بالاتری که این دستورات بیان می‌کنند.

یکی از مهارت‌های کاربردی در برنامه‌نویسی، این است که زمانی که در سطح بسیار پایینی از انتزاع کار می‌کنید، نسبت به آن آگاه باشید.

تکرار انتزاعی

توابع ساده، مانند مواردی که تا کنون دیده‌ایم، برای ایجاد انتزاع مفید هستند. اما گاهی اوقات کافی نیستند.

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

for (let i = 0; i < 10; i++) {
  console.log(i);
}

آیا می‌توان "انجام یک کار به تعداد N بار" را با استفاده از یک تابع جدا کرد؟ خوب خیلی راحت می‌توان تابعی نوشت که console.log را N بار فراخوانی کند.

function repeatLog(n) {
  for (let i = 0; i < n; i++) {
    console.log(i);
  }
}

اما اگر بخواهیم کاری به غیر از چاپ اعداد در خروجی انجام دهیم چه؟ با توجه به این که "انجام یک کار" را می‌توان به عنوان یک تابع در نظر گرفت و توابع هم در واقع مقدار هستند، می‌توانیم "کار"مان را به عنوان یک مقدار تابع ارسال کنیم.

function repeat(n, action) {
  for (let i = 0; i < n; i++) {
    action(i);
  }
}

repeat(3, console.log);
// → 0
// → 1
// → 2

نیازی نیست که حتما یک تابع از پیش تعریف شده را به تابع repeat ارسال کنید. اغلب آسان‌تر است که یک "مقدار تابع" همان موقع ایجاد کنیم.

let labels = [];
repeat(5, i => {
  labels.push(`Unit ${i + 1}`);
});
console.log(labels);
// → ["Unit 1", "Unit 2", "Unit 3", "Unit 4", "Unit 5"]

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

توابع رده‌بالا (Higher order functions)

توابعی که روی توابع دیگر عمل می کنند، چه با گرفتن آن‌ها به عنوان آرگومان و چه با برگرداندن آن‌ها ، توابع رده‌بالا نامیده می‌شوند. با توجه به این که پیش تر دیده‌ایم که توابع در واقع یک نوع مقدار هستند، مسئله‌ی قابل توجه و به خصوصی در مورد فلسفه‌ی وجود اینگونه توابع وجود ندارد. اصطلاح رده‌بالا (higher-order) از ریاضیات گرفته شده است جایی که به تمایز بین توابع و دیگر مقادیر اهمیت بیشتری داده شده است.

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

function greaterThan(n) {
  return m => m > n;
}
let greaterThan10 = greaterThan(10);
console.log(greaterThan10(11));
// → true

یا توابعی داشته باشید که دیگر توابع را تغییر می‌دهند.

function noisy(f) {
  return (...args) => {
    console.log("calling with", args);
    let result = f(...args);
    console.log("called with", args, ", returned", result);
    return result;
  };
}
noisy(Math.min)(3, 2, 1);
// → calling with [3, 2, 1]
// → called with [3, 2, 1] , returned 1

حتی می‌توانید توابعی بنویسید که نوعی جدیدی از جریان کنترل را فراهم نمایند.

function unless(test, then) {
  if (!test) then();
}

repeat(3, n => {
  unless(n % 2 == 1, () => {
    console.log(n, "is even");
  });
});
// → 0 is even
// → 2 is even

متد از پیش تعریف شده‌ای به نام forEach برای آرایه‌ها وجود دارد که کاری شبیه حلقه‌ی for/of را به عنوان یک تابع رده‌بالا انجام می‌دهد.

["A", "B"].forEach(l => console.log(l));
// → A
// → B

مجموعه داده‌ی الفبا

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

یونیکد را از [فصل ?] (values#unicode) به خاطر بیاورید، سیستمی که برای هر کاراکتر از زبان‌های نوشتاری، عددی را اختصاص می‌داد. اکثر این کاراکتر‌ها به الفبای مشخصی تعلق دارند. این استاندارد دارای 140 الفبای متفاوت است – از این تعداد، 81 تای آن‌ها امروزه استفاده می‌شوند و 59 مورد دیگر به تاریخ پیوسته‌اند.

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

Tamil handwriting

مجموعه‌ داده‌ی نمونه‌ی ما حاوی اطلاعاتی از حدود 140 الفبای موجود در یونیکد است. این داده‌ها در قسمت کدهای این فصل به عنوان متغیر SCRIPTS قابل دانلود می‌باشند. این متغیر شامل آرایه‌ای از اشیاء است که هر کدام معرف یک الفبا می‌باشند.

{
  name: "Coptic",
  ranges: [[994, 1008], [11392, 11508], [11513, 11520]],
  direction: "ltr",
  year: -200,
  living: false,
  link: "https://en.wikipedia.org/wiki/Coptic_alphabet"
}

این اشیاء شامل نام الفبا، بازه‌ی یونیکدی که به آن اختصاص دارد، جهتی که به آن سمت نوشته می‌شود، منشاء زمانی (تقریبی)، اینکه اکنون نیز استفاده می‌شوند یا خیر، و لینکی به اطلاعات بیشتر می‌باشند. جهت نوشتن می‌تواند "ltr" برای چپ به راست، "rtl" راست به چپ (مانند عربی و عبری) یا "ttb" برای بالا به پایین (مانند زبان مغولی) باشد.

خاصیت ranges شامل آرایه‌ای از بازه‌های کاراکتر یونیکد می‌باشد که هر کدام یک آرایه‌ی دو عنصری است که یک مرز پایین و بالا دارد. هر کد کاراکتری که در این بازه قرار بگیرد متعلق به الفبای مذکور است. مرز پایین، خود نیز شامل می‌شود (کد 994 یک کاراکتر قبطی است) اما مرز بالایی، در الفبای مورد نظر قرار ندارد (کد 1008 متعلق به قبطی نیست(

فیلتر کردن آرایه ها

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

function filter(array, test) {
  let passed = [];
  for (let element of array) {
    if (test(element)) {
      passed.push(element);
    }
  }
  return passed;
}

console.log(filter(SCRIPTS, script => script.living));
// → [{name: "Adlam", …}, …]

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

توجه کنید که چگونه تابع filter، به جای اینکه عناصر را از آرایه حذف کند، آرایه‌ی جدیدی می‌سازد که شامل فقط عناصری است که با شرط تطبیق دارند. این تابع ناب (pure) است. آرایه‌ای که دریافت می‌کند را تغییر نمی‌دهد.

شبیه forEach، تابع filter نیز یک متد استاندارد آرایه است. در مثال بالا، تابع تعریف شد تا شیوه‌ی کارکرد درون آن را نشان دهد. از حالا به بعد، به شکل زیر از آن استفاده خواهیم کرد:

console.log(SCRIPTS.filter(s => s.direction == "ttb"));
// → [{name: "Mongolian", …}, …]

تغییر شکل به وسیله‌ی map

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

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

function map(array, transform) {
  let mapped = [];
  for (let element of array) {
    mapped.push(transform(element));
  }
  return mapped;
}

let rtlScripts = SCRIPTS.filter(s => s.direction == "rtl");
console.log(map(rtlScripts, s => s.name));
// → ["Adlam", "Arabic", "Imperial Aramaic", …]

شبیه forEach و filter متد map نیز از متدهای استاندارد آرایه است.

خلاصه‌ کردن یک آرایه به وسیله‌ی متد reduce

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

عمل "رده‌بالا"یی که برای این الگو وجود دارد، reduce)کاهش) خوانده می‌شود (گاهی اوقات هم آن را تاکردن می‌نامند) این متد به صورت مکرر عنصری از آرایه را گرفته و آن را با مقدار قبلی ترکیب کرده و در نهایت یک مقدار واحد تولید می‌کند. زمانی که اعداد را جمع می‌کنید، ابتدا با عدد صفر شروع می‌کنید و بعد یکایک عناصر را به مجموع اضافه می‌کنید.

پارامتر‌های تابع reduce، بدون در نظر گرفتن خود آرایه، شامل یک تابع ترکیب و یک مقدار اولیه می‌باشند. مفهوم این تابع به سرراستی filter و map نیست، پس بیایید نگاه دقیق‌تری بکنیم:

function reduce(array, combine, start) {
  let current = start;
  for (let element of array) {
    current = combine(current, element);
  }
  return current;
}

console.log(reduce([1, 2, 3, 4], (a, b) => a + b, 0));
// → 10

متد استاندارد آرایه‌ی reduce که شبیه به تابع بالا می‌باشد، یک مزیت بیشتر نیز دارد. اگر آرایه‌ی شما حداقل یک عنصر داشته باشد، می‌توانید آرگومان start را حذف کنید. خود تابع، اولین عنصر آرایه را به عنوان مقدار شروع در نظر گرفته و عمل کاهش را از عنصر دوم شروع می‌کند.

console.log([1, 2, 3, 4].reduce((a, b) => a + b));
// → 10

برای استفاده از متد reduce (دو بار) برای پیدا کردن الفبایی که بیشترین حروف را دارد، می‌توانیم چیزی مثل کد پایین بنویسیم:

function characterCount(script) {
  return script.ranges.reduce((count, [from, to]) => {
    return count + (to - from);
  }, 0);
}

console.log(SCRIPTS.reduce((a, b) => {
  return characterCount(a) < characterCount(b) ? b : a;
}));
// → {name: "Han", …}

تابع characterCount بازه‌های مربوط به یک الفبا را به وسیله‌ی جمع کردن اندازه‌ی آن‌ها کاهش می‌دهد. به استفاده از "تجزیه" (destructing) در لیست پارامتر‌ها در تابع کاهش دهنده توجه کنید. در فراخوانی دوم تابع reduce، از این نتیجه برای پیدا کردن بزرگترین الفبا استفاده می‌شود؛ دو به دو الفبا‌ها را مقایسه کرده و الفبای بزرگتر را برمی‌گرداند.

الفبای Han (هان) دارای بیش از 89,000 کاراکتر در استاندارد یونیکد است، که باعث شده به عنوان بزرگ‌ترین سیستم نوشتاری در این مجموعه شناخته شود. هان الفبایی است که (گاهی) برای متون چینی، ژاپنی، و کره‌ای استفاده می‌شود. این زبان‌ها کاراکتر‌های مشترک زیادی دارند اگرچه که به شکل متفاوتی آن‌ها را می‌نویسند. کنسرسیوم یونیکد (مستقر در ایالات متحده) تصمیم گرفت که آن‌ها را به عنوان یک سیستم نوشتاری واحد در نظر بگیرد تا کد‌های کاراکتر کمتری استفاده شود. این کار یکی‌سازی الفبای هان خوانده می‌شود که هنوز بعضی افراد را خیلی عصبانی می‌کند.

ترکیب پذیری

ملاحظه کنید چگونه بدون استفاده توابع رده‌بالا، می‌توانستیم مثال قبل را بنویسیم (پیدا کردن بزرگ‌ترین الفبا). آن‌قدر‌ها هم بد از کار در نمی‌آمد.

let biggest = null;
for (let script of SCRIPTS) {
  if (biggest == null ||
      characterCount(biggest) < characterCount(script)) {
    biggest = script;
  }
}
console.log(biggest);
// → {name: "Han", …}

با چند متغیر اضافی و چهار خط کدنویسی بیشتر، همچنان برنامه خوانایی خوبی دارد.

توابع رده‌بالا زمانی شروع به درخشش می‌کنند که نیاز باشد عملیات را ترکیب کنید. به عنوان یک مثال، بیایید کدی بنویسیم که میانگین سال ایجاد را برای الفبا‌های زنده و از رده خارج پیدا می‌کند.

function average(array) {
  return array.reduce((a, b) => a + b) / array.length;
}

console.log(Math.round(average(
  SCRIPTS.filter(s => s.living).map(s => s.year))));
// → 1188
console.log(Math.round(average(
  SCRIPTS.filter(s => !s.living).map(s => s.year))));
// → 188

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

قطعا می‌شد این محاسبه را به وسیله یک حلقه‌ی بزرگ پیاده‌سازی کرد.

let total = 0, count = 0;
for (let script of SCRIPTS) {
  if (script.living) {
    total += script.year;
    count += 1;
  }
}
console.log(Math.round(total / count));
// → 1188

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

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

رشته‌ها و کدهای کاراکتر

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

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

function characterScript(code) {
  for (let script of SCRIPTS) {
    if (script.ranges.some(([from, to]) => {
      return code >= from && code < to;
    })) {
      return script;
    }
  }
  return null;
}

console.log(characterScript(121));
// → {name: "Latin", …}

متد some یکی دیگر از توابع رده‌بالا است. این متد تابعی را به عنوان شرط دریافت می‌کند. این شرط به تک تک عناصر آرایه اعمال شده و اگر حداقل برای یکی از آن‌ها صدق کند (true باشد)، تابع نیز true را برمی‌گرداند.

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

در فصل 1 اشاره کردم که در جاوااسکریپت رشته‌ها به عنوان دنباله‌ای از اعداد 16 بیتی کدگذاری می‌شوند که به آن‌ها واحد‌های کد گفته می‌شود. ابتدا قرار بود در یونیکد هر کد کاراکتر در یکی از این واحد‌ها قرار گیرد ( که چیزی بیش از 65,000 کاراکتر را در اختیار شما می‌گذارد). زمانی که روشن شد این مقدار کافی نیست، خیلی‌ها از موضوع اختصاص حافظه‌ی بیشتر برای هر کاراکتر طفره می‌رفتند. برای حل این مشکل، UTF-16 اختراع شد، فرمتی که برای رشته‌های جاوااسکریپت استفاده می‌شود. این سیستم اکثر کاراکتر‌های رایج را با یک واحد کد 16 بیتی توصیف می‌کند اما برای دیگر کد‌ها، از دو واحد استفاده می‌کند.

این روز‌ها UTF-16 عموما به عنوان ایده‌ی بدی شناخته می‌شود. به نظر می‌رسد که عمدا طوری طراحی شده است که اشتباه ساز باشد. به سادگی می‌توان برنامه‌هایی نوشت که در ظاهر تفاوتی بین واحد‌ها و کاراکتر‌های کد قائل نمی‌شوند و بدون مشکل هم کار می‌کنند. اما به محض اینکه کسی سعی کند از این گونه برنامه‌ها برای نوشتن بعضی کاراکتر‌های چینی نامتداول استفاده کند، برنامه از کار می‌افتد. خوشبختانه، با ظهور ایموجی، همه به سراغ استفاده از کاراکتر‌های دو واحده رفته‌اند، و مسئولیت سروکار داشتن با این گونه مشکلات با عدالت بیشتری توزیع شده است.

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

// Two emoji characters, horse and shoe
let horseShoe = "🐴👟";
console.log(horseShoe.length);
// → 4
console.log(horseShoe[0]);
// → (Invalid half-character)
console.log(horseShoe.charCodeAt(0));
// → 55357 (Code of the half-character)
console.log(horseShoe.codePointAt(0));
// → 128052 (Actual code for horse emoji)

متد charCodeAt در جاوااسکریپت به شما یک واحد کد تحویل می‌دهد نه کد یک کاراکتر کامل. متد codePointAt، که بعدا اضافه شد، کد کامل یونیکد کاراکتر را برمی گرداند. بنابراین می‌توانیم از این متد برای گرفتن کاراکتر‌های یک رشته استفاده کنیم. اما آرگومانی که به متد codePointAt ارسال می‌شود هنوز یک اندیس گرفته شده از دنباله‌ی کد‌های واحد است. پس با توجه به آن، برای پیمایش همه‌ی کاراکتر‌های یک رشته، همچنان باید بدانیم که هر کاراکتر یک واحد یا دو واحد کد اشغال کرده است.

در فصل پیش، اشاره کردم که حلقه‌ی for/of را همچنین می‌توان برای رشته‌ها استفاده کرد. شبیه codePointAt، این نوع از حلقه نیز زمانی معرفی شد که همه از مشکلات UTF-16 آگاه بودند. با استفاده از آن برای پیمایش یک رشته، به جای کد‌های واحد، کاراکتر‌های واقعی برگردانده می‌شوند.

let roseDragon = "🌹🐉";
for (let char of roseDragon) {
  console.log(char);
}
// → 🌹
// → 🐉

اگر کاراکتری دارید (که رشته‌ای از یک یا دو واحد کد است)، می‌توانید از متد codePointAt(0) برای گرفتن کد متناظرش استفاده کنید.

تشخیص متن

تا اینجا یک تابع به نام characterScript به همراه روشی برای پیمایش کاراکتر‌ها در اختیار داریم. گام بعدی شمردن کاراکتر‌هایی است که به هر الفبا مربوط می‌شود. می‌توان از تابع زیر برای این کار استفاده کرد:

function countBy(items, groupName) {
  let counts = [];
  for (let item of items) {
    let name = groupName(item);
    let known = counts.findIndex(c => c.name == name);
    if (known == -1) {
      counts.push({name, count: 1});
    } else {
      counts[known].count++;
    }
  }
  return counts;
}

console.log(countBy([1, 2, 3, 4, 5], n => n > 2));
// → [{name: false, count: 2}, {name: true, count: 3}]

تابع countBy به عنوان آرگومان، یک مجموعه (هرچیزی که بتوان به وسیله‌ی for/of آن را پیمایش کرد) به همراه تابعی برای گروه‌بندی دریافت می‌کند. خروجی این تابع، آرایه‌ای از اشیاء است که هر یک معرف یک گروه است و به شما می‌گوید چه تعداد عنصر در آن گروه پیدا شده است.

این تابع خود از متدی دیگر به نام findIndex استفاده می‌کند. این متد به شکلی شبیه به indexOf عمل می‌کند، اما به جای گشتن برای یک مقدار خاص، به دنبال اولین مقداری می‌گردد که تابع داده شده، با آن مقدار true را برمی گرداند. مانند indexOf، اگر عنصری با آن شرایط پیدا نشود، -1 برگردانده می‌شود.

با استفاده از countBy می‌توانیم تابعی بنویسیم که الفبای یک متن را برای ما مشخص کند.

function textScripts(text) {
  let scripts = countBy(text, char => {
    let script = characterScript(char.codePointAt(0));
    return script ? script.name : "none";
  }).filter(({name}) => name != "none");

  let total = scripts.reduce((n, {count}) => n + count, 0);
  if (total == 0) return "No scripts found";

  return scripts.map(({name, count}) => {
    return `${Math.round(count * 100 / total)}% ${name}`;
  }).join(", ");
}

console.log(textScripts('英国的狗说"woof", 俄罗斯的狗说"тяв"'));
// → 61% Han, 22% Latin, 17% Cyrillic

تابع بالا ابتدا تعداد کاراکتر‌ها را با نام می‌شمارد، با استفاده از characterScript به آن‌ها نامی را اختصاص می‌دهد، و برای کاراکتر‌هایی که جزء هیچ الفبایی محسوب نمی‌شوند، مقدار “none” را به رشته باز می‌گرداند. تابع filter تمامی “none”‌ها را از آرایه‌ی نتیجه حذف می‌کند، چرا که علاقه‌ای به این کاراکتر‌ها نداریم.

برای اینکه بتوانیم درصد‌ها را محاسبه کنیم، ابتدا به تعداد همه‌ی کاراکتر‌هایی که به یک الفبا تعلق دارند نیاز داریم، که می‌توانیم این کار را با reduce انجام دهیم. اگر این کاراکتر‌ها پیدا نشدند، تابع، یک رشته‌ی مشخص برمی‌گرداند. در غیر این صورت به وسیله تابع map موارد محاسبه ‌شده را به رشته‌هایی مناسب خواندن تبدیل می‌کند و در آخر به وسیله join آن‌ها را به هم الحاق می‌نماید.

خلاصه

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

آرایه‌ها مجموعه‌ی مفیدی از توابع رده‌بالا را فراهم می‌سازند. می‌توانید از forEach برای پیمایش عناصر یک آرایه استفاده کنید. برای برگرداندن یک آرایه‌ی جدید با عناصری که شرایط خاصی دارند، متد filter مفید است. برای تغییر شکل عناصر یک آرایه به وسیله‌ی یک تابع، می‌توانید از map استفاده کنید. reduce برای ترکیب همه‌ی عناصر یک آرایه و ساخت یک مقدار واحد، کاربرد دارد. متد some بررسی می‌کند که آیا حداقل یک عنصر در آرایه با شرط تابع ورودی تطبیق می‌کند و findIndex موقعیت اولین عنصری که با شرط ارسالی تطبیق دارد را برمی‌گرداند.

تمرین‌ها

یک‌سطح کردن آرایه

با استفاده از متد reduce و ترکیب آن با concat، آرایه‌ای از آرایه‌ها را گرفته و آرایه‌ی تختی (مسطح) بسازید که شامل همه‌ی عناصر آرایه‌های اصلی باشد.

let arrays = [[1, 2, 3], [4, 5], [6]];
// Your code here.
// → [1, 2, 3, 4, 5, 6]

شبیه‌سازی حلقه

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

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

// Your code here.

loop(3, n => n > 0, n => n - 1, console.log);
// → 3
// → 2
// → 1

همه‌چیز

مشابه متد some، آرایه‌ها متدی به نام every نیز دارند. این متد زمانی true برمی‌گرداند که تابع داده شد برای همه‌ی عناصر آرایه، مقدار true را تولید کند. به نوعی، some نسخه‌ای از عملگر || است که روی آرایه‌ها عمل می‌کند و every شبیه به عملگر && کار می‌کند.

متد every را به عنوان یک تابع پیاده‌سازی کنید که یک آرایه و یک تابع دریافت می‌کند. دو نسخه از این تابع را بنویسید، یک نسخه با استفاده از حلقه و دیگری با استفاده از متد some.

function every(array, test) {
  // Your code here.
}

console.log(every([1, 3, 5], n => n < 10));
// → true
console.log(every([2, 4, 16], n => n < 10));
// → false
console.log(every([], n => n < 10));
// → true

مانند عملگر &&، متد every به محض اینکه به موردی برخورد کند که با شرط تطبیق ندارد، ارزیابی دیگر عناصر را متوقف می‌کند. بنابراین در نسخه‌ی مبتنی بر حلقه، می‌توان با مشاهده‌ی خروجی false تابع روی یک عنصر، از حلقه به وسیله‌ی break یا return خارج شد. اگر حلقه بدون برخورد با چنین عنصری به انتهای خود برسد، می‌دانیم که همه‌ی عناصر مطابق تابع شرط بوده‌اند و می‌توانیم true را برگردانیم.

برای ساخت every با استفاده از some، می‌توانیم از قوانین دمورگان استفاده کنیم، که براساس آن‌، a && b برابر است با !(!a || !b). می‌توان آن را به آرایه‌ها تعمیم داد به این صورت که همه‌ی عناصر موجود در آرایه با شرط تطبیق خواهند داشت اگر عنصری در آرایه وجود نداشته باشد که با شرط تطبیق نداشته باشد.

جهت نوشتن غالب

تابعی بنویسید که بتواند جهت نوشتن غالب یک متن را محاسبه کند. به یاد دارید که هر شیء الفبا خاصیتی به نام direction دارد که می‌تواند “ltr” (چپ به راست)، “rtl” (راست به چپ)، یا “ttb” (بالا به پایین) باشد.

جهت نوشتاری غالب، جهتی است که بیشتر کاراکتر‌هایی که الفبای مشخصی دارند، در آن جهت نوشته می‌شوند. احتمالا دو تابع characterScript و countBy که پیش‌تر نوشته شده‌اند، اینجا کاربرد خواهند داشت.

function dominantDirection(text) {
  // Your code here.
}

console.log(dominantDirection("Hello!"));
// → ltr
console.log(dominantDirection("Hey, مساء الخير"));
// → rtl

پاسخ شما ممکن است شباهت زیادی به نیمه‌ی اول مثال textScripts داشته باشد. دوباره باید کاراکتر‌ها را با شرطی براساس characterScript بشمارید و سپس بخشی از نتیجه که الفبای مشخصی ندارند را فیلتر کنید.

پیدا کردن جهت نوشته بر اساس شمارش بیشترین کاراکتر را می‌توان با متد reduce انجام داد. اگر راه حل به ذهن‌تان نرسید، به مثالی که پیش‌تر در این فصل در مورد استفاده از reduce برای پیدا کردن الفبایی با بیشترین حروف مراجعه کنید.