در این درس میخوانید
آرایهای که برای ده ردیف درست به نظر میرسد، ممکن است با یک خانهٔ خالی، مبلغ نامعتبر یا تغییر ترتیب ورودی، گزارشی متفاوت بسازد. در این آموزش پیشرفته، فهرست نام متدها را از اول حفظ نمیکنیم؛ قرارداد رفتار را بررسی میکنیم و سپس دربارهٔ هزینهٔ اجرا تصمیم میگیریم. مسئلهٔ کاربردی ما پردازش ردیفهای یک گزارش است: داده باید معتبر بماند، ترتیب خروجی قابل توضیح باشد و نسخهٔ سریعتر همان پاسخ نسخهٔ مرجع را بدهد. نمونهها برای JavaScript و Node.js هستند؛ آرایه در این دو زبان جداگانهای نیست و تفاوت محیط اجرا را با تفاوت قواعد زبان اشتباه نمیگیریم.
پیش از بخش تحلیل سرعت، درس مستقل «هزینهٔ اجرای کد» را بخوانید تا اندازهٔ ورودی، پیچیدگی زمانی و فضایی و تفاوت رشد هزینه با زمان واقعی روشن باشد:
https://www.darsavand.com/resources/javascript-runtime-cost-complexity
فایلهای کامل آزمایشگاه، آزمونها، دادهٔ خام و راهنمای اجرای فارسی را از صفحهٔ پروژه دریافت کنید:
https://www.darsavand.com/resources/javascript-arrays-performance-lab
پیشنیاز، کار عملی با تابع، شیء و آرایه است. برای مرور عملیات پایه، درس «آرایه در جاوااسکریپت» را بخوانید: https://www.darsavand.com/courses/javascript-from-zero-to-project?lesson=e04dd799-aa51-46f1-8278-21b3a554a3a6
برای مبنای گزارشگیری و Map نیز این درس مرتبط است: https://www.darsavand.com/courses/javascript-project-lab?lesson=6b314ebc-2e12-443e-b0bf-cdb27d3abb96
اینجا از تعریف دوبارهٔ map و filter عبور میکنیم و میپرسیم: آیا همهٔ اندیسها واقعاً وجود دارند، چه چیزی کپی شده، «برابر» یعنی چه و مقایسهگر چه تعهدی دارد؟ مدخل #array برای مراجعهٔ سریع مفید است؛ پاسخ این پرسشها را باید با اجرای نمونه و بررسی ورودی اثبات کنید.
در پایان باید بتوانید خانهٔ غایب را از undefined موجود تشخیص دهید، اثر تغییر ورودی را پیشبینی کنید، برای مرتبسازی یک سیاست روشن بنویسید و قبل از اندازهگیری زمان، برابری نتیجهها را بیازمایید. برای تمرینهای مستقل، هر باکس را جدا در Console مرورگر جدید یا یک فایل آزمایشی با پسوند mjs اجرا کنید؛ متغیرهای نمونههای مختلف را بیدلیل در یک دامنه ترکیب نکنید. پروژهٔ همراه با Node.js نسخهٔ ۲۴ یا بالاتر اجرا میشود. این آموزش ادعا نمیکند رفتار سرعت روی همهٔ مرورگرها یکسان است؛ بخش نخست، رفتار قابل مشاهدهٔ برنامه را تثبیت میکند تا مقایسهٔ بعدی معنی داشته باشد.
اندیس، ویژگی و length؛ سه مفهوم نزدیک اما متفاوت
آرایه یک شیء با قواعد ویژه برای اندیسهای خود و length است. نوشتن در یک اندیس جلوتر، طول را افزایش میدهد، اما خانههای میان راه را با مقدار دلخواه پر نمیکند. از سوی دیگر، ویژگیای مانند note میتواند روی همان شیء باشد و عضو اندیسدار فهرست محسوب نشود. رشتهٔ «01» نیز همان اندیس استاندارد «1» نیست. این تمایز وقتی اهمیت دارد که دادهٔ ورودی را با شمار کل ویژگیهای شیء یا با بیشترین شناسهٔ تجاری اشتباه میگیرید. شناسهٔ ردیف یک مفهوم محصول است؛ لازم نیست شمارهٔ آن، محل قرارگیری ردیف در آرایه باشد.
const rows = ['الف'];
rows[3] = 'د';
rows.note = 'دادهٔ آزمایشی';
rows['01'] = 'ویژگی دیگر';
console.log(rows.length); // 4
console.log(rows[1]); // undefined
console.log(Object.hasOwn(rows, 1)); // false
console.log(Object.keys(rows)); // ['0', '3', 'note', '01']
rows.length = 2;
console.log(rows.length, Object.hasOwn(rows, 3)); // 2 falseدر این مثال length چهار است، ولی فقط دو اندیس مقدارِ متعلق به خود آرایه دارند. خانهٔ خالی یا hole یعنی آن ویژگی اندیسدار وجود ندارد؛ مقدار ویژهای به نام hole داخل خانه ذخیره نشده است. خواندن خانهٔ غایب معمولاً undefined میدهد، اما همین نتیجه بهتنهایی وجود ویژگی را ثابت نمیکند. کوتاهکردن length در این آرایهٔ معمولی، اندیسهای بیرون محدوده را حذف کرد؛ افزایش آن در عوض خانههای غایب میسازد. بنابراین length شمار رکوردهای معتبر گزارش نیست. برای تشخیص اعتبار هر ردیف باید قرارداد جداگانه داشته باشیم و موفقیت یک خواندن ساده کافی نیست.
length فقط عدد صحیح نامنفیِ کمتر از دو به توان سیودو میپذیرد؛ مقدار منفی یا اعشاری خطاست. این سقف زبانی، توصیهٔ ساخت آرایهای نزدیک آن اندازه نیست. در کار واقعی، سقف ورودی را متناسب با حافظه و نیاز برنامه بسیار کوچکتر انتخاب کنید. آرایهٔ new Array(5) نیز پنج ردیف آماده نمیسازد؛ پنج جایگاه غایب دارد. اگر تولید نمونهٔ متراکم لازم است، سازندهای مانند Array.from همراه تابع مقداردهی را آگاهانه به کار ببرید. نامگذاری «ظرفیت رزروشده» برای length هم نباید وعدهٔ کنترل مستقیم حافظهٔ داخلی موتور را القا کند؛ این ویژگی قرارداد قابل مشاهدهٔ آرایه است.
چرا فهرست کلیدها، پیمایش مقدارها و مالکیت یکی نیستند؟
const sparse = [, undefined, 'پایان'];
sparse.note = 'یادداشت';
console.log(sparse.length); // 3
console.log(Object.hasOwn(sparse, 0), Object.hasOwn(sparse, 1)); // false true
console.log(Object.keys(sparse)); // ['1', '2', 'note']
console.log([...sparse.keys()]); // [0, 1, 2]
console.log([...sparse]); // [undefined, undefined, 'پایان']Object.keys فقط نام ویژگیهای رشتهای، قابل شمارش و متعلق به خود شیء را برمیگرداند؛ length به این فهرست نمیآید، ولی note میآید. iterator کلیدهای آرایه در نمونه، اندیسهای بازهٔ طول را تولید میکند؛ iterator مقدارها نیز خانهٔ غایب را هنگام خواندن به شکل undefined میبیند. در مقابل، for...in میتواند ویژگیهای قابل شمارش در زنجیرهٔ prototype را هم ببیند و ابزار مناسبی برای فرض «فقط ردیفهای فهرست» نیست. روش پیمایش را از پرسش انتخاب کنید: مالکیت ویژگی، بازهٔ اندیسها یا مقدارهای قابل خواندن؟ این سه پرسش نتیجهٔ یکسانی ندارند.
تفاوت مهم دیگری میان in و Object.hasOwn وجود دارد: اولی میتواند ویژگی ارثرسیده را هم پیدا کند. برخی متدهای آرایه برای تصمیم به بازدید یک اندیس، وجود ویژگی را در زنجیره بررسی میکنند؛ پس جملهٔ کوتاه «همهٔ حفرهها همیشه رد میشوند» دقیق نیست. در مثال زیر فقط prototype همین آرایهٔ آزمایشی را عوض میکنیم و به Array.prototype عمومی دست نمیزنیم. این کار برای آشکارکردن رفتار است، نه پیشنهاد طراحی مدل گزارش. ورودی پروژهٔ کاربردی، آرایهای عادی از دادههای ساده است؛ getter، Proxy و prototype دستکاریشده را بیتوضیح وارد فرضهای آزمون کارایی نمیکنیم.
const localPrototype = Object.create(Array.prototype);
localPrototype[1] = 'مقدار ارثی';
const values = ['الف', , 'ج'];
Object.setPrototypeOf(values, localPrototype);
const visited = [];
values.forEach((value, index) => visited.push([index, value]));
console.log(Object.hasOwn(values, 1), 1 in values); // false true
console.log(visited);
// [[0, 'الف'], [1, 'مقدار ارثی'], [2, 'ج']]خانهٔ غایب در هر متد چه سرنوشتی دارد؟
برای آرایهٔ معمولی و بدون اندیس ارثی، map، filter و forEach خانهٔ غایب را به callback تحویل نمیدهند. بااینحال خروجی آنها یکسان نیست: map معمولاً طول و جای غیبت را نگه میدارد، ولی filter خروجی انتخابشده را فشرده میسازد. find و findIndex در مقابل، اندیس غایب را هم با مقدار خواندهشده بررسی میکنند. includes خانهٔ غایب را مانند undefined میبیند، درحالیکه indexOf برای یافتن undefined صریح، آن جای غایب را نادیده میگیرد. بنابراین عوضکردن نام یک روش با روش دیگر، حتی وقتی ظاهر خروجیهای عادی مشابه است، ممکن است قرارداد را عوض کند.
const input = [10, , undefined, 30];
let mapCalls = 0;
const mapped = input.map(value => { mapCalls += 1; return value; });
const filtered = input.filter(() => true);
const findVisits = [];
input.find((value, index) => { findVisits.push(index); return false; });
console.log(mapCalls, mapped.length, Object.hasOwn(mapped, 1)); // 3 4 false
console.log(filtered.length, Object.hasOwn(filtered, 1)); // 3 true
console.log(findVisits); // [0, 1, 2, 3]
console.log([,].includes(undefined), [,].indexOf(undefined)); // true -1
console.log(Object.hasOwn(input.slice(), 1), Object.hasOwn([...input], 1));
// false trueباکس را اجرا و سپس جای خالی را با undefined صریح پر کنید. تعداد فراخوانی map بیشتر میشود، گرچه خواندن دو حالت ممکن است متن یکسانی در کنسول نشان دهد. slice غیبت را حفظ میکند؛ spread با پیمایش مقدارها، در آن محل undefined موجود میسازد. Array.from روی همین آرایهٔ عادی نیز خروجی متراکم میدهد. این تبدیل ممکن است عمدی باشد، ولی «کپی کاملاً یکسان» نام مناسبی برای آن نیست. مقایسهٔ فقط JSON.stringify هم این تفاوت را خوب آشکار نمیکند؛ در نمایش JSON آرایه، غیبت و undefined میتوانند هر دو به null تبدیل شوند. آزمون مالکیت اندیس لازم است.
flat نیز ابزار اعتبارسنجی یا پرکردن دادهٔ گمشده نیست. در سطحهایی که باز میکند، خانههای غایب را کنار میگذارد؛ عمق پیشفرض فقط یک است و آرایهٔ عمیقتر ممکن است همچنان داخل نتیجه بماند. flatMap خروجی callback را فقط یک سطح باز میکند و callback آن روی غیبتِ ورودی اجرا نمیشود. اگر جایگاه ردیف معنای مستقلی دارد، حذف این فاصلهها میتواند ارتباط با شمارهٔ اولیه را از بین ببرد. در گزارش پروژه، بهجای «تمیزکردن» خاموش ورودی با این روشها، آرایهٔ ناقص را رد میکنیم تا خطای داده پشت خروجی مرتب پنهان نشود.
تغییر آرایه در حین پیمایش؛ تصویری ثابت از همه چیز نداریم
const data = [10, 20, 30];
const observed = [];
data.forEach((value, index) => {
observed.push(value);
if (index === 0) {
data.push(40);
delete data[1];
data[2] = 99;
}
});
console.log(observed); // [10, 99]
console.log(data.length); // 4forEach در این نمونه طول آغازین را مبنای محدوده میگیرد؛ ۴۰ تازه به انتها اضافه شده و بازدید نمیشود. ردیف دوم پیش از نوبتش حذف شده و مقدار سوم پیش از خواندهشدن تغییر کرده است. پس «طول در ابتدا خوانده شد» به معنی snapshot مستقل از همهٔ مقدارها نیست. این رفتار برای کدهای دارای اثر جانبی اهمیت دارد. نسخهای که map و filter را به یک حلقه تبدیل میکند، ممکن است ترتیب اجرای callback، زمان وقوع خطا یا مشاهدهٔ تغییرها را جابهجا کند. پیش از ادعای معادلبودن، باید ساده و بیاثر جانبی بودن عملیات را روشن کنیم.
قاعدهٔ عملی برای گزارش آموزشی این است که تابع محاسبه ورودی را تغییر ندهد و تابعهای انتخاب و محاسبه فقط به دادهٔ معتبر تکیه کنند. اگر واقعاً ویرایش همزمان لازم دارید، آن را مرحلهای جدا طراحی کنید و نتیجهٔ مورد انتظار را قبل از انتخاب حلقه بنویسید. نام یک متدِ ظاهراً تابعی، callback تغییردهنده را پاک و بیاثر نمیکند. این نکته هنگام استفاده از sort درون یک callback یا تغییر شیء مشترک هم صدق میکند. خواننده باید بتواند تشخیص دهد کدام عملیات صرفاً گزارش میسازد و کدام عملیات وضعیت برنامه را عوض میکند.
نسخهٔ تازه، الزاماً دادهٔ مستقل نیست
مدخل #array-copy-spread یادآور میشود که ساخت آرایهٔ تازه با مستقلشدن ردیفها فرق دارد. در این مرحله مسئلهٔ تازه فقط کپی سطحی نیست؛ باید مالکیت خروجی را تعریف کنیم. toSorted، toReversed و toSpliced آرایهٔ ورودی را مستقیماً تغییر نمیدهند، اما شیءهای باقیمانده همچنان میتوانند مشترک باشند. اگر خروجی گزارش را به ویرایشگر بدهید، دستکاری آن شیء شاید دادهٔ اصلی را تغییر دهد. انتخاب درست میتواند ساختن DTO کوچک از فیلدهای لازم باشد؛ یعنی شیء خروجی با قرارداد مشخص، نه کپی عمیق بیدلیل از تمام ساختار برنامه.
const original = [{ id: 'r2', amount: 20 }, { id: 'r1', amount: 10 }];
const ordered = original.toSorted((a, b) => a.amount - b.amount);
console.log(original.map(row => row.id)); // ['r2', 'r1']
console.log(ordered[0] === original[1]); // true
const report = ordered.map(({ id, amount }) => ({ id, amount }));
report[0].amount = 999;
console.log(original[1].amount); // 10
console.log(report[0] === ordered[0]); // falseدر نمونه، ردیفهای خروجی report شیءهای تازهاند، چون فقط دو فیلد ساده دوباره ساخته شدهاند. اگر فیلد تو در تو را همانطور منتقل کنیم، اشتراک آن سطح باقی میماند. Object.freeze نیز بهتنهایی تمام درخت را منجمد نمیکند؛ برای آزمون میتوان ورودی و ردیفهای ساده را منجمد کرد تا تغییر ناخواسته زودتر دیده شود. این روش، همهٔ امکانهای اثر جانبی را اثبات نمیکند، اما برای قرارداد کوچک پروژه مفید است. برعکس، تغییر مستقیم همیشه خطا نیست؛ اگر تابع مالک تنها نسخه باشد و قرارداد صریحاً ویرایش بخواهد، mutation انتخاب مجاز و قابل آزمونی است.
حذف و درج؛ انتخاب بر اساس معنای نتیجه
const queue = ['a', 'b', 'c'];
const deleted = queue.slice();
delete deleted[1];
const compact = queue.toSpliced(1, 1);
const tail = queue.toSpliced(-1, 1, 'new');
console.log(deleted.length, Object.hasOwn(deleted, 1)); // 3 false
console.log(compact); // ['a', 'c']
console.log(tail); // ['a', 'b', 'new']
console.log(queue); // ['a', 'b', 'c']delete ویژگی را حذف میکند و شکاف باقی میگذارد؛ حذف ردیف با جابهجایی بقیه، درخواست متفاوتی است. splice آرایهٔ اصلی را ویرایش و ردیفهای برداشتهشده را برمیگرداند؛ toSpliced آرایهٔ نتیجه را تازه میسازد و خروجیاش ردیفهای باقیمانده و درجشده است. اندیس منفی از انتها محاسبه میشود. تفاوت «پارامتر حذف داده نشده» با «صفر» را نیز از روی امضای متد بخوانید. برای صف پرترافیک، بیرونکشیدن مداوم از ابتدای آرایه میتواند کار جابهجایی ایجاد کند؛ تصمیم ساختاری را با اندازه و الگوی استفاده بسنجید، نه با حکم کلی ممنوعبودن shift.
اگر هدف دسترسی با شناسه است، Map ممکن است قرارداد روشنتری از جستوجوی مکرر در فهرست داشته باشد؛ اگر ترتیبِ فهرست برای صفحه لازم است، آرایه همچنان جای خود را دارد. گاهی هر دو نمای داده لازماند و باید سازگاری آنها مدیریت شود. جایگزینی آرایه با Map بدون تعریف شناسهٔ تکراری، مسئله را حل نمیکند: set دوباره روی یک کلید، مقدار قبلی را عوض میکند. استاندارد نیز برای Map فقط دسترسی متوسطِ زیرخطی میخواهد؛ از نام ساختار، تضمین مطلق زمان ثابت نتیجه نگیرید. هزینهٔ ساختن و نگهداری نمای کمکی هم در تصمیم بعدی حساب میشود.
برابری، شناسهٔ تکراری و NaN
const numbers = [NaN, -0, 4];
console.log(numbers.indexOf(NaN)); // -1
console.log(numbers.includes(NaN)); // true
console.log(numbers.includes(0)); // true
const records = [{ id: 'r1' }, { id: 'r1' }];
console.log(new Set(records).size); // 2
console.log(new Set(records.map(row => row.id)).size); // 1indexOf برای مقایسهٔ مقدار از برابری سخت استفاده میکند؛ NaN با خودش هم در این مقایسه برابر نیست. includes و عضویت در Set از رابطهٔ SameValueZero استفاده میکنند که NaN را پیدا میکند و مثبت و منفی صفر را یکسان میبیند. این تفاوت به معنی معتبرشدن مبلغ NaN نیست؛ ابتدا باید مقدارِ دامنه را بررسی کرد. برای شیءها نیز هویت مرجع مهم است؛ دو شیء با فیلد id برابر، خودکار یک رکورد واحد محسوب نمیشوند. رابطهٔ برابری را از نیاز استخراج کنید و بعد روش جستوجو یا مجموعهسازی را انتخاب کنید.
در گزارش، شناسهٔ تکراری باید خطا باشد، چون ممکن است یک تراکنش دوبار وارد شده باشد. حذف خاموش تکرار با Set و ادامهٔ محاسبه، تصمیم حسابداری پنهان میسازد. در مقابل، ساخت گزینههای یکتای یک فیلتر میتواند دقیقاً همان رفتاری باشد که میخواهیم. همچنین مقداری مانند صفر، رشتهٔ خالی یا false را صرفاً با filter(Boolean) حذف نکنید مگر قرارداد آنها را نامعتبر بداند. این عبارت بهصورت ویژه «دادهٔ خراب» را تشخیص نمیدهد؛ فقط truthiness را میسنجد. دادهٔ معتبر و موجود، لزوماً مقدار truthy نیست و فشردهکردن فهرست ممکن است اطلاعات درست را حذف کند.
مرتبسازی؛ مقایسهگر باید قانون باشد، نه حدس
مرتبسازی پیشفرض Array بر پایهٔ تبدیل مقدارهای غیرِ undefined به رشته و مقایسهٔ واحدهای UTF-16 است؛ پس ۱۰۰ ممکن است پیش از ۲۰ قرار گیرد. sort خود آرایه را تغییر میدهد و همان مرجع را برمیگرداند؛ toSorted آرایهٔ تازه میدهد. در آرایهٔ عادیِ تنک، sort حفرهها را در انتها نگه میدارد و undefined موجود را پیش از آنها میآورد؛ toSorted غیبتها را مانند undefined خوانده و خروجی متراکم میسازد. مقایسهگر معمولی برای undefined فراخوانی نمیشود. این جزئیات نشان میدهد جایگزینی دو روش فقط پرسش سرعت نیست؛ شکل و مالکیت خروجی هم مهم است.
const nums = [20, 100, 3];
console.log(nums.toSorted()); // [100, 20, 3]
console.log(nums.toSorted((a, b) => a - b)); // [3, 20, 100]
const records = [
{ id: 'b', score: 2 },
{ id: 'a', score: 2 },
{ id: 'c', score: 1 }
];
console.log(records.toSorted((a, b) => a.score - b.score).map(x => x.id));
// ['c', 'b', 'a']
const totalOrder = records.toSorted((a, b) =>
a.score - b.score || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
);
console.log(totalOrder.map(x => x.id)); // ['c', 'a', 'b']مقایسهگر باید منفی، صفر یا مثبت را با معنای ثابت برگرداند؛ لازم نیست فقط عدد یک و منفی یک باشد. نوشتن a > b یک بولی میدهد و برای ترتیب عمومی قانون کامل نمیسازد. مقایسهگرِ درست باید برای ورودی یکسان جواب سازگار داشته باشد، جهت معکوس را رعایت کند و رابطهٔ انتقالپذیر بسازد؛ تغییر ردیفها، زمان جاری یا عدد تصادفی درون آن این تعهد را مخدوش میکند. حاصل NaN نیز هنگام مقایسه مانند صفر تعبیر میشود، پس تفریق مبلغهای نامعتبر میتواند خطا را به تساوی ظاهری تبدیل کند. اعتبارسنجیِ پیش از مرتبسازی بخشی از صحت آن است.
پایداری میگوید ردیفهایی که مقایسهگر برابر میداند، ترتیب نسبی قبلی را حفظ میکنند. در مثال نخست b پیش از a باقی ماند؛ برای ترتیب قطعیِ مستقل از جای اولیه، کلید شکستن تساوی را افزودیم. اگر شناسهها طبق قرارداد یکتا باشند، کلید نهایی ترتیب دو ردیف متفاوت را روشن میکند. این سیاست را با داشتن دادهٔ تکراری یا مقایسهگر ناسازگار اشتباه نگیرید. همچنین استاندارد، تعداد دقیق مقایسهها یا الگوریتم و هزینهٔ زمانی و فضایی یکسانی برای همهٔ اجراها وعده نمیدهد. یک آزمون کوچک نمیتواند از بیرون ثابت کند که همهٔ موتورهای آینده همان الگوریتم داخلی را خواهند داشت.
عنوان فارسی و ترتیب قابل بازتولید
برای عنوان فارسی، مقایسهٔ رشتهای ساده جای سیاست زبانی را نمیگیرد. Intl.Collator را با locale و گزینههای روشن میسازیم و تابع compare آن را استفاده میکنیم؛ نتیجهٔ معتبر فقط علامت عدد است و نباید انتظار دقیقِ منفی یک داشته باشیم. numeric میتواند بخش عددی عنوانها را به شکل طبیعیتری مقایسه کند؛ sensitivity نیز میزان تمایز را تغییر میدهد. اگر دو عنوان از دید این سیاست برابر شدند، شناسهٔ یکتا میتواند تساوی را بشکند. این مرحله ترتیب نمایشی است؛ برابری شناسهها یا اعتبار نام کاربر را از نتیجهٔ collator استخراج نمیکنیم.
const collator = new Intl.Collator('fa', { numeric: true, sensitivity: 'base' });
const lessons = [
{ id: 'x2', title: 'درس 10' },
{ id: 'x1', title: 'درس 2' },
{ id: 'x0', title: 'درس 2' }
];
const sorted = lessons.toSorted((a, b) =>
collator.compare(a.title, b.title) ||
(a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
);
console.log(sorted.map(row => row.id)); // ['x0', 'x1', 'x2']
console.log(Math.sign(collator.compare('درس 2', 'درس 10'))); // -1برای آزمون، رابطههای مهم و شکستن تساوی را بررسی کنید و دربارهٔ تمام جزئیات ترتیب یک فهرست چندزبانه در همهٔ نسخههای ICU وعده ندهید. اگر محصول به ترتیب بایگانیِ کاملاً ثابت میان ماشینها نیاز دارد، قرارداد و نسخهٔ دادهٔ مرتبسازی آن باید جدا تعیین شود. مثال بالا مستقل است و گزینهٔ numeric را عمداً روشن کرده؛ تابع rankProducts در فایل همراه، از گزینههای پیشفرض همان locale استفاده میکند. مدخل #array-sort-reverse برای مراجعهٔ سریع کاربرد دارد، اما تصمیم اصلی شما تعریف کلیدها و رفتار تساوی است؛ انتخاب نام متد جای این طراحی را نمیگیرد.
پروژهٔ array-lab؛ از ورودی معتبر تا گزارش قابل آزمون
اکنون نمونههای کوچک را به یک قرارداد کاربردی وصل میکنیم. فایل arrays.mjs همراه پروژه، مرز دریافت داده را از توابع محاسبه جدا میکند. ردیف گزارش، id عدد صحیح امن و نامنفیِ یکتا، category متنیِ یک تا چهل واحد رشته، active بولی و amount عدد صحیحِ صفر تا یک میلیارد دارد. آرایهٔ تنک، شناسهٔ تکراری و مقدار نامعتبر پذیرفته نمیشوند؛ مجموع نیز باید صحیح امن بماند. این تصمیمها یک تمرین آموزشیاند و معادل طراحی کامل سامانهٔ مالی نیستند. مدخل #validation-contracts برای مرور این تفکیک مفید است: تبدیل موفق به آرایه یا JSON، اعتبار دامنهٔ داده را اثبات نمیکند.
هنگام خواندن ماژول کامل زیر، ابتدا تابع اعتبارسنجی را پیدا کنید؛ بعد مسیر دادهٔ پذیرفتهشده تا گزارش را دنبال کنید. ببینید چه فیلدهایی نگه داشته میشوند، مالکیت ردیفهای خروجی چیست و کدام تابع مجاز است ورودی را تغییر دهد. نامهای همین فایل واقعی مبنای اجرای پروژهاند و لازم نیست نسخهای با نامهای مشابه از روی حدس بسازید. بخش اندازهگیری، همین قرارداد را نگه میدارد و راههای متفاوت انجام یک کار را مقایسه میکند؛ دادهٔ کمتر، حذف بررسی لازم یا نتیجهٔ متفاوت، «بهینهسازی همان برنامه» محسوب نمیشود.
// Teaching helpers: correctness comes before measurement.
export function describeSlots(array) {
if (!Array.isArray(array)) throw new TypeError('Expected an array');
const slots = [];
for (let index = 0; index < array.length; index += 1) {
slots.push({ index, own: Object.hasOwn(array, index),
present: index in array, value: array[index] });
}
return { length: array.length, keys: Object.keys(array), slots };
}
// Unlike a-b, this is also defined for NaN and equal infinities.
export function compareNumbers(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new TypeError('Expected numbers');
}
if (Number.isNaN(a)) return Number.isNaN(b) ? 0 : 1;
if (Number.isNaN(b)) return -1;
return a < b ? -1 : a > b ? 1 : 0;
}
export function sortNumbers(values) {
if (!Array.isArray(values)) throw new TypeError('Expected an array');
for (let i = 0; i < values.length; i += 1) {
if (!Object.hasOwn(values, i) || typeof values[i] !== 'number') {
throw new TypeError('Expected dense numeric data');
}
}
return values.toSorted(compareNumbers);
}
export function rankProducts(products, locale = 'fa') {
if (!Array.isArray(products)) throw new TypeError('Expected an array');
const seen = new Set();
for (let i = 0; i < products.length; i += 1) {
if (!Object.hasOwn(products, i)) throw new TypeError('Sparse product list');
const product = products[i];
if (!product || typeof product !== 'object' ||
!['id', 'title', 'price'].every(key =>
Object.hasOwn(Object.getOwnPropertyDescriptor(product, key) ?? {}, 'value')) ||
typeof product.id !== 'string' || product.id.length === 0 ||
seen.has(product.id) || typeof product.title !== 'string' ||
typeof product.price !== 'number' || !Number.isFinite(product.price) || product.price < 0) {
throw new TypeError('Invalid product');
}
seen.add(product.id);
}
const collator = new Intl.Collator(locale);
return products.toSorted((a, b) => compareNumbers(a.price, b.price) ||
collator.compare(a.title, b.title) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
}
// Contract for this small example: unique ids and ordinary {id, meta} records.
export function updateNote(rows, id, note) {
if (!Array.isArray(rows) || typeof note !== 'string' || note.length > 200) {
throw new TypeError('Expected rows and a short note');
}
if (!rows.some(row => row.id === id)) return rows;
return rows.map(row => row.id === id ? { ...row, meta: { ...row.meta, note } } : row);
}
// Validate once at the boundary, not inside each timed report.
// Proxies and concurrent mutation are outside this educational contract.
export function assertReportRows(rows) {
if (!Array.isArray(rows) || Object.getPrototypeOf(rows) !== Array.prototype ||
rows.length > 1_000_000) throw new TypeError('Expected an ordinary bounded array');
const ids = new Set();
let total = 0;
for (let i = 0; i < rows.length; i += 1) {
const slot = Object.getOwnPropertyDescriptor(rows, String(i));
if (!slot || !Object.hasOwn(slot, 'value')) throw new TypeError('Expected dense data slots');
const row = slot.value;
if (!row || typeof row !== 'object' ||
![Object.prototype, null].includes(Object.getPrototypeOf(row))) {
throw new TypeError('Expected ordinary records');
}
for (const key of ['id', 'category', 'active', 'amount']) {
const field = Object.getOwnPropertyDescriptor(row, key);
if (!field || !Object.hasOwn(field, 'value')) throw new TypeError('Expected own data fields');
}
if (!Number.isSafeInteger(row.id) || row.id < 0 || ids.has(row.id) ||
typeof row.category !== 'string' || row.category.length < 1 || row.category.length > 40 ||
typeof row.active !== 'boolean' || !Number.isSafeInteger(row.amount) ||
row.amount < 0 || row.amount > 1_000_000_000) throw new TypeError('Invalid report record');
ids.add(row.id);
total += row.amount;
if (!Number.isSafeInteger(total)) throw new RangeError('Unsafe total');
}
return rows;
}
// Preconditions for both reports: validated, unchanged dense data;
// category is a string and minAmount a safe nonnegative integer.
export function reportWithMethods(rows, category, minAmount) {
const amounts = rows
.filter(row => row.active && row.category === category && row.amount >= minAmount)
.map(row => row.amount);
return amounts.reduce((report, amount) => {
report.count += 1;
report.total += amount;
report.max = report.max === null ? amount : Math.max(report.max, amount);
return report;
}, { count: 0, total: 0, max: null });
}
export function reportWithLoop(rows, category, minAmount) {
const report = { count: 0, total: 0, max: null };
for (let i = 0; i < rows.length; i += 1) {
const row = rows[i];
if (row.active && row.category === category && row.amount >= minAmount) {
report.count += 1;
report.total += row.amount;
report.max = report.max === null ? row.amount : Math.max(report.max, row.amount);
}
}
return report;
}
export function makeRows(size) {
if (!Number.isSafeInteger(size) || size < 1 || size > 1_000_000) {
throw new RangeError('Size must be 1..1000000');
}
return Array.from({ length: size }, (_, id) => ({ id,
category: id % 4 === 0 ? 'education' : 'other',
active: id % 5 !== 0, amount: (id * 7919) % 100000 }));
}این مثال به محیط مرورگر، پروژه یا Node.js نیاز دارد؛ طبق راهنمای درس اجرا کنید.describeSlots برای دیدن وضع هر اندیس است و بهاندازهٔ length پیمایش میکند؛ آن را روی آرایهٔ تنکِ بسیار عظیم آزمایش نکنید. compareNumbers قرارداد مستقلی دارد: NaN را در انتها و بینهایتهای برابر را برابر قرار میدهد؛ تفریق ساده برای Infinity منهای Infinity جواب مناسب نمیدهد. این پذیرش NaN در ابزار مرتبسازی عمومی، مجوز پذیرش آن بهعنوان مبلغ گزارش نیست. rankProducts نیز قیمت متناهی و نامنفی و شناسهٔ متنی یکتا میخواهد و آرایهٔ تازه با ردیفهای مشترک برمیگرداند. updateNote فقط برای نمونهٔ کوچکِ دارای شناسههای یکتا و شیءهای عادی است؛ آن را اعتبارسنج عمومی هر دادهٔ ناشناخته فرض نکنید.
برای محاسبهٔ گزارش، ابتدا assertReportRows را یک بار در مرز ورود فراخوانی کنید. این بررسی فقط مقدارها را نمیسنجد؛ اندیسها و فیلدهای لازم باید data property متعلق به خود داده باشند، نه getter وابسته به زمان یا ویژگی ارثی. بررسی عادی نمیتواند هر رفتار Proxy دلخواه یا تغییر همزمان را مهار کند؛ چنین ورودیهایی بیرون قرارداد این پروژهاند. builtinها و prototypeهای استاندارد نیز باید دستنخورده باشند؛ تغییر constructor یا Symbol.species در قرارداد مقایسه نیست. پس از پذیرش، category پرسوجو باید متن و minAmount عدد صحیح امن و نامنفی باشد. دو تابع reportWithMethods و reportWithLoop خودشان دوباره همهٔ این کنترلها را انجام نمیدهند؛ حذف کنترل از حلقهٔ زمانسنجی به معنی حذف آن از برنامه نیست.
import { assertReportRows, reportWithMethods, reportWithLoop } from './arrays.mjs';
const rows = assertReportRows([
{ id: 1, category: 'education', active: true, amount: 10 },
{ id: 2, category: 'education', active: true, amount: 30 },
{ id: 3, category: 'education', active: false, amount: 90 }
]);
console.log(reportWithMethods(rows, 'education', 10));
console.log(reportWithLoop(rows, 'education', 10));
// هر دو: { count: 2, total: 40, max: 30 }
console.log(reportWithLoop(rows, 'missing', 0));
// { count: 0, total: 0, max: null }این مثال به محیط مرورگر، پروژه یا Node.js نیاز دارد؛ طبق راهنمای درس اجرا کنید.بلوک مصرف ماژول را در فایل آزمایشی خودتان کنار arrays.mjs قرار دهید و با node اجرا کنید. ردیف غیرفعالِ نودواحدی در پاسخ حساب نشده است؛ پرسش گزارش فقط ردیفهای فعالِ دستهٔ انتخابی و بالاتر از آستانه است. بیشینهٔ گزارش خالی null است، چون «ردیفی نداریم» با «بیشترین مقدار صفر است» فرق دارد. اگر به همین ورودی یک مبلغ NaN اضافه کنید، مرز اعتبارسنجی باید پیش از محاسبه خطا بدهد. ترتیب ورودی را هم عوض کنید و نتیجهٔ همین دادههای صحیح را مقایسه کنید؛ این آزمایش دربارهٔ گزارش انتخابی است و ادعای یکسانبودن ترتیب همهٔ انواع جمع اعشاری ندارد.
آزمونها؛ مثال موفق کافی نیست
آزمونهای همراه را پیش از تغییر کد اجرا کنید و پس از هر تغییر دوباره بخوانید؛ فقط سبزشدن خلاصه را نبینید. انتظار مهم این است که ورودی معتبر جواب درست بدهد و ورودی نامعتبر مطابق قرارداد رد شود. برای آرایهٔ غایب، undefined صریح، مبلغ NaN، شناسهٔ تکراری، فهرست خالی، مجموع مرزی و ثابتماندن ورودی، سناریوی جدا لازم است. برخی از این حالتها ظاهر خروجی یکسان دارند، ولی معنای داده متفاوت است. آزمون مناسب باید همین مرز را آشکار کند؛ مقایسهٔ رشتهٔ چاپشده یا تعداد ردیفهای یک نمونهٔ شلوغ بهتنهایی کافی نیست.
import test from 'node:test';
import assert from 'node:assert/strict';
import { describeSlots, compareNumbers, sortNumbers, rankProducts, updateNote,
assertReportRows, reportWithMethods, reportWithLoop, makeRows } from './arrays.mjs';
test('holes, own undefined, and length are different facts', () => {
const input = [, undefined, 'x'];
const result = describeSlots(input);
assert.equal(result.length, 3);
assert.deepEqual(result.keys, ['1', '2']);
assert.deepEqual(result.slots[0], { index: 0, own: false, present: false, value: undefined });
assert.deepEqual(result.slots[1], { index: 1, own: true, present: true, value: undefined });
});
test('an inherited indexed property is present without being own', () => {
const prototype = Object.create(Array.prototype);
prototype[0] = 'inherited';
const input = new Array(2);
Object.setPrototypeOf(input, prototype);
assert.deepEqual(describeSlots(input).slots[0],
{ index: 0, own: false, present: true, value: 'inherited' });
assert.deepEqual(input.map(value => value), ['inherited', ,]);
});
test('enumeration keys include own custom keys but length does not count them', () => {
const input = ['a'];
input.label = 'note';
input['01'] = 'not an array index';
assert.equal(input.length, 1);
assert.deepEqual(describeSlots(input).keys, ['0', 'label', '01']);
});
test('delete leaves a hole; splice closes the gap; shortening length removes slots', () => {
const deleted = ['a', 'b', 'c'];
delete deleted[1];
assert.equal(deleted.length, 3);
assert.equal(Object.hasOwn(deleted, 1), false);
const spliced = ['a', 'b', 'c'];
spliced.splice(1, 1);
assert.deepEqual(spliced, ['a', 'c']);
deleted.length = 1;
assert.equal(Object.hasOwn(deleted, 2), false);
});
test('map skips holes; spread and toSorted materialize undefined', () => {
const input = [, 2];
const mapped = input.map(value => value * 2);
assert.equal(Object.hasOwn(mapped, 0), false);
assert.deepEqual([...input], [undefined, 2]);
assert.deepEqual(input.toSorted(), [2, undefined]);
assert.equal(Object.hasOwn(input.toSorted(), 1), true);
});
test('forEach skips absent slots; for-of reads undefined at a hole', () => {
const input = [, undefined, 3];
const indexes = [];
input.forEach((_, index) => indexes.push(index));
assert.deepEqual(indexes, [1, 2]);
assert.deepEqual(Array.from(input), [undefined, undefined, 3]);
});
test('SameValueZero searches NaN and treats signed zero alike', () => {
const input = [NaN, -0, +0];
assert.equal(input.indexOf(NaN), -1);
assert.equal(input.includes(NaN), true);
assert.equal(new Set(input).size, 2);
assert.equal(Object.is(-0, +0), false);
assert.equal(new Set([{ id: 1 }, { id: 1 }]).size, 2);
});
test('numeric comparator orders NaN last and handles infinities without NaN results', () => {
assert.deepEqual(sortNumbers([NaN, Infinity, 10, -Infinity, 2, NaN]),
[-Infinity, 2, 10, Infinity, NaN, NaN]);
assert.equal(compareNumbers(Infinity, Infinity), 0);
assert.equal(compareNumbers(-0, +0), 0);
assert.throws(() => compareNumbers('2', 2), TypeError);
});
test('comparator satisfies reflexivity, antisymmetry and transitivity on boundary values', () => {
const values = [-Infinity, -4, -0, +0, 2, Infinity, NaN];
for (const a of values) {
assert.equal(compareNumbers(a, a), 0);
for (const b of values) {
assert.equal(compareNumbers(a, b) + compareNumbers(b, a), 0);
for (const c of values) {
if (compareNumbers(a, b) <= 0 && compareNumbers(b, c) <= 0) {
assert.ok(compareNumbers(a, c) <= 0);
}
}
}
}
});
test('sortNumbers rejects sparse/non-numeric input and does not change input', () => {
const input = [10, 2, 1];
assert.deepEqual(sortNumbers(input), [1, 2, 10]);
assert.deepEqual(input, [10, 2, 1]);
assert.deepEqual([...input].sort(), [1, 10, 2]);
assert.throws(() => sortNumbers([, 2]), TypeError);
assert.throws(() => sortNumbers([undefined, 2]), TypeError);
});
test('stable sorting preserves source order when comparator returns zero', () => {
const input = [{ id: 'b', price: 10 }, { id: 'a', price: 10 }, { id: 'c', price: 5 }];
assert.deepEqual(input.toSorted((a, b) => a.price - b.price).map(row => row.id), ['c', 'b', 'a']);
assert.deepEqual(input.map(row => row.id), ['b', 'a', 'c']);
});
test('product ranking uses price, locale collation and deterministic id tie-break', () => {
const input = [{ id: 'b', title: 'کتاب', price: 10 },
{ id: 'a', title: 'کتاب', price: 10 }, { id: 'c', title: 'دفتر', price: 2 }];
const ranked = rankProducts(input);
assert.deepEqual(ranked.map(row => row.id), ['c', 'a', 'b']);
assert.deepEqual(input.map(row => row.id), ['b', 'a', 'c']);
assert.equal(ranked[0], input[2]); // fresh array, shared objects
const words = [{ id: '1', title: 'ب', price: 1 }, { id: '2', title: 'آ', price: 1 }];
assert.ok(new Intl.Collator('fa').compare(rankProducts(words)[0].title,
rankProducts(words)[1].title) <= 0);
});
test('invalid price, duplicate product ids and accessor fields are rejected', () => {
assert.throws(() => rankProducts([{ id: 'a', title: 'x', price: NaN }]), TypeError);
assert.throws(() => rankProducts([{ id: 'a', title: 'x', price: Infinity }]), TypeError);
assert.throws(() => rankProducts([{ id: 'a', title: 'x', price: 1 },
{ id: 'a', title: 'y', price: 2 }]), TypeError);
assert.throws(() => rankProducts([{ id: 'a', title: 'x', get price() { throw Error('read'); } }]), TypeError);
});
test('shallow spread shares nested records; targeted replacement makes cancellation possible', () => {
const original = [{ id: 1, meta: { note: 'old', source: 'manual' } },
{ id: 2, meta: { note: 'other' } }];
assert.equal([...original][0], original[0]);
const edited = updateNote(original, 1, 'draft');
assert.notEqual(edited, original);
assert.notEqual(edited[0], original[0]);
assert.notEqual(edited[0].meta, original[0].meta);
assert.equal(edited[1], original[1]);
assert.equal(original[0].meta.note, 'old');
assert.deepEqual(edited[0].meta, { note: 'draft', source: 'manual' });
assert.equal(updateNote(original, 99, 'missing'), original);
});
test('freezing an array is shallow', () => {
const rows = Object.freeze([{ amount: 1 }]);
rows[0].amount = 2;
assert.equal(rows[0].amount, 2);
assert.throws(() => rows.push({ amount: 3 }), TypeError);
});
test('typed subarray shares storage but slice owns a copy', () => {
const source = new Float64Array([1, 2, 3]);
const view = source.subarray(1);
const copy = source.slice(1);
view[0] = 99;
assert.equal(source[1], 99);
assert.equal(copy[0], 2);
assert.equal(view.buffer, source.buffer);
assert.notEqual(copy.buffer, source.buffer);
assert.equal(view.byteOffset, Float64Array.BYTES_PER_ELEMENT);
});
test('typed numeric conversion is a data decision, not a universal performance upgrade', () => {
assert.equal(new Uint8Array([257, -1, 1.9])[0], 1);
assert.deepEqual([...new Uint8Array([257, -1, 1.9])], [1, 255, 1]);
assert.notEqual(new Float32Array([0.1])[0], 0.1);
assert.equal(new Float64Array([0.1])[0], 0.1);
assert.throws(() => new Float64Array([1n]), TypeError);
});
test('report boundary accepts deterministic dense independent datasets', () => {
const a = makeRows(100);
const b = makeRows(100);
assert.equal(assertReportRows(a), a);
assert.deepEqual(a, b);
assert.notEqual(a, b);
assert.notEqual(a[0], b[0]);
assert.throws(() => makeRows(0), RangeError);
});
test('report boundary rejects holes, inherited fields, indexed accessors and unsafe values', () => {
assert.throws(() => assertReportRows(new Array(2)), TypeError);
const valid = makeRows(1)[0];
assert.throws(() => assertReportRows([Object.create(valid)]), TypeError);
let getterRead = false;
const slots = [];
Object.defineProperty(slots, '0', { get() { getterRead = true; return valid; } });
assert.throws(() => assertReportRows(slots), TypeError);
assert.equal(getterRead, false);
for (const patch of [{ amount: NaN }, { amount: -1 }, { amount: 1.5 },
{ amount: 1_000_000_001 }, { active: 1 }, { category: '' }, { id: -1 }]) {
assert.throws(() => assertReportRows([{ ...valid, ...patch }]), TypeError);
}
});
test('report boundary rejects own field getters without invoking them and duplicate ids', () => {
const row = { ...makeRows(1)[0], get amount() { throw Error('unexpected read'); } };
assert.throws(() => assertReportRows([row]), TypeError);
assert.throws(() => assertReportRows([makeRows(1)[0], makeRows(1)[0]]), TypeError);
});
test('both reports match a hand-calculated result and preserve frozen input', () => {
const rows = [{ id: 1, category: 'education', active: true, amount: 10 },
{ id: 2, category: 'education', active: true, amount: 30 },
{ id: 3, category: 'education', active: false, amount: 90 },
{ id: 4, category: 'other', active: true, amount: 80 }];
assertReportRows(rows);
rows.forEach(Object.freeze);
Object.freeze(rows);
for (const report of [reportWithMethods, reportWithLoop]) {
assert.deepEqual(report(rows, 'education', 10), { count: 2, total: 40, max: 30 });
assert.deepEqual(report(rows, 'education', 30), { count: 1, total: 30, max: 30 });
assert.deepEqual(report(rows, 'missing', 0), { count: 0, total: 0, max: null });
assert.deepEqual(report([], 'education', 0), { count: 0, total: 0, max: null });
}
});
test('zero is a real maximum; empty report uses null', () => {
const rows = [{ id: 0, category: 'education', active: true, amount: 0 }];
assertReportRows(rows);
for (const report of [reportWithMethods, reportWithLoop]) {
assert.deepEqual(report(rows, 'education', 0), { count: 1, total: 0, max: 0 });
}
});
test('equivalence holds over multiple sizes, thresholds and categories', () => {
for (const size of [1, 7, 101, 1000]) {
const rows = assertReportRows(makeRows(size));
const before = structuredClone(rows);
for (const category of ['education', 'other', 'missing']) {
for (const minimum of [0, 1, 50000, 90000, 100000]) {
assert.deepEqual(reportWithMethods(rows, category, minimum),
reportWithLoop(rows, category, minimum));
}
}
assert.deepEqual(rows, before);
}
});
test('sparse input demonstrates why the equivalence precondition matters', () => {
const sparse = new Array(1);
assert.deepEqual(reportWithMethods(sparse, 'education', 0), { count: 0, total: 0, max: null });
assert.throws(() => reportWithLoop(sparse, 'education', 0), TypeError);
assert.throws(() => assertReportRows(sparse), TypeError);
});این مثال به محیط مرورگر، پروژه یا Node.js نیاز دارد؛ طبق راهنمای درس اجرا کنید.بستهٔ همراه را استخراج و در ترمینال وارد پوشهٔ دارای package.json شوید. دستور node --version باید نسخهٔ ۲۴ یا بالاتر نشان دهد. چون این پروژه وابستگی شخص ثالث ندارد، برای اجرای آزمون، نصب بستهٔ بیرونی لازم نیست. دستور npm test را اجرا کنید؛ در Windows PowerShell میتوانید npm.cmd test را به کار ببرید. اگر فایل یا ماژول پیدا نمیشود، اول پوشهٔ فعلی و نام واقعی فایلها را بررسی کنید. شکست یک assertion یعنی انتظار و رفتار واقعی متفاوتاند؛ متن خطا و همان ورودی کوچک را نگه دارید و علت را قبل از تغییر آزمون پیدا کنید.
برای افزودن ویژگی تازه، ابتدا یک نمونهٔ موفق و یک نمونهٔ مرزی بنویسید. مثلاً اگر دستهٔ خالی را بعداً مجاز میکنید، این تصمیم باید هم در اعتبارسنجی و هم در گزارش و توضیح رابط روشن باشد. از catch خالی برای عبور از ردیف خراب استفاده نکنید؛ گزارش ناقص با ظاهر موفق ممکن است خطرناکتر از توقف با پیام روشن باشد. اگر سیاست محصول حذف ردیف است، شمار و دلیل حذف باید جزئی از خروجی تعریف شود. در پروژهٔ فعلی رفتار را از روی کد و آزمونِ همان نسخه بخوانید و یک سیاست تازه را بیاعلام به آن نسبت ندهید.
تمرینهای صحت و پاسخ راهنما
تمرین نخست: آرایهای با یک خانهٔ غایب و یک undefined صریح بسازید. پیش از اجرا، تعداد callbackهای map و find و نتیجهٔ includes و indexOf را بنویسید. پاسخ راهنما، شمارش بازهٔ طول و سپس بررسی وجود ویژگی در هر اندیس است؛ دو خواندن undefined الزاماً دو وضعیت یکسان نیستند. تمرین دوم: تابع گزارش را با ردیفهای منجمد و سپس با ترتیب ورودی معکوس اجرا کنید. مجموعها باید ثابت بمانند و سیاست ترتیب خروجی باید مستقل توضیح داده شود. حفظ مقدار، حفظ مرجع و حفظ ترتیب سه آزمون متفاوتاند و جای یکدیگر را نمیگیرند.
تمرین سوم: دادهای بسازید که دو ردیف مبلغ و عنوان برابر ولی شناسهٔ متفاوت داشته باشند. پاسخ درست برای ترتیب قطعی، کلید نهاییِ شناسه و اعتبارسنجی یکتایی آن است؛ تکیه بر شانس یا خروجی یک اجرای sort کافی نیست. سپس NaN را وارد کنید و ثابت کنید مرز اعتبارسنجی پیش از مقایسه آن را رد میکند. معیار تحویل این بخش یک گزارش درست، توضیح تفاوت رفتار متدها و مجموعهای از آزمونهای قابل اجراست. حالا میتوانیم هزینهٔ همان کار را اندازه بگیریم؛ سرعت را پس از تثبیت معنا بررسی میکنیم و نتیجهٔ زمان را به محیط و بار آزمودهشده محدود نگه میداریم.
بهینهسازی آرایه؛ ابتدا هزینه را تعریف کنید
اکنون که رفتار داده روشن است، میتوانیم دربارهٔ هزینهٔ اجرای همان رفتار تصمیم بگیریم. پرسش مفید این نیست که «کدام روش آرایه همیشه سریعتر است؟»؛ باید بپرسیم چه دادهای، با چه خروجی و در کدام مسیر پرتکرار پردازش میشود. یک گزارش با صد ردیف و یک تبدیل با صدها هزار مقدار، الزاماً تصمیم یکسان نمیخواهند. اگر مفهوم اندازهٔ ورودی، هزینهٔ رشد و حافظهٔ کمکی تازه است، پیش از این بخش آموزش مستقل زیر را بخوانید. اینجا تعریفهای مقدماتی را تکرار نمیکنیم و تمرکز بر انتخاب عملی و آزمایش منصفانه است: https://www.darsavand.com/resources/javascript-runtime-cost-complexity
مدل هزینه، شمار تقریبی کارهای مهم را برحسب اندازهٔ ورودی توضیح میدهد؛ زمان اندازهگیریشده مدت مشاهدهشده در یک محیط است. دو پیادهسازی که هر دو خطی رشد میکنند ممکن است بهخاطر تعداد عبورها، تخصیص نتیجه، شکل داده یا بهینهسازی موتور زمان متفاوتی داشته باشند. از طرف دیگر، سریعترشدن برای یک ورودی کوچک، بهترشدن نرخ رشد را ثابت نمیکند. مشخصات زبان دربارهٔ رفتار روشهاست؛ از وجود یک نام داخلی در Array، تضمین عمومی دربارهٔ الگوریتم همهٔ موتورهای امروز و فردا نسازید. مدل آموزشی ما باید همراه فرضهایش خوانده شود.
برای نمونهٔ ساده، فرض کنید n ردیف معتبر داریم و k ردیف از فیلتر میگذرند. مسیر filter سپس map، آزمون انتخاب را برای ردیفهای ورودی و تبدیل را برای خروجی انتخابشده انجام میدهد. در مدل سطح کد، یک آرایهٔ واسط با k مرجع و یک خروجی نهایی لازم داریم. یک حلقهٔ ادغامشده میتواند همان انتخاب و تبدیل را در یک عبور هماهنگ کند و آرایهٔ واسط را نسازد. هر دو همچنان با n و اندازهٔ خروجی رشد میکنند؛ حذف آرایهٔ واسط، مجوز نوشتن «پیچیدگی از خطی به ثابت رسید» نیست.
ادغام filter و map فقط با حفظ قرارداد معنی دارد
برای مقایسهٔ منصفانه، در این آزمایش آرایهٔ معمولیِ متراکم و ردیفهای دادهٔ ساده در نظر میگیریم. انتخاب و تبدیل فقط به مقدار همان ردیف وابستهاند؛ ورودی، وضعیت بیرونی یا ترتیب اجرای callbackها را تغییر نمیدهند. دسترسیها getter، Proxy یا زیرکلاس دارای رفتار سفارشی نیستند. روشهای استاندارد و constructor/species دستکاری نشدهاند. خروجی باید ترتیب، مقدارها و سیاست اعتبارسنجی یکسان داشته باشد. این فرضها عمدیاند: حلقهای که برای دادهٔ معتبر آزمایش نوشته شده، جایگزین عمومی همهٔ کاربردهای filter و map نیست. پیش از زمانگیری، همارزی را روی دامنهٔ پذیرفتهشده با آزمون نشان میدهیم.
حتی «callback خالص» بهتنهایی شرط کاملی نیست. در filter(...).map(...)، اندیسِ map اندیس داخل آرایهٔ فیلترشده است؛ حلقهای که اندیس ردیف اصلی را به تبدیل بدهد ممکن است خروجی دیگری بسازد. آرگومان سوم callback هم آرایهٔ مربوط به همان مرحله است. در زنجیرهٔ map سپس filter، مرحلهٔ دوم آرایهٔ تبدیلشده را میبیند. بنابراین اگر کد به index یا array وابسته است، باید معنای آنها را در نسخهٔ تازه دقیقاً حفظ کنید یا دامنهٔ بازنویسی را به تابعهای مقدارمحور محدود کنید. شباهت ظاهری دو خط کافی نیست.
const rows = [10, 20, 30, 40];
const chained = rows.filter(value => value >= 30)
.map((value, index) => `${index}:${value}`);
const naive = [];
for (let index = 0; index < rows.length; index += 1) {
if (rows[index] >= 30) naive.push(`${index}:${rows[index]}`);
}
console.log(chained); // ['0:30', '1:40']
console.log(naive); // ['2:30', '3:40']این بلوک را مستقل در فایل fusion-index.mjs و با node fusion-index.mjs اجرا کنید. نتیجهٔ نادرستِ naive خطای سرعت نیست؛ تغییر قرارداد است و نباید وارد جدول مقایسهٔ دو پاسخ همارز شود. یک مورد ظریف دیگر، ترتیب اثرهای جانبی است: زنجیره ابتدا همهٔ انتخابها را تمام میکند و سپس تبدیلها را انجام میدهد، اما حلقهٔ ادغامشده میتواند انتخاب و تبدیل را در هم بیامیزد. اگر callback گزارش ثبت کند، شمارندهٔ مشترک تغییر دهد یا در زمان متفاوت خطا بیندازد، خروجی نهاییِ برابر برای اثبات رفتار یکسان کافی نیست.
function traceChained(values) {
const trace = [];
const output = values.filter(value => {
trace.push(`f${value}`); return value % 2 === 1;
}).map(value => { trace.push(`m${value}`); return value * 10; });
return { output, trace };
}
function traceFused(values) {
const trace = []; const output = [];
for (const value of values) {
trace.push(`f${value}`);
if (value % 2 === 1) { trace.push(`m${value}`); output.push(value * 10); }
}
return { output, trace };
}
console.log(traceChained([1, 2, 3]));
// output: [10, 30], trace: ['f1', 'f2', 'f3', 'm1', 'm3']
console.log(traceFused([1, 2, 3]));
// output: [10, 30], trace: ['f1', 'm1', 'f2', 'f3', 'm3']تخصیص، نگهداری و جمعآوری حافظه سه هزینهٔ متفاوتاند
ساخت آرایه یا شیء تازه، مصرف حافظه و کارِ مدیریت آن را مطرح میکند. در سطح کد میتوانیم خروجیها و ساختارهای واسط را بشماریم، اما از همین شمارش اندازهٔ دقیق بایتهای یک Array معمولی را به دست نمیآوریم؛ نمایش داخلی، ظرفیت و بهینهسازی موتور مؤثرند. اگر خروجی قراردادی یک فهرست تازه است، هزینهٔ خود آن خروجی را نمیتوان از مقایسه حذف کرد و بعد نسخهٔ بدون خروجی را برنده نامید. حذف ساختار واسط با حذف خروجی موردنیاز فرق دارد. در گزارش، حافظهٔ ورودی، خروجی و کمکی را جدا توضیح دهید.
جمعآوری زباله یا GC مسئول آزادکردن دادههای دستنیافتنی است؛ کنارگذاشتن آخرین مرجع به معنی آزادشدن فوری و قابل زمانبندی توسط برنامهنویس نیست. ساخت خروجیهای زیاد ممکن است روی اجرای بعدی یا نمونهٔ بعدی اثر بگذارد. در این آزمایش GC را با پرچم خاص مجبور به اجرا نمیکنیم و از نوسان یک نمونه، علت قطعیِ «این توقف حتماً GC بود» نتیجه نمیگیریم. ثبت یک عدد heapUsed پیش و پس از تابع نیز اوج تخصیص یا کل بایتهای ساختهشده را نشان نمیدهد. برای تشخیص مشکل واقعی حافظه، مشاهده و ابزار مناسبِ جداگانه لازم است.
TypedArray یک قرارداد عددی است، نه دکمهٔ سرعت
TypedArray برای دادهٔ عددی با نوع و اندازهٔ عنصر مشخص مناسب است؛ مثلاً نمونههای یک سیگنال یا دادهٔ باینریِ فایل. Uint8Array عنصر هشتبیتی بدون علامت و Float64Array عنصر عددی دوبل دارد. این ساختار را برای ردیفهای دارای نام و دسته جای Array شیءها نمیگذاریم، مگر اینکه مدل داده را آگاهانه تغییر دهیم. byteLength اندازهٔ نمای بایتی را روشن میکند، ولی زمان سریعتر را تضمین نمیکند. تبدیل دادهٔ معمولی به نمای عددی، هزینه و قرارداد جدا دارد؛ اگر تبدیل فقط برای یک جمع کوتاه انجام میشود، باید آن هزینه هم در تصمیم واقعی دیده شود.
const small = Uint8Array.of(300, -1, 1.7);
console.log([...small]); // [44, 255, 1]
const preciseInteger = 16_777_217;
console.log(Float32Array.of(preciseInteger)[0]); // 16777216
console.log(Float64Array.of(preciseInteger)[0]); // 16777217
const source = Float64Array.of(10, 20, 30, 40);
const view = source.subarray(1, 3);
const copied = view.slice();
view[0] = 99;
console.log([...source]); // [10, 99, 30, 40]
console.log([...copied]); // [20, 30]
console.log(view.byteLength, view.buffer.byteLength); // 16 32بلوک را مستقل در typed-cost.mjs اجرا کنید. تبدیل ۳۰۰ به Uint8Array خطای اعتبارسنجی تجاری نمیدهد؛ مقدار طبق نوع عددی تبدیل میشود. کمکردن اندازهٔ عنصر با Float32 نیز میتواند دقت عدد را تغییر دهد. برای مبلغ صحیح یا شناسه، نتیجهٔ سریع اما گردشده قابل قبول نیست. همچنین Float64Array همان محدودیت عمومی دقت Number برای عددهای بزرگ را رفع نمیکند. پیش از زمانگیری، دامنهٔ مقدار، نیاز دقت و نوع جمع را بنویسید و خروجیها را بررسی کنید. نوع کوچکتر تنها وقتی انتخاب درستی است که داده واقعاً در قرارداد آن جا شود.
subarray در مثال یک نمای اشتراکی است و slice کپی مستقل میسازد؛ مقایسهٔ زمان این دو بهعنوان «دو راه کپی» از ابتدا نادرست است. نگهداشتن یک نمای کوچک میتواند مخزن بزرگِ پشت آن را زنده نگه دارد. برعکس، کپی برای جداکردن طول عمر یا جلوگیری از تغییر مشترک هزینهای دارد که ممکن است لازم باشد. اگر ورودی benchmark از قبل Float64Array است، زمان تبدیل Array به آن در جمع ساده شمرده نمیشود؛ این آزمایش فقط پرسش «پس از آمادهشدن داده، پیمایش چه زمان دارد؟» را جواب میدهد. برای مسیر کامل محصول، آزمایش انتهابهانتها جدا بسازید.
قبل از کرنومتر، طرح آزمایش را بنویسید
برای هر مقایسه، داده، اندازه، پراکندگی مقدارها، سهم انتخابشده و کارِ داخل محدودهٔ زمانگیری را ثابت و آشکار کنید. ساخت ورودی نباید برای یک روش داخل زمان و برای دیگری بیرون باشد. اگر تابع ورودی را مرتب یا کوتاه میکند، اجرای بعدی نباید ناخواسته روی ورودی ازپیشتغییریافته انجام شود؛ یا ورودی مستقل آماده کنید یا هزینهٔ کپی را برای همهٔ طرفهای همان پرسش یکسان حساب کنید. آزمون همارزی پیش از زمانگیری لازم است، ولی چند آزمون فقط دامنهٔ تعریفشده را پوشش میدهند و اثبات عمومی برای همهٔ شیءهای ممکن زبان نیستند.
performance.now در Node.js زمان گذشته را با واحد میلیثانیه میدهد؛ این مقدار زمان CPU خالصِ تابع نیست. زمانبندی سیستمعامل، بار محیط، موتور و کارهای همان فرایند بر مشاهده اثر میگذارند. console.log داخل حلقهٔ زمانگیری، نوشتن فایل، یا تولید دادهٔ تصادفیِ متفاوت میتواند پرسش آزمایش را عوض کند. نتیجه را پس از اندازهگیری گزارش میکنیم و یک checksum از خروجی مصرف میکنیم تا کار اندازهگیریشده به نتیجهای قابل بررسی وصل باشد. checksum ساده بهتنهایی برابری کامل دو خروجی را ثابت نمیکند؛ برای همین پیش از benchmark آزمون دقیق هم داریم.
اجرای نخست ممکن است هزینهٔ آمادهشدن کد و حالت متفاوت موتور را داشته باشد. چند دور گرمکردن در آزمایشِ حالتِ گرم میگذاریم و آنها را جزو نمونههای گزارششده حساب نمیکنیم؛ این کار تضمین نمیکند همهٔ بهینهسازیهای ممکن تمام شدهاند. اگر مسئلهٔ محصول آغاز سرد یک پردازش کوتاه است، حذف این هزینه پاسخ پرسش شما نیست. ترتیب اجرای روشها نیز باید جابهجا یا چرخشی شود تا یک روش همیشه از جای ثابتی در جریان آزمایش سود نبرد. بااینحال تغییر ترتیب، همهٔ اثرهای گرما، GC و بار مشترک محیط را حذف نمیکند.
بهجای انتخاب کوچکترین زمان دلخواه، چند مشاهده را نگه میداریم و میانه همراه پراکندگی را میخوانیم. میانه مقدار وسط نمونههای مرتبشده است و تحت تأثیر یک دور بسیار کند کمتر از میانگین جابهجا میشود؛ اما کیفیت دادهٔ بد را معجزهوار اصلاح نمیکند. دامنهٔ کمینه تا بیشینه و شاخص پراکندگی نشان میدهند عدد مرکزی چقدر تنهاست. از چند درصد اختلاف روی نمونههای پرنوسان، حکم قطعی نسازید. اگر زمان هر کار بسیار کوتاه است، تکرار کنترلشده در یک دور میتواند سربار ساعت را کمتر برجسته کند؛ تعداد تکرارها و واحد نتیجه باید در گزارش روشن بماند.
کد کامل benchmark همراه پروژه
فایل bench.mjs بعدی بخشی از بستهٔ array-lab است و به arrays.mjs همان بسته وابسته است؛ فایلها را کنار هم نگه دارید. کدِ آزمایشِ همراه را تغییر ندادهایم تا نتیجهٔ چاپشده با نسخهٔ قابل دریافت قابل مقایسه باشد. ابتدا آزمونهای صحت را اجرا کنید؛ شکست آزمون را با کمکردن تعداد داده پنهان نکنید. این آزمایش به وابستگی شخص ثالث یا پرچم اجباری GC نیاز ندارد. اگر خروجی عددی با محیط نویسنده فرق کرد، نخست نسخه، قرارداد اندازهگیری و داده را مقایسه کنید، نه اینکه بلافاصله یکی از محیطها را خراب بدانید.
import assert from 'node:assert/strict';
import { performance } from 'node:perf_hooks';
import { cpus, platform, release, arch, availableParallelism } from 'node:os';
import { createHash } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { assertReportRows, makeRows, reportWithMethods, reportWithLoop } from './arrays.mjs';
const started = performance.now();
const sampleCount = 9;
const warmupRounds = 3;
const variants = { methods: reportWithMethods, loop: reportWithLoop };
const fingerprint = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
const median = sorted => sorted[Math.floor(sorted.length / 2)];
function statistics(values) {
const sorted = values.toSorted((a, b) => a - b);
const middle = median(sorted);
const p25 = sorted[Math.floor((sorted.length - 1) * 0.25)];
const p75 = sorted[Math.floor((sorted.length - 1) * 0.75)];
return { median: middle, min: sorted[0], max: sorted.at(-1), p25, p75,
iqr: p75 - p25,
mad: median(values.map(value => Math.abs(value - middle)).toSorted((a, b) => a - b)) };
}
function runBatch(fn, rows, minimum, iterations, expected) {
const outputs = new Array(iterations); // the same harness work for both variants
const start = performance.now();
for (let i = 0; i < iterations; i += 1) outputs[i] = fn(rows, 'education', minimum);
const batchMs = performance.now() - start;
// Consume and check every result AFTER the measured interval.
let checksum = 0;
for (const output of outputs) {
assert.deepEqual(output, expected);
checksum += output.count + output.total + (output.max ?? 0);
}
assert.ok(Number.isSafeInteger(checksum));
return { batchMs, checksum };
}
const cases = [];
for (const size of [1000, 100000]) {
for (const minimum of [0, 50000, 90000]) {
if (performance.now() - started > 30000) throw new Error('Stopped: benchmark exceeded 30 seconds');
const input = { methods: makeRows(size), loop: makeRows(size) };
assertReportRows(input.methods);
assertReportRows(input.loop);
assert.deepEqual(input.methods, input.loop);
assert.notEqual(input.methods[0], input.loop[0]);
const before = Object.fromEntries(Object.entries(input).map(([key, rows]) => [key, fingerprint(rows)]));
const expected = reportWithMethods(input.methods, 'education', minimum);
assert.deepEqual(expected, reportWithLoop(input.loop, 'education', minimum));
const iterations = Math.ceil(600000 / size);
const order = [];
const samples = { methods: [], loop: [] };
const checksums = { methods: [], loop: [] };
for (let round = -warmupRounds; round < sampleCount; round += 1) {
const names = (round + warmupRounds + cases.length) % 2 === 0 ?
['methods', 'loop'] : ['loop', 'methods'];
order.push({ phase: round < 0 ? 'warmup' : 'sample', round: round < 0 ? round + warmupRounds : round, names });
for (const name of names) {
const result = runBatch(variants[name], input[name], minimum, iterations, expected);
if (round >= 0) {
samples[name].push(result.batchMs);
checksums[name].push(result.checksum);
}
}
}
const after = Object.fromEntries(Object.entries(input).map(([key, rows]) => [key, fingerprint(rows)]));
assert.deepEqual(before, after);
assert.deepEqual(checksums.methods, checksums.loop);
cases.push({ name: `report-n${size}-min${minimum}`, size, category: 'education', minimum,
selectedCount: expected.count, selectivity: expected.count / size,
expected, iterationsPerSample: iterations, order, inputSha256: before,
inputUnchanged: true,
variants: Object.fromEntries(Object.keys(variants).map(name => [name, {
batchMs: samples[name], checksums: checksums[name],
summaryBatchMs: statistics(samples[name]),
summaryMsPerReport: statistics(samples[name].map(value => value / iterations))
}])) });
}
}
const cpu = cpus()[0];
const sourceSha256 = {};
for (const file of ['arrays.mjs', 'arrays.test.mjs', 'bench.mjs']) {
sourceSha256[file] = createHash('sha256').update(await readFile(new URL(file, import.meta.url))).digest('hex');
}
const results = {
schemaVersion: 1, generatedAt: new Date().toISOString(),
environment: { node: process.version, v8: process.versions.v8, platform: platform(),
release: release(), arch: arch(), cpuModel: cpu?.model ?? 'unknown',
logicalCpuCount: cpus().length, availableParallelism: availableParallelism(),
execArgv: process.execArgv, forcedGC: false },
methodology: { sampleCount, warmupRounds, clock: 'performance.now()',
unit: 'milliseconds', workRowsPerBatch: 600000, order: 'alternating; case starting variant rotates',
inputs: 'independent equal prevalidated datasets; creation and validation excluded',
included: 'report work, output creation, harness array assignment, ordinary runtime GC/JIT effects',
excluded: 'dataset creation, validation, fingerprinting, expected-result checks and checksum consumption',
limits: 'single process/environment; no browser, other CPU, memory-peak, tail-latency or universal speed conclusion' },
sourceSha256, cases, totalWallMs: performance.now() - started
};
await writeFile(new URL('./results.json', import.meta.url), JSON.stringify(results, null, 2) + '\n');
console.log(JSON.stringify({ saved: 'results.json', node: results.environment.node,
cases: cases.length, samplesPerVariant: sampleCount, totalWallMs: results.totalWallMs }, null, 2));
این مثال به محیط مرورگر، پروژه یا Node.js نیاز دارد؛ طبق راهنمای درس اجرا کنید.اجرای قابل تکرار و خواندن نتایج واقعی
در پوشهٔ استخراجشدهٔ array-lab که package.json و arrays.mjs در آن دیده میشوند، ابتدا node --version را بررسی کنید؛ نسخه باید ۲۴ یا بالاتر باشد. این بسته وابستگی خارجی ندارد و برای این اجرای آموزشی npm install لازم نیست. دستورهای Bash زیر check، آزمونها و سپس benchmark را اجرا میکنند. اجرای benchmark، results.json همین پوشه را بازنویسی میکند؛ برای مقایسه با نمونهٔ همراه، ابتدا از نتیجهٔ قبلی یک کپی با نام دیگر بگیرید. پیام saved فقط ذخیرهٔ نتیجهٔ آزمایش را خبر میدهد، نه درستبودن همهٔ تعمیمهای ممکن از آن.
node --version
npm run check
npm test
npm run benchدر Windows PowerShell، بهترتیب npm.cmd run check، npm.cmd test و npm.cmd run bench را به کار ببرید. در صورت خطا، نخستین پیام را بخوانید: پیدانشدن package.json معمولاً یعنی پوشهٔ کاری اشتباه است؛ خطای نحوی میتواند نشانهٔ نسخهٔ نامناسب Node یا ذخیرهٔ ناقص فایل باشد. هنگام آزمایش، کار سنگین غیرضروری را کنار بگذارید و اجرای همزمان چند نسخهٔ benchmark را شروع نکنید. کنترلِ ۳۰ ثانیه در مرز سناریوها انجام میشود؛ قطعکنندهٔ فوریِ محاسبهٔ همگام نیست. اگر اسکریپت متوقف شد، نتیجهٔ نیمهکاره را گزارش کامل شش سناریو معرفی نکنید. خاموشکردن اعتبارسنجی برای عبور از خطا، نتیجه را معتبر نمیکند.
در این benchmark، methods یعنی reportWithMethods با filter، map و reduce؛ loop یعنی reportWithLoop با یک حلقهٔ اندیسی. هر دو فقط شیئی شامل count، total و max برمیگردانند، نه فهرست ردیفها. نسخهٔ نخست دو ساختار واسط انتخاب ردیف و مبلغها را دارد؛ نسخهٔ دوم مستقیم گزارش را جمع میکند. اعتبارسنجی مرزی و ساخت دو ورودی مستقلِ برابر، بیرون محدودهٔ زمان انجام میشوند. خروجی هر تکرار در آرایهٔ ابزار آزمایش نگه داشته و پس از پایان زمان دقیقاً بررسی و در checksum مصرف میشود. ساخت خروجی و ثبت آن در این آرایه، جزو زمان هر دو نسخه است.
دو اندازهٔ ۱۰۰۰ و ۱۰۰۰۰۰ با سه حد مبلغ صفر، ۵۰۰۰۰ و ۹۰۰۰۰ ترکیب شدهاند؛ دسته همیشه education است. سازندهٔ makeRows دادهٔ قطعی میسازد، پس این یک نمونهٔ تصادفی از کاربران واقعی نیست. هر مورد سه دور گرمکردن و نه نمونهٔ اندازهگیری برای هر روش دارد و ترتیب دو روش جابهجا میشود. تعداد تکرار در هر نمونه ceil(600000 / n) است: برای هزار ردیف، ۶۰۰ گزارش و برای صد هزار ردیف، شش گزارش. بنابراین زمان batch را با زمان یک گزارش اشتباه نگیرید؛ summaryMsPerReport زمان batch تقسیم بر تعداد تکرارهاست، نه زمانسنجی مستقل تکتک درخواستها.
اندازهگیری واقعیِ همراه در 2026-10-29T11:06:39.225Z با Node.js v24.22.2 و V8 13.6.233.17-node.63 ثبت شده است. محیط گزارششده linux، کرنل 6.18.55 و معماری x64 است؛ نام CPU، AMD EPYC 9V74 80-Core Processor، با 9 پردازندهٔ منطقی قابل مشاهده و availableParallelism برابر 8 گزارش شده است. این اطلاعات تضمین اختصاصیبودن سختافزار محیط مشترک نیست؛ محاسبهٔ این برنامه همگام است و تعداد CPUها به معنی اجرای موازی گزارشها نیست. پرچم اجرایی اضافه و GC اجباری وجود نداشته؛ کل زمان ثبتشدهٔ اجرای آزمایش حدود 1.278 ثانیه است.
خلاصهٔ زیر مستقیماً از results.json همان اجرا ساخته و برای خواندن تا نه رقم اعشار گرد شده است. selected شمار ردیفهای انتخابشده است؛ برای n برابر هزار، سهمها ۲۰، ۱۰٫۱ و ۲٫۲ درصد و برای صد هزار، ۲۰، ۱۰ و دو درصد بودهاند. medianMs میانه و iqrMs فاصلهٔ چارک سوم و اول با واحد میلیثانیه برای هر گزارش است. قرارداد انتخاب چارکها در تابع statistics دیده میشود؛ برای نه نمونه، اندیسهای ۲ و ۶ با شمارش از صفر، یعنی مقدار سوم و هفتم را میگیرد. نمونههای خام، کمینه، بیشینه و MAD نیز در فایل کامل ماندهاند؛ MAD میانهٔ فاصلهٔ مطلق هر نمونه از میانه است.
{
"unit": "milliseconds per report, rounded to 9 decimal places",
"recordedAt": "2026-10-29T11:06:39.225Z",
"cases": [
{
"n": 1000,
"minimum": 0,
"selected": 200,
"iterationsPerBatch": 600,
"methods": {
"medianMs": 0.013429593,
"iqrMs": 0.00164346
},
"loop": {
"medianMs": 0.002555775,
"iqrMs": 0.000589493
}
},
{
"n": 1000,
"minimum": 50000,
"selected": 101,
"iterationsPerBatch": 600,
"methods": {
"medianMs": 0.010304557,
"iqrMs": 0.001038493
},
"loop": {
"medianMs": 0.001836602,
"iqrMs": 0.000296433
}
},
{
"n": 1000,
"minimum": 90000,
"selected": 22,
"iterationsPerBatch": 600,
"methods": {
"medianMs": 0.009057498,
"iqrMs": 0.0025393
},
"loop": {
"medianMs": 0.001759433,
"iqrMs": 0.000152883
}
},
{
"n": 100000,
"minimum": 0,
"selected": 20000,
"iterationsPerBatch": 6,
"methods": {
"medianMs": 1.3449125,
"iqrMs": 0.139139333
},
"loop": {
"medianMs": 0.2327345,
"iqrMs": 0.051039
}
},
{
"n": 100000,
"minimum": 50000,
"selected": 10000,
"iterationsPerBatch": 6,
"methods": {
"medianMs": 1.042003,
"iqrMs": 0.108394333
},
"loop": {
"medianMs": 0.2281225,
"iqrMs": 0.0301055
}
},
{
"n": 100000,
"minimum": 90000,
"selected": 2000,
"iterationsPerBatch": 6,
"methods": {
"medianMs": 0.911265,
"iqrMs": 0.047739
},
"loop": {
"medianMs": 0.212145,
"iqrMs": 0.029954
}
}
]
}در این اجرای مشخص، میانهٔ loop در هر شش مورد کمتر از methods بوده است. این مشاهده با حذف آرایههای واسط و انجام مستقیم گزارش سازگار است، ولی آزمایش علتِ سهمِ هر سازوکار موتور را جدا نکرده است. از آن برتری همیشگی حلقه، بدبودن map یا زمان همین تابع در مرورگر نتیجه نمیگیریم. برای نمونهٔ صد هزار ردیف با حد صفر، میانهها تقریباً ۱٫۳۴۴۹ و ۰٫۲۳۲۷ میلیثانیهاند و IQRها تقریباً ۰٫۱۳۹۱ و ۰٫۰۵۱۰ میلیثانیه. زمان هر گزارش هزارردیفی بسیار کوچکتر است؛ یک اختلاف نسبی چشمگیر ممکن است در مسیر کمتکرار محصول سود عملی اندکی داشته باشد.
مقایسهٔ دو اندازه را هم دقیق بخوانید: تعداد ردیفهای هر batch عمداً تقریباً ثابت نگه داشته شده، پس یکسانبودن تقریبی زمان batch به معنی هزینهٔ ثابت تابع با رشد n نیست. تنها دو اندازه و همین توزیع داده داریم؛ آنها اثبات رسمی نرخ رشد یا پیشبینی دادههای متفاوت نیستند. این اجرا اوج حافظه، تأخیر صدکی درخواستهای یک سرور، مرورگر، آغاز سرد یا TypedArray را اندازه نگرفته است. اگر موضوع تصمیم شما یکی از اینهاست، آزمایش متناسب با همان پرسش لازم است. جمعبندی قابل دفاع، محدود به همین دو تابع، داده و محیط ثبتشده است.
تمرین پایانی؛ تصمیم را همراه شواهد تحویل دهید
تمرین اول: در یک نسخهٔ جدا از دادهٔ آزمایش، شرط انتخاب را طوری عوض کنید که تقریباً همهٔ ردیفها و سپس سهم کمی از آنها باقی بمانند. پیش از اجرا پیشبینی کنید چه چیزی تغییر میکند: تعداد آزمونهای انتخاب، تعداد تبدیلها، اندازهٔ خروجی و آرایهٔ واسط. پاسخ راهنما این است که شمار بررسی ردیفهای ورودی همچنان تابع n است، ولی k و هزینهٔ وابسته به خروجی عوض میشوند. سپس آزمون همارزی، warmup و همهٔ نمونهها را دوباره اجرا کنید. تغییر یک زمان بدون ثبت سهم انتخابشده، نتیجهٔ قابل بازبینی نیست.
تمرین دوم: یک ادغامِ ظاهراً سریع با اندیس اصلی نوشتهاید و خروجی با map بعد از filter فرق کرده است. پاسخ، اصلاح قرارداد اندیس یا محدودکردن تابع تبدیل به مقدار ردیف است؛ زمان کمتر این خطا را جبران نمیکند. تمرین سوم: نسخهٔ Float32Array جمع سریعتری نشان داده ولی یک مقدار صحیح بزرگ را گرد کرده است. پاسخ درست این است که دو خروجی همارز نیستند؛ آن نتیجه را از مقایسهٔ پذیرفتنی حذف کنید و نوع سازگار را دوباره بسنجید. در هر دو تمرین، آزمون شکست باید باقی بماند تا بهینهسازی بعدی همان خطا را بازنگرداند.
برای تحویل، یک یادداشت کوتاه بنویسید: نیاز واقعی، داده و سقف آن، دو پیادهسازی همارز، آزمونهای صحت، فرمان اجرا، محیط، نمونههای خام، میانه و پراکندگی، و محدودیت نتیجه. سپس تصمیم را به هزینهٔ نگهداری کد وصل کنید. اگر اختلاف عملی ناچیز است و زنجیرهٔ روشنتر نیاز کاربر را برآورده میکند، پیچیدهکردن آن توجیهی ندارد. اگر مسیر اندازهگیریشده واقعاً پرتکرار است و ادغام با همان قرارداد کمک میکند، تغییر را همراه آزمون و توضیح فرضها نگه دارید. مهارت اصلی، انتخاب یک نام متد محبوب نیست؛ ساختن تصمیمی است که نفر بعد بتواند آن را دوباره بررسی کند.
منابع رسمی انگلیسی و دادهٔ آزمایش
Array: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array
Array.length: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/length
Object.keys: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/keys
Object.hasOwn: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/hasOwn
Array.map: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map
Array.find: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find
Array.includes: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/includes
Array.indexOf: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf
Array.flat: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flat
Array.toSpliced: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/toSpliced
Array.sort: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort
Array.toSorted: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/toSorted
Intl.Collator: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator
ECMAScript indexed collections: https://tc39.es/ecma262/multipage/indexed-collections.html
ECMAScript keyed collections: https://tc39.es/ecma262/multipage/keyed-collections.html
MDN Array filter: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter
ECMAScript indexed collections: https://tc39.es/ecma262/multipage/indexed-collections.html#sec-array.prototype.map
MDN TypedArray: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray
MDN TypedArray subarray: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/subarray
MDN TypedArray slice: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/slice
MDN Memory management: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Memory_management
Node24 Performance measurement: https://nodejs.org/docs/latest-v24.x/api/perf_hooks.html
Node24 Process memoryUsage: https://nodejs.org/docs/latest-v24.x/api/process.html#processmemoryusage
Node OS: https://nodejs.org/api/os.html
Node Dont Block Event Loop: https://nodejs.org/learn/asynchronous-work/dont-block-the-event-loop
V8 Retiring Octane: https://v8.dev/blog/retiring-octane
تألیف و مثالهای اختصاصی: تحریریه درسآوند. نتیجهٔ خام شش بنچمارک، مشخصات محیط، کد کامل و راهنمای اجرا در فایل ZIP همراه همین آموزش قرار دارند. نتایج اندازهگیری به داده و محیط آزمودهشده محدودند.
پیشنیاز و محیط اجرای مثالها
کار عملی با تابع، شیء و آرایه؛ مطالعهٔ پیشنیاز هزینهٔ اجرا: https://www.darsavand.com/resources/javascript-runtime-cost-complexity . مثالهای پروژه با Node.js 24 یا نسخهٔ پشتیبانیشدهٔ جدیدتر اجرا میشوند.
پیشرفت روی همین مرورگر ذخیره میشود.