التقاط المتغيرات والنطاق في Lambda
تعبير lambda ليس مجرّد قطعة كود معزولة — إنّه إغلاق (closure). يستطيع الوصول إلى خارج قائمة معاملاته وقراءة المتغيرات المُعلَنة في التابع أو الصنف المحيط. فهم ما يستطيع lambda التقاطه، ولماذا توجد قيود معيّنة، وكيف يتصرّف this بداخله — هذه هي الركائز الثلاث لهذا الدرس.
القاعدة الذهبية: المتغير الفعليًا النهائي
تسمح Java للـ lambda بالتقاط متغير محلي فقط إذا كان ذلك المتغير فعليًا نهائيًا (effectively final) — أي أنّ قيمته لا تُعاد إسناد بعد تعيينها الأول. لا يجب التصريح عليه بـ final، لكنّه يجب أن يتصرّف كذلك.
public class Capture {
public static void main(String[] args) {
int threshold = 10; // فعليًا نهائي — لم يُعَد إسناده قط
// صحيح: threshold يُلتقط بأمان
Runnable r = () -> System.out.println("Threshold is " + threshold);
r.run(); // Threshold is 10
}
}
الآن لاحظ ما يحدث حين تحاول إعادة إسناد المتغير بعد تعريف الـ lambda:
int threshold = 10;
threshold = 20; // إعادة إسناد — لم يعد فعليًا نهائيًا
// خطأ في الترجمة: المتغير المُستخدم في تعبير lambda يجب أن يكون فعليًا نهائيًا
Runnable r = () -> System.out.println(threshold);
لماذا هذا القيد موجود؟ المتغيرات المحلية تعيش على مكدّس الخيط الحالي. يمكن لكائن lambda أن يعمر أطول من إطار ذلك المكدّس — قد يُخزَّن أو يُمرَّر لخيط آخر أو يُستدعى لاحقًا. لو كان المتغير قابلًا للتغيير، ربّما يقرأ الـ lambda قيمة قديمة أو متعارضة. بإلزام الفعلية النهائية، يضمن المُجمِّع أنّ الـ lambda يرى دومًا لقطة ثابتة ومتسقة.
حقول النسخة والحقول الثابتة: قواعد مختلفة
قاعدة الفعلية النهائية تنطبق فقط على المتغيرات المحلية. حقول النسخة والحقول الثابتة توجد في الكومة (heap) لا في المكدّس، لذا تستطيع التعبيرات lambda قراءتها وكتابتها بحرية دون أي قيد.
public class Counter {
private int count = 0; // حقل نسخة — موجود في الكومة
public Runnable makeIncrementer() {
// قراءة 'count' وكتابته مسموح — إنّه حقل لا متغير محلي
return () -> count++;
}
public int getCount() { return count; }
public static void main(String[] args) {
Counter c = new Counter();
Runnable inc = c.makeIncrementer();
inc.run();
inc.run();
System.out.println(c.getCount()); // 2
}
}
تعديل الحالة المشتركة من داخل lambda أمر خطير في الكود المتزامن. كون Java تسمح بذلك لا يعني أنّه آمن. حين تستدعي خيوط متعددة lambda يكتب في حقل مشترك، تحتاج إلى مزامنة. في الكود أحادي الخيط يكون ذلك مقبولًا، لكن احتفظ بهذه المقايضة في ذهنك.
التقاط النطاق المحيط: ما يراه الـ Lambda
يرث الـ lambda النطاق المعجمي (lexical scope) الكامل للتابع الذي كُتب فيه. يشمل ذلك كل متغير محلي موجود في النطاق عند نقطة تعريف الـ lambda — ليس فقط المتغيرات المذكورة صراحةً بداخله. يُغلق الـ lambda على المتغيرات التي يستخدمها فعليًا.
import java.util.List;
import java.util.function.Predicate;
public class ScopeDemo {
public static Predicate<String> startsWithFilter(String prefix) {
// 'prefix' معامل — فعليًا نهائي (لم يُعَد إسناده)
return s -> s.startsWith(prefix); // يلتقط 'prefix' من النطاق الخارجي
}
public static void main(String[] args) {
Predicate<String> startsWithJ = startsWithFilter("J");
List.of("Java", "Kotlin", "JavaScript", "Python")
.stream()
.filter(startsWithJ)
.forEach(System.out::println);
// Java
// JavaScript
}
}
هنا يكون startsWithFilter قد عاد منذ زمن بحلول وقت تقييم startsWithJ من طرف الـ stream. يحمل الـ lambda نسخته الخاصة من prefix — تلك النسخة هي القيمة الملتقطة.
كيف يتصرّف this داخل الـ Lambda
هنا يختلف الـ lambda اختلافًا جوهريًا عن الأصناف المجهولة. داخل صنف مجهول، تشير this إلى نسخة ذلك الصنف المجهول. داخل lambda، تشير this إلى نسخة الصنف المحيط — نفس this التي ستستخدمها في أي مكان آخر في ذلك التابع.
public class Greeter {
private final String name;
public Greeter(String name) {
this.name = name;
}
public Runnable asRunnable() {
// 'this' داخل الـ lambda هي نسخة Greeter
return () -> System.out.println("Hello from " + this.name);
}
public static void main(String[] args) {
Greeter g = new Greeter("Alice");
Runnable r = g.asRunnable();
r.run(); // Hello from Alice
}
}
قارن ذلك بما يقابله من الصنف المجهول:
public Runnable asRunnableAnon() {
return new Runnable() {
@Override
public void run() {
// 'this' هنا هي نسخة Runnable المجهولة، ليست نسخة Greeter
// للوصول إلى الصنف الخارجي تكتب Greeter.this.name
System.out.println("Hello from " + Greeter.this.name);
}
};
}
فضّل lambda حين تحتاج للإشارة إلى الكائن المحيط. بما أنّ this في الـ lambda تعني دومًا الصنف الخارجي، لن تحتاج قط للمؤهّل OuterClass.this. هذا يقلّل الكود المكرّر ويزيل مصدرًا شائعًا للارتباك لدى المطوّرين الجدد على الأصناف المجهولة.
أنماط عملية لتفادي مشكلات الالتقاط
أحيانًا تريد استخدام متغير داخل lambda لكنّ المُجمِّع يرفضه لأنّه أُعيد إسناده. الإصلاح الاحترافي هو نسخه إلى متغير جديد فعليًا نهائي قبل تعريف الـ lambda:
public static void printIfAboveThreshold(List<Integer> values, int threshold) {
// threshold معامل؛ افترض أنّ المنطق يعدّله لاحقًا
threshold = adjustThreshold(threshold); // إعادة إسناد — لم يعد فعليًا نهائيًا
int limit = threshold; // نسخ في متغير جديد لا يُعاد إسناده أبدًا
values.stream()
.filter(n -> n > limit) // يلتقط 'limit' لا 'threshold'
.forEach(System.out::println);
}
private static int adjustThreshold(int t) { return t + 5; }
هذا النمط احترافي وواضح ويجعل القصد صريحًا: ينبغي للـ lambda استخدام القيمة المُعدَّلة لا المعامل الأصلي.
الخلاصة
- تستطيع التعبيرات lambda التقاط المتغيرات المحلية الفعليًا النهائية — تلك التي تُسنَد مرة واحدة فقط.
- يمكن قراءة حقول النسخة والحقول الثابتة وكتابتها بحرية (مع الحرص على المزامنة في الكود المتزامن).
- القيمة الملتقطة هي لقطة؛ الـ lambda يحمل نسخته الخاصة من المتغير المحلي.
- تشير
this داخل lambda إلى نسخة الصنف المحيط، لا إلى كائن lambda اصطناعي.
- حين لا يكون المتغير فعليًا نهائيًا، انسخه إلى متغير جديد قبل تعريف الـ lambda.
تجعل هذه القواعد التعبيرات lambda آمنة للتمرير والاستدعاء لاحقًا دون تسمية غير متوقعة أو تسابق على البيانات المحلية.