الثبات والدوال النقية
ما هو الثبات؟
الثبات (Immutability) هو أحد أهم المفاهيم في برمجة جافاسكريبت الحديثة. القيمة الثابتة هي قيمة لا يمكن تغييرها أبدا بعد إنشائها. بدلا من تعديل البيانات الموجودة، تنشئ هياكل بيانات جديدة تعكس التغييرات المطلوبة مع ترك الأصل دون تغيير. قد يبدو هذا غير فعال في البداية -- لماذا ننشئ كائنا جديدا بالكامل عندما نحتاج فقط لتغيير خاصية واحدة؟ لكن الثبات يوفر فوائد عميقة لموثوقية الكود وقابلية التنبؤ وتصحيح الأخطاء وتحسين الأداء. إنه مبدأ أساسي في البرمجة الوظيفية وأصبح ذا أهمية متزايدة في أطر العمل مثل React وRedux ومكتبات إدارة الحالة الحديثة.
في جافاسكريبت، القيم الأولية (السلاسل النصية، الأرقام، القيم المنطقية، null، undefined، Symbol، وBigInt) ثابتة بطبيعتها بالفعل. عندما تكتب let x = 5; x = 10;، أنت لا تغير القيمة 5 -- بل توجه المتغير x إلى قيمة جديدة تماما 10. القيمة 5 نفسها تظل دون تغيير في الذاكرة. السلاسل النصية تعمل بنفس الطريقة: استدعاء str.toUpperCase() لا يعدل السلسلة الأصلية بل يرجع سلسلة جديدة تماما. التحدي مع الثبات ينشأ مع أنواع المراجع -- الكائنات والمصفوفات -- التي هي قابلة للتغيير بشكل افتراضي في جافاسكريبت. فهم هذا التمييز أمر حاسم لكتابة كود موثوق.
البيانات القابلة للتغيير مقابل الثابتة
لتقدير الثبات حقا، تحتاج لفهم كيف يعمل التغيير في جافاسكريبت ولماذا يسبب مشاكل. عندما تنشئ كائنا أو مصفوفة وتعينها لمتغير، يحتفظ ذلك المتغير بمرجع (عنوان ذاكرة) للبيانات الفعلية. عندما تعين ذلك المتغير لمتغير آخر، يشير كلا المتغيرين إلى نفس البيانات بالضبط في الذاكرة. هذا يعني أن تعديل البيانات من خلال متغير واحد يؤثر على جميع المتغيرات التي تشير إليها.
مثال: البيانات القابلة للتغيير -- مشكلة المرجع المشترك
// الكائنات قابلة للتغيير بشكل افتراضي
const user = { name: 'Alice', age: 30, role: 'developer' };
const admin = user; // كلا المتغيرين يشيران إلى نفس الكائن
admin.role = 'admin';
console.log(user.role); // "admin" -- تم تغيير user أيضا!
console.log(user === admin); // true -- هما نفس الكائن
// المصفوفات أيضا قابلة للتغيير بشكل افتراضي
const original = [1, 2, 3, 4, 5];
const copy = original; // كلاهما يشير إلى نفس المصفوفة
copy.push(6);
console.log(original); // [1, 2, 3, 4, 5, 6] -- تم تعديل الأصلية!
console.log(original === copy); // true -- نفس المصفوفة
// الكائنات المتداخلة تضاعف المشكلة
const config = {
database: { host: 'localhost', port: 5432 },
cache: { enabled: true, ttl: 3600 },
};
const testConfig = config;
testConfig.database.host = 'test-server';
console.log(config.database.host); // "test-server" -- تم تعديل إعدادات الإنتاج!
const لا تجعل القيم ثابتة. إنها تمنع فقط إعادة تعيين المتغير لقيمة مختلفة. كائن أو مصفوفة const لا يزال بإمكانهما تعديل خصائصهما أو عناصرهما بحرية. كتابة const user = { name: 'Alice' }; user.name = 'Bob'; تعمل بشكل مثالي. الكلمة المفتاحية const تضمن أن user سيشير دائما إلى نفس الكائن، لكنها لا تقول شيئا عما إذا كان محتوى ذلك الكائن يمكن أن يتغير. هذا أحد أكثر المفاهيم الخاطئة شيوعا في جافاسكريبت.مشاكل التغيير
التغيير ينشئ عدة فئات من الأخطاء التي يصعب تتبعها بشكل سيء السمعة. المشكلة الأساسية هي أنه عندما يمكن أن تتغير البيانات من أي مكان في برنامجك، تفقد القدرة على فهم كودك محليا. يجب أن تفهم كل مكان في قاعدة الكود بأكملها قد يصل إلى ويعدل جزءا معينا من البيانات.
مثال: أخطاء التغيير في العالم الحقيقي
// خطأ 1: الدالة تعدل مدخلاتها بشكل غير متوقع
function addDiscount(products, discountPercent) {
for (let i = 0; i < products.length; i++) {
products[i].price *= (1 - discountPercent / 100);
}
return products;
}
const catalog = [
{ name: 'حاسوب محمول', price: 1000 },
{ name: 'هاتف', price: 500 },
];
const discounted = addDiscount(catalog, 10);
console.log(catalog[0].price); // 900 -- تم تغيير الكتالوج الأصلي!
// كل استدعاء لـ addDiscount يستمر في تخفيض الأسعار أكثر
// خطأ 2: حالة مشتركة بين المكونات
const appState = {
user: { name: 'Alice', preferences: { theme: 'dark' } },
notifications: [],
};
function UserProfile(state) {
const userData = state.user;
userData.lastViewed = new Date(); // يغير الحالة المشتركة!
return userData;
}
function NotificationPanel(state) {
// هذا المكون يرى الآن lastViewed على المستخدم -- غير متوقع!
console.log(state.user.lastViewed); // كائن Date -- من أين جاء هذا؟
}
// خطأ 3: حالات السباق مع العمليات غير المتزامنة
const sharedData = { count: 0 };
async function incrementAsync() {
const current = sharedData.count;
await new Promise(resolve => setTimeout(resolve, 100));
sharedData.count = current + 1; // قد يكتب فوق زيادة أخرى!
}
// كلاهما يقرأ العد كـ 0، كلاهما يكتب 1 -- المتوقع 2 لكن حصلنا على 1
Promise.all([incrementAsync(), incrementAsync()])
.then(() => console.log(sharedData.count)); // 1 وليس 2!
أنماط ثابتة للمصفوفات
توفر جافاسكريبت عدة طرق مدمجة وعوامل تنشئ مصفوفات جديدة بدلا من تعديل الموجودة. هذه هي أساس عمليات المصفوفات الثابتة. المفتاح هو استخدام الطرق التي ترجع مصفوفات جديدة دائما (مثل map وfilter وconcat وslice) وتجنب الطرق التي تعدل في المكان (مثل push وpop وsplice وsort وreverse).
مثال: عمليات المصفوفات الثابتة باستخدام عامل الانتشار
const numbers = [1, 2, 3, 4, 5];
// إضافة عناصر -- استخدم الانتشار بدلا من push/unshift
const withSix = [...numbers, 6]; // [1, 2, 3, 4, 5, 6]
const withZero = [0, ...numbers]; // [0, 1, 2, 3, 4, 5]
const withInserted = [...numbers.slice(0, 2), 99, ...numbers.slice(2)];
// [1, 2, 99, 3, 4, 5]
console.log(numbers); // [1, 2, 3, 4, 5] -- لم تتغير!
// إزالة عناصر -- استخدم filter بدلا من splice
const withoutThree = numbers.filter(n => n !== 3); // [1, 2, 4, 5]
const withoutFirst = numbers.slice(1); // [2, 3, 4, 5]
const withoutLast = numbers.slice(0, -1); // [1, 2, 3, 4]
const withoutIndex2 = [...numbers.slice(0, 2), ...numbers.slice(3)];
// [1, 2, 4, 5]
console.log(numbers); // [1, 2, 3, 4, 5] -- لا تزال دون تغيير!
// دمج المصفوفات -- استخدم الانتشار أو concat بدلا من push
const moreNumbers = [6, 7, 8];
const combined = [...numbers, ...moreNumbers]; // [1, 2, 3, 4, 5, 6, 7, 8]
const alsoCombined = numbers.concat(moreNumbers); // نفس النتيجة
// استبدال عناصر في فهرس محدد
const index = 2;
const replaced = [...numbers.slice(0, index), 99, ...numbers.slice(index + 1)];
// [1, 2, 99, 4, 5]
console.log(numbers); // [1, 2, 3, 4, 5] -- دائما دون تغيير!
مثال: التحويلات الثابتة باستخدام map وfilter وreduce
const users = [
{ id: 1, name: 'أحمد', active: true },
{ id: 2, name: 'سعيد', active: false },
{ id: 3, name: 'محمد', active: true },
{ id: 4, name: 'فاطمة', active: false },
];
// تحويل كل عنصر -- map ترجع دائما مصفوفة جديدة
const names = users.map(user => user.name);
// ['أحمد', 'سعيد', 'محمد', 'فاطمة']
// تصفية العناصر -- filter ترجع دائما مصفوفة جديدة
const activeUsers = users.filter(user => user.active);
// [{ id: 1, ... }, { id: 3, ... }]
// تحديث عنصر محدد بشكل ثابت
const updatedUsers = users.map(user =>
user.id === 2
? { ...user, active: true } // إنشاء كائن جديد للمستخدم المحدث
: user // الاحتفاظ بمرجع المستخدمين غير المتغيرين
);
console.log(users[1].active); // false -- الأصلي لم يتغير
console.log(updatedUsers[1].active); // true -- المصفوفة الجديدة بها مستخدم محدث
// ربط العمليات لتحويلات معقدة
const result = users
.filter(user => user.active)
.map(user => ({ ...user, name: user.name.toUpperCase() }))
.reduce((acc, user) => ({ ...acc, [user.id]: user }), {});
sort() وreverse() -- فهي تغير المصفوفة الأصلية وترجع مرجعا لها. للترتيب بشكل ثابت، انشر دائما في مصفوفة جديدة أولا: const sorted = [...numbers].sort((a, b) => a - b). في محركات جافاسكريبت الأحدث (ES2023+)، يمكنك أيضا استخدام toSorted() وtoReversed() وtoSpliced() التي ترجع مصفوفات جديدة دون تغيير الأصلية. هذه مصممة خصيصا لعمليات المصفوفات الثابتة.أنماط ثابتة للكائنات
الكائنات في جافاسكريبت قابلة للتغيير بشكل افتراضي، لكن عامل الانتشار وObject.assign يسمحان لك بإنشاء نسخ معدلة دون لمس الأصل. عامل الانتشار (...) ينشئ نسخة سطحية من الكائن، ويمكنك تجاوز خصائص محددة بإدراجها بعد الانتشار. هذا هو النمط الأكثر شيوعا لتحديثات الكائنات الثابتة في جافاسكريبت الحديثة.
مثال: تحديثات الكائنات الثابتة باستخدام الانتشار
const user = {
id: 1,
name: 'أحمد',
email: 'ahmed@example.com',
age: 30,
role: 'developer',
};
// تحديث خاصية واحدة -- انتشار ثم تجاوز
const updatedUser = { ...user, age: 31 };
console.log(user.age); // 30 -- الأصلي لم يتغير
console.log(updatedUser.age); // 31 -- كائن جديد بعمر محدث
// تحديث خصائص متعددة
const promotedUser = { ...user, role: 'مطور أول', age: 31 };
// إضافة خصائص جديدة
const enrichedUser = { ...user, department: 'الهندسة', startDate: '2020-01-15' };
// إزالة خاصية باستخدام التفكيك
const { email, ...userWithoutEmail } = user;
console.log(userWithoutEmail);
// { id: 1, name: 'أحمد', age: 30, role: 'developer' }
// أسماء الخصائص المحسوبة للتحديثات الديناميكية
const field = 'name';
const newValue = 'أحمد محمد';
const dynamicUpdate = { ...user, [field]: newValue };
console.log(dynamicUpdate.name); // "أحمد محمد"
console.log(user); // لم يتغير تماما -- جميع الخصائص أصلية
مثال: Object.assign للتحديثات الثابتة
const defaults = {
theme: 'light',
language: 'ar',
notifications: true,
fontSize: 16,
};
// Object.assign ينشئ كائنا جديدا عندما يكون الهدف {}
const userPrefs = Object.assign({}, defaults, { theme: 'dark', fontSize: 18 });
console.log(defaults.theme); // "light" -- لم يتغير
console.log(userPrefs.theme); // "dark" -- كائن جديد
// دمج كائنات متعددة بشكل ثابت
const base = { a: 1, b: 2 };
const overrides = { b: 3, c: 4 };
const extras = { d: 5 };
const merged = Object.assign({}, base, overrides, extras);
// { a: 1, b: 3, c: 4, d: 5 }
console.log(base); // { a: 1, b: 2 } -- لم يتغير
// تحذير: Object.assign بهدف غير فارغ يغير الهدف!
const mutableMerge = Object.assign(base, overrides); // يغير base!
console.log(base); // { a: 1, b: 3, c: 4 } -- تم تعديل base!
// استخدم دائما كائنا فارغا {} كمعامل أول للثبات
const safeMerge = Object.assign({}, base, overrides); // آمن!
Object.assign يقومان فقط بـ نسخ سطحي. ينسخان خصائص المستوى الأعلى، لكن إذا كانت أي قيمة خاصية كائنا أو مصفوفة، ستحتوي النسخة على مرجع لنفس الكائن المتداخل. هذا يعني أن تعديل خاصية متداخلة على النسخة سيؤثر على الأصل. للثبات العميق، تحتاج تقنيات إضافية مثل النسخ العميق أو أنماط التحديث الثابت المتخصصة، التي سنغطيها في الأقسام التالية.Object.freeze -- الثبات السطحي
Object.freeze() هي طريقة مدمجة في جافاسكريبت تمنع تعديل خصائص الكائن الموجودة. بمجرد التجميد، لا يمكنك إضافة خصائص جديدة أو إزالة خصائص موجودة أو تغيير قيم الخصائص الموجودة. محاولات تعديل كائن مجمد تفشل بصمت في الوضع العادي وتطرح TypeError في الوضع الصارم. ومع ذلك، Object.freeze سطحي -- يجمد فقط خصائص المستوى الأعلى. الكائنات المتداخلة داخل كائن مجمد تظل قابلة للتغيير.
مثال: سلوك Object.freeze
'use strict';
const config = Object.freeze({
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3,
});
// كل هذه ستطرح TypeError في الوضع الصارم
// config.apiUrl = 'https://other.com'; // TypeError
// config.newProp = 'value'; // TypeError
// delete config.timeout; // TypeError
console.log(Object.isFrozen(config)); // true
// لكن التجميد سطحي -- الكائنات المتداخلة لا تزال قابلة للتغيير!
const appConfig = Object.freeze({
server: { host: 'localhost', port: 3000 },
database: { host: 'localhost', port: 5432 },
});
appConfig.server.port = 8080; // هذا يعمل -- الكائن المتداخل غير مجمد!
console.log(appConfig.server.port); // 8080 -- تم تعديله!
// مرجع المستوى الأعلى مجمد، لكن الكائن الذي يشير إليه ليس كذلك
// appConfig.server = {}; // TypeError -- لا يمكن إعادة تعيين خاصية المستوى الأعلى
appConfig.server.host = 'production'; // يعمل! الكائن المتداخل قابل للتغيير
// تجميد المصفوفات
const frozenArray = Object.freeze([1, 2, 3, 4, 5]);
// frozenArray.push(6); // TypeError
// frozenArray[0] = 99; // TypeError
// frozenArray.length = 0; // TypeError
console.log(frozenArray); // [1, 2, 3, 4, 5] -- مجمدة حقا على المستوى الأعلى
التجميد العميق
بما أن Object.freeze سطحي، تحتاج نهجا تكراريا لتجميد شجرة كائنات كاملة. دالة التجميد العميق تمر عبر جميع خصائص الكائن وتجمد كل كائن متداخل تجده. هذا يضمن أنه لا يمكن تعديل أي مستوى من هيكل البيانات.
مثال: تنفيذ التجميد العميق
function deepFreeze(obj) {
// الحصول على جميع أسماء الخصائص (بما فيها غير القابلة للتعداد)
const propNames = Object.getOwnPropertyNames(obj);
// تجميد كل كائن متداخل قبل تجميد الأصل
for (const name of propNames) {
const value = obj[name];
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
deepFreeze(value);
}
}
return Object.freeze(obj);
}
// الاستخدام
const config = deepFreeze({
server: {
host: 'localhost',
port: 3000,
ssl: { enabled: true, cert: '/path/to/cert' },
},
database: {
host: 'localhost',
port: 5432,
credentials: { user: 'admin', password: 'secret' },
},
features: ['auth', 'logging', 'caching'],
});
// كل هذه تفشل الآن في الوضع الصارم
// config.server.port = 8080; // TypeError
// config.server.ssl.enabled = false; // TypeError
// config.database.credentials.user = 'root'; // TypeError
// config.features.push('newFeature'); // TypeError
console.log(Object.isFrozen(config)); // true
console.log(Object.isFrozen(config.server)); // true
console.log(Object.isFrozen(config.server.ssl)); // true
console.log(Object.isFrozen(config.database.credentials)); // true
console.log(Object.isFrozen(config.features)); // true
Date وMap وSet أو RegExp لأن تجميدها يكسر طرقها الداخلية. أيضا، تجميد الكائنات الكبيرة بشكل تكراري له تكلفة أداء. استخدم التجميد العميق بشكل استراتيجي للبيانات الثابتة الحرجة، وليس كنهج شامل لجميع الكائنات في تطبيقك.structuredClone -- النسخ العميق
structuredClone() هي طريقة جافاسكريبت حديثة (متوفرة في جميع المتصفحات الرئيسية وNode.js 17+) تنشئ نسخة عميقة حقيقية من القيمة. على عكس عامل الانتشار أو Object.assign، يقوم structuredClone بنسخ جميع الكائنات والمصفوفات المتداخلة بشكل تكراري، منتجا نسخة مستقلة تماما بدون مراجع مشتركة مع الأصل. هذا يجعلها لا تقدر بثمن لإنشاء نسخ ثابتة من هياكل البيانات المعقدة.
مثال: structuredClone للنسخ العميق
const original = {
user: {
name: 'أحمد',
address: {
city: 'القاهرة',
coordinates: { lat: 30.0444, lng: 31.2357 },
},
},
tags: ['مطور', 'مرشد'],
metadata: {
created: new Date('2024-01-15'),
scores: [95, 87, 92],
},
};
// نسخ عميق باستخدام structuredClone
const clone = structuredClone(original);
// تعديل النسخة -- الأصل غير متأثر تماما
clone.user.name = 'سعيد';
clone.user.address.city = 'الرياض';
clone.user.address.coordinates.lat = 24.7136;
clone.tags.push('متحدث');
clone.metadata.scores.push(100);
console.log(original.user.name); // "أحمد" -- لم يتغير
console.log(original.user.address.city); // "القاهرة" -- لم يتغير
console.log(original.user.address.coordinates.lat); // 30.0444 -- لم يتغير
console.log(original.tags); // ['مطور', 'مرشد'] -- لم يتغير
console.log(original.metadata.scores); // [95, 87, 92] -- لم يتغير
// structuredClone يتعامل مع الأنواع الخاصة
const specialClone = structuredClone({
date: new Date(),
map: new Map([['key', 'value']]),
set: new Set([1, 2, 3]),
});
console.log(specialClone.date instanceof Date); // true
console.log(specialClone.map instanceof Map); // true
console.log(specialClone.set instanceof Set); // true
structuredClone نسخ الدوال أو عقد DOM أو الكائنات ذات مفاتيح خصائص Symbol. إذا كانت كائناتك تحتوي على دوال (مثل الطرق أو عمليات الاسترجاع)، ستحتاج نهج نسخ مخصص. أيضا، structuredClone لا يحافظ على سلسلة النموذج الأولي -- الكائنات المنسوخة تفقد ارتباطاتها بالفئات. لكائنات البيانات العادية (وهي حالة الاستخدام الأكثر شيوعا في إدارة الحالة)، structuredClone هو أفضل خيار مدمج للنسخ العميق. للبيئات القديمة التي تفتقر إلى structuredClone، الحل البديل الكلاسيكي هو JSON.parse(JSON.stringify(obj))، لكن هذا يفقد كائنات Date وقيم undefined وMap وSet وأي أنواع أخرى غير آمنة لـ JSON.التحديثات المتداخلة الثابتة
الجانب الأكثر تحديا في الثبات هو تحديث الخصائص المتداخلة بعمق. تحتاج لإنشاء كائنات جديدة على كل مستوى من التسلسل الهرمي للتداخل، مع نشر الخصائص غير المتغيرة وتجاوز فقط ما تحتاج لتغييره. هذا ينتج كودا مطولا لكن يمكن التنبؤ به. النمط هو نشر كل مستوى من الخارج إلى الداخل، مع استبدال فقط الفرع الذي يقود إلى الخاصية التي يتم تغييرها.
مثال: التحديثات الثابتة للكائنات المتداخلة
const state = {
user: {
profile: {
name: 'أحمد',
address: {
street: 'شارع الملك فهد 123',
city: 'الرياض',
zip: '11564',
},
},
settings: {
theme: 'dark',
notifications: { email: true, sms: false, push: true },
},
},
posts: [
{ id: 1, title: 'المنشور الأول', likes: 10 },
{ id: 2, title: 'المنشور الثاني', likes: 25 },
],
};
// تحديث خاصية متداخلة بعمق: user.profile.address.city
const updatedCity = {
...state,
user: {
...state.user,
profile: {
...state.user.profile,
address: {
...state.user.profile.address,
city: 'جدة',
},
},
},
};
console.log(state.user.profile.address.city); // "الرياض" -- لم يتغير
console.log(updatedCity.user.profile.address.city); // "جدة"
// الفروع غير المتغيرة تحتفظ بمراجعها (فعال!)
console.log(state.user.settings === updatedCity.user.settings); // true
// تحديث إعداد إشعارات متداخل
const toggledSms = {
...state,
user: {
...state.user,
settings: {
...state.user.settings,
notifications: {
...state.user.settings.notifications,
sms: !state.user.settings.notifications.sms,
},
},
},
};
// تحديث عنصر محدد في مصفوفة
const likedPost = {
...state,
posts: state.posts.map(post =>
post.id === 1
? { ...post, likes: post.likes + 1 }
: post
),
};
console.log(state.posts[0].likes); // 10
console.log(likedPost.posts[0].likes); // 11
console.log(state.posts[1] === likedPost.posts[1]); // true -- المنشور غير المتغير محفوظ
مثال: دالة مساعدة للتحديثات الثابتة العميقة
// أداة لإجراء تحديثات ثابتة في مسار محدد
function updatePath(obj, path, updater) {
if (path.length === 0) {
return typeof updater === 'function' ? updater(obj) : updater;
}
const [head, ...rest] = path;
if (Array.isArray(obj)) {
return obj.map((item, index) =>
index === head ? updatePath(item, rest, updater) : item
);
}
return {
...obj,
[head]: updatePath(obj[head], rest, updater),
};
}
const state = {
users: [
{ name: 'أحمد', scores: [90, 85, 95] },
{ name: 'سعيد', scores: [80, 75, 88] },
],
};
// تحديث درجة أحمد الثانية
const updated = updatePath(state, ['users', 0, 'scores', 1], 92);
console.log(state.users[0].scores[1]); // 85 -- لم يتغير
console.log(updated.users[0].scores[1]); // 92
// زيادة درجة سعيد الأولى
const incremented = updatePath(state, ['users', 1, 'scores', 0], prev => prev + 5);
console.log(incremented.users[1].scores[0]); // 85
// تغيير اسم أحمد
const renamed = updatePath(state, ['users', 0, 'name'], 'أحمد محمد');
console.log(renamed.users[0].name); // "أحمد محمد"
console.log(state.users[0].name); // "أحمد" -- الأصل لم يتغير
ما هي الدوال النقية؟
الدالة النقية هي دالة تستوفي متطلبين صارمين. أولا، بنفس المدخلات، ترجع دائما نفس المخرجات -- وهذا يسمى الحتمية. ثانيا، لا تنتج أي آثار جانبية -- لا تعدل أي شيء خارج نطاقها الخاص، بما في ذلك معاملات الإدخال أو المتغيرات العامة أو الملفات أو قواعد البيانات أو DOM أو وحدة التحكم. الدالة النقية هي مثل الدالة الرياضية: تربط المدخلات بالمخرجات ولا شيء آخر. لا تعتمد على أو تغير أي حالة خارجية. مزيج الثبات والدوال النقية هو أساس البرمجة الوظيفية، ومعا يجعلان كودك أسهل بشكل كبير في الفهم والاختبار والصيانة.
مثال: الدوال النقية مقابل غير النقية
// نقية: نفس المدخل يعطي دائما نفس المخرج، بلا آثار جانبية
function add(a, b) {
return a + b;
}
function calculateArea(radius) {
return Math.PI * radius * radius;
}
function formatUser(user) {
return `${user.firstName} ${user.lastName}`;
}
function filterAdults(people) {
return people.filter(person => person.age >= 18);
}
// غير نقية: تعتمد على حالة خارجية
let taxRate = 0.08;
function calculateTotal(price) {
return price * (1 + taxRate); // تعتمد على متغير خارجي
// إذا تغير taxRate، نفس السعر يعطي نتيجة مختلفة
}
// غير نقية: تعدل حالة خارجية
let callCount = 0;
function trackableAdd(a, b) {
callCount++; // أثر جانبي: يعدل متغيرا خارجيا
return a + b;
}
// غير نقية: تعدل معامل الإدخال
function addToCart(cart, item) {
cart.push(item); // تغير مصفوفة الإدخال
return cart;
}
// النسخة النقية من addToCart
function addToCartPure(cart, item) {
return [...cart, item]; // ترجع مصفوفة جديدة، الإدخال لم يتغير
}
// غير نقية: غير حتمية (مخرج مختلف لنفس الإدخال)
function getUserGreeting(user) {
const hour = new Date().getHours(); // تعتمد على الوقت الحالي
if (hour < 12) return `صباح الخير يا ${user.name}`;
if (hour < 18) return `مساء الخير يا ${user.name}`;
return `مساء الخير يا ${user.name}`;
}
// النسخة النقية: اجعل التبعية صريحة
function getUserGreetingPure(user, hour) {
if (hour < 12) return `صباح الخير يا ${user.name}`;
if (hour < 18) return `مساء الخير يا ${user.name}`;
return `مساء الخير يا ${user.name}`;
}
الشفافية المرجعية
الدالة النقية تظهر خاصية تسمى الشفافية المرجعية. هذا يعني أن أي استدعاء للدالة يمكن استبداله بقيمة الإرجاع الخاصة به دون تغيير سلوك البرنامج. إذا كان add(2, 3) يرجع دائما 5، فأينما ترى في كودك add(2, 3)، يمكنك ذهنيا (أو حرفيا) استبداله بـ 5 ويتصرف البرنامج بشكل مطابق. هذه خاصية قوية بشكل لا يصدق للتفكير في الكود لأنك تستطيع تقييم أجزاء من برنامجك بمعزل عن غيرها. الشفافية المرجعية تجعل إعادة الهيكلة أكثر أمانا وتمكن تحسينات المترجم وتبسط تصحيح الأخطاء بالسماح لك بالتفكير في التعبيرات الفردية دون القلق بشأن تغييرات الحالة المخفية.
مثال: الشفافية المرجعية عمليا
// الدوال النقية شفافة مرجعيا
function double(x) { return x * 2; }
function increment(x) { return x + 1; }
function square(x) { return x * x; }
// هذا التعبير:
const result1 = double(increment(square(3)));
// يمكن تتبعه خطوة بخطوة باستبدال استدعاءات الدوال بالقيم:
// square(3) => 9
// increment(9) => 10
// double(10) => 20
const result2 = 20;
console.log(result1 === result2); // true -- شفاف مرجعيا
// يمكنك إعادة ترتيب استدعاءات الدوال النقية بحرية
const a = double(5); // 10
const b = square(3); // 9
const c = increment(4); // 5
// ترتيب هذه الأسطر الثلاثة لا يهم لأن
// أيا منها لا يؤثر على أو يعتمد على الآخر
// الدوال غير النقية تكسر الشفافية المرجعية
let counter = 0;
function impureIncrement() {
counter++;
return counter;
}
// impureIncrement() !== impureIncrement()
// الاستدعاء الأول يرجع 1، الثاني يرجع 2
// لا يمكنك استبدال الاستدعاء بقيمة ثابتة
const x = impureIncrement(); // 1
const y = impureIncrement(); // 2 -- نتيجة مختلفة لنفس (بلا) إدخال!
// هذا يجعل التفكير في الكود أصعب بكثير
// لأن ترتيب التنفيذ أصبح حاسما الآن
فوائد الدوال النقية: الاختبار والتخزين المؤقت
اثنتان من أكثر فوائد الدوال النقية ملموسية هما تبسيط الاختبار والتخزين المؤقت التلقائي من خلال الحفظ (memoization). لأن الدوال النقية ليس لديها تبعيات على حالة خارجية ولا تنتج آثارا جانبية، فإن اختبارها لا يتطلب أكثر من تقديم المدخلات والتأكد من المخرجات. ولأن نفس المدخلات تنتج دائما نفس المخرجات، يمكنك تخزين النتائج مؤقتا (حفظها) لتجنب الحساب المتكرر.
مثال: اختبار سهل للدوال النقية
// دالة نقية -- سهلة الاختبار بشكل تافه
function calculateDiscount(price, discountPercent) {
if (price <= 0 || discountPercent < 0 || discountPercent > 100) {
return 0;
}
return Math.round(price * (discountPercent / 100) * 100) / 100;
}
// الاختبارات لا تتطلب أي إعداد -- فقط إدخال ومخرج
console.assert(calculateDiscount(100, 10) === 10, '10% من 100');
console.assert(calculateDiscount(49.99, 25) === 12.5, '25% من 49.99');
console.assert(calculateDiscount(0, 50) === 0, 'سعر صفر');
console.assert(calculateDiscount(100, -5) === 0, 'خصم سلبي');
console.assert(calculateDiscount(100, 150) === 0, 'خصم فوق 100%');
// مقارن ترتيب نقي
function sortByPrice(a, b) {
return a.price - b.price;
}
// شرط تصفية نقي
function isInStock(product) {
return product.stock > 0;
}
// محول نقي
function toSummary(product) {
return {
name: product.name,
price: `$${product.price.toFixed(2)}`,
available: product.stock > 0,
};
}
// كل دالة يمكن اختبارها بشكل مستقل
console.assert(sortByPrice({ price: 10 }, { price: 20 }) < 0, 'ترتيب تصاعدي');
console.assert(isInStock({ stock: 5 }) === true, 'متوفر');
console.assert(isInStock({ stock: 0 }) === false, 'غير متوفر');
console.assert(toSummary({ name: 'أداة', price: 9.99, stock: 3 }).price === '$9.99', 'تنسيق السعر');
مثال: الحفظ -- تخزين نتائج الدوال النقية مؤقتا
// غلاف حفظ عام للدوال النقية
function memoize(fn) {
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log(`إصابة ذاكرة التخزين للمعاملات: ${key}`);
return cache.get(key);
}
console.log(`حساب للمعاملات: ${key}`);
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
// دالة نقية مكلفة -- مرشح مثالي للحفظ
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
const memoFib = memoize(function fib(n) {
if (n <= 1) return n;
return memoFib(n - 1) + memoFib(n - 2);
});
console.time('الاستدعاء الأول');
console.log(memoFib(35)); // حساب... يستغرق وقتا في الاستدعاء الأول
console.timeEnd('الاستدعاء الأول');
console.time('الاستدعاء الثاني');
console.log(memoFib(35)); // إصابة ذاكرة التخزين -- فوري!
console.timeEnd('الاستدعاء الثاني');
// حفظ تحويل بيانات
const processData = memoize(function(data, filters) {
console.log('معالجة...');
return data
.filter(item => item.category === filters.category)
.map(item => ({ ...item, price: item.price * (1 - filters.discount) }))
.sort((a, b) => a.price - b.price);
});
const products = [
{ name: 'أ', category: 'إلكترونيات', price: 100 },
{ name: 'ب', category: 'كتب', price: 20 },
{ name: 'ج', category: 'إلكترونيات', price: 50 },
];
const filters = { category: 'إلكترونيات', discount: 0.1 };
processData(products, filters); // "معالجة..." -- يحسب
processData(products, filters); // "إصابة ذاكرة التخزين" -- يرجع النتيجة المخزنة
تحديد والقضاء على الآثار الجانبية
الأثر الجانبي هو أي تغيير ملحوظ تقوم به الدالة على العالم خارج نطاقها الخاص. تشمل الآثار الجانبية تعديل المتغيرات العامة أو الخارجية، وتغيير معاملات الدالة، والكتابة إلى وحدة التحكم، وإجراء طلبات HTTP، والقراءة من أو الكتابة إلى DOM، والقراءة من أو الكتابة إلى الملفات أو قواعد البيانات، وتوليد أرقام عشوائية، والحصول على التاريخ أو الوقت الحالي. هدف البرمجة الوظيفية ليس القضاء على جميع الآثار الجانبية (برنامجك يجب أن يتفاعل في النهاية مع العالم الخارجي) بل عزلها ودفعها إلى حدود برنامجك والحفاظ على المنطق الأساسي نقيا.
مثال: تحديد الآثار الجانبية الشائعة
// أثر جانبي: تعديل حالة خارجية
let total = 0;
function addToTotal(amount) {
total += amount; // أثر جانبي: يعدل متغيرا خارجيا
return total;
}
// البديل النقي: إرجاع المجموع الجديد
function addToTotalPure(currentTotal, amount) {
return currentTotal + amount; // لا أثر جانبي
}
// أثر جانبي: تعديل معاملات الإدخال
function sortUsers(users) {
users.sort((a, b) => a.name.localeCompare(b.name)); // أثر جانبي: يغير الإدخال
return users;
}
// البديل النقي: إرجاع مصفوفة مرتبة جديدة
function sortUsersPure(users) {
return [...users].sort((a, b) => a.name.localeCompare(b.name));
}
// أثر جانبي: التلاعب بـ DOM
function updateDisplay(message) {
document.getElementById('output').textContent = message; // أثر جانبي
}
// البديل النقي: إرجاع البيانات، دع المستدعي يتعامل مع DOM
function formatMessage(data) {
return `${data.user}: ${data.message} (${data.timestamp})`;
}
// الأثر الجانبي معزول عند المستدعي:
// document.getElementById('output').textContent = formatMessage(data);
// أثر جانبي: طلبات HTTP
async function fetchAndProcess(url) {
const response = await fetch(url); // أثر جانبي: طلب شبكة
const data = await response.json();
return data.map(item => item.name); // هذا الجزء نقي
}
// أفضل: فصل الأثر الجانبي عن التحويل النقي
async function fetchData(url) {
const response = await fetch(url); // الأثر الجانبي معزول هنا
return response.json();
}
function extractNames(data) { // تحويل نقي
return data.map(item => item.name);
}
// الاستخدام: أثر جانبي ثم تحويل نقي
// const data = await fetchData(url);
// const names = extractNames(data);
مثال: إعادة هيكلة كود غير نقي إلى كود نقي
// قبل: سلة تسوق غير نقية بآثار جانبية كثيرة
let cart = [];
let orderCount = 0;
function addItem(name, price) {
cart.push({ name, price, id: ++orderCount }); // تغير cart وorderCount
console.log(`تمت إضافة ${name}`); // أثر جانبي لوحدة التحكم
updateCartDisplay(); // أثر جانبي DOM
saveToLocalStorage(); // أثر جانبي تخزين
}
function getTotal() {
let sum = 0;
for (const item of cart) {
sum += item.price;
}
return sum;
}
// بعد: منطق أساسي نقي مع آثار جانبية عند الحدود
// دوال نقية -- بلا آثار جانبية، سهلة الاختبار
function addItemToCart(cart, item) {
return [...cart, { ...item, id: cart.length + 1 }];
}
function removeItemFromCart(cart, itemId) {
return cart.filter(item => item.id !== itemId);
}
function calculateTotal(cart) {
return cart.reduce((sum, item) => sum + item.price, 0);
}
function applyDiscount(cart, discountPercent) {
return cart.map(item => ({
...item,
price: Math.round(item.price * (1 - discountPercent / 100) * 100) / 100,
}));
}
function getCartSummary(cart) {
return {
items: cart.length,
total: calculateTotal(cart),
itemNames: cart.map(item => item.name),
};
}
// الآثار الجانبية معزولة في مكان واحد -- "الغلاف"
function handleAddItem(name, price) {
const currentCart = getCartFromState(); // قراءة الحالة
const newCart = addItemToCart(currentCart, { name, price }); // نقي
const summary = getCartSummary(newCart); // نقي
saveCartToState(newCart); // كتابة الحالة
renderCart(summary); // تحديث DOM
console.log(`تمت إضافة ${name} -- المجموع: $${summary.total}`); // تسجيل
}
// اختبار الدوال النقية لا يتطلب أي إعداد
console.assert(
calculateTotal([{ price: 10 }, { price: 20 }]) === 30,
'حساب المجموع'
);
const testCart = [{ id: 1, name: 'أ', price: 50 }];
const withB = addItemToCart(testCart, { name: 'ب', price: 30 });
console.assert(withB.length === 2, 'تمت إضافة عنصر');
console.assert(testCart.length === 1, 'الأصل لم يتغير');
تمرين عملي
ابنِ نظام إدارة حالة ثابت لتطبيق مدير المهام. ابدأ بتعريف كائن حالة أولية بالبنية التالية: مصفوفة tasks (كل مهمة لها خصائص id وtitle وcompleted وpriority وtags)، وكائن filters (بخصائص status وpriority وsearchTerm)، وكائن ui (بخصائص selectedTaskId وsortBy). جميع تحديثات الحالة يجب أن تكون ثابتة -- لا تعدل أبدا الحالة الموجودة. نفذ الدوال النقية التالية: addTask(state, taskData) التي ترجع حالة جديدة مع المهمة المضافة، toggleTask(state, taskId) التي ترجع حالة جديدة مع حالة إكمال المهمة معكوسة، updateTaskTags(state, taskId, newTags) التي ترجع حالة جديدة مع وسوم المهمة مستبدلة، deleteTask(state, taskId) التي ترجع حالة جديدة بدون المهمة المحددة، setFilter(state, filterName, value) التي ترجع حالة جديدة مع المرشح محدثا، getVisibleTasks(state) التي ترجع مصفوفة مرشحة ومرتبة من المهام بناء على المرشحات الحالية وإعدادات الترتيب (هذه حسابات مشتقة نقية). تحقق من الثبات بالتأكد من أن الحالة الأصلية لم تتغير بعد كل عملية. استخدم Object.freeze أو deepFreeze على حالتك الأولية لضمان عدم حدوث تغييرات عرضية. اكتب ما لا يقل عن 10 تأكيدات اختبار تتحقق من صحة مخرجات كل دالة وثبات حالة الإدخال. مكافأة: نفذ دالة undo بالاحتفاظ بمصفوفة تاريخ من الحالات السابقة -- الثبات يجعل هذا تافها لأن كل حالة هي لقطة كاملة.