لماذا الثبات مهم جدا

مرحبا هبر!



نريد اليوم أن نتطرق إلى موضوع الثبات ونرى ما إذا كانت هذه المشكلة تستحق دراسة أكثر جدية.



الكائنات غير القابلة للتغيير هي ظاهرة قوية بما لا يقاس في البرمجة. تساعدك الثبات على تجنب جميع أنواع مشكلات التزامن ومجموعة كبيرة من الأخطاء ، ولكن قد يكون فهم التركيبات غير القابلة للتغيير أمرًا صعبًا. دعنا نلقي نظرة على ماهيتها وكيفية استخدامها.



أولاً ، ألق نظرة على كائن بسيط:



class Person {
    public String name;
    
    public Person(
        String name
    ) {
        this.name = name;
    }
}


كما ترى ، Personيأخذ الكائن معلمة واحدة في مُنشئه ثم يضعه في متغير عام name. وفقًا لذلك ، يمكننا القيام بأشياء مثل:



Person p = new Person("John");
p.name = "Jane";


بسيط ، صحيح؟ قراءة البيانات أو تعديلها في أي وقت كما يحلو لنا. ولكن هناك مشكلتان في هذه الطريقة. أولها وأهمها هو أننا نستخدم متغيرًا في صنفنا name، وبالتالي ، نقدم بشكل نهائي التخزين الداخلي للفصل في واجهة برمجة التطبيقات العامة. بمعنى آخر ، لا توجد طريقة يمكننا من خلالها تغيير طريقة تخزين الاسم داخل الفصل الدراسي ، إلا إذا أعدنا كتابة جزء مهم من تطبيقنا.



توفر بعض اللغات (على سبيل المثال ، C #) القدرة على إدراج دالة getter للتغلب على هذه المشكلة ، ولكن في معظم اللغات الموجهة للكائنات ، عليك التصرف بشكل صريح:



class Person {
    private String name;
    
    public Person(
        String name
    ) {
        this.name = name;
    }
    
    public String getName() {
        return name;
    }
}


حتى الان جيدة جدا. إذا كنت تريد الآن تغيير التخزين الداخلي للاسم ، على سبيل المثال ، للاسم الأول والأخير ، فيمكنك القيام بذلك:



class Person {
    private String firstName;
    private String lastName;
    
    public Person(
        String firstName,
        String lastName
    ) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
    
    public String getName() {
        return firstName + " " + lastName;
    }
}


إذا لم تخوض في المشاكل الخطيرة المرتبطة بتمثيل الأسماء هذا ، فمن الواضح أن واجهة برمجة التطبيقات getName()لم تتغير خارجيًا .



ماذا عن وضع الأسماء؟ ما الذي تحتاج إلى إضافته ليس فقط للحصول على الاسم ، ولكن أيضًا لتعيينه على هذا النحو؟



class Person {
    private String name;
    
    //...
    
    public void setName(String name) {
        this.name = name;
    }
    
    //...
}


للوهلة الأولى ، يبدو الأمر رائعًا ، لأنه يمكننا الآن تغيير الاسم مرة أخرى. لكن هناك عيبًا أساسيًا في طريقة تعديل البيانات هذه. لها وجهان: فلسفي وعملي.



لنبدأ بمشكلة فلسفية. الغرض من الكائن هو Personتمثيل شخص. في الواقع ، يمكن تغيير اسم العائلة ، ولكن سيكون من الأفضل تسمية وظيفة لهذا الغرض changeName، لأن هذا الاسم يعني أننا نغير لقب الشخص نفسه. يجب أن يتضمن أيضًا منطق العمل لتغيير الاسم الأخير لشخص ما ، وليس مجرد التصرف كمحدد. setNameيؤدي الاسم إلى نتيجة منطقية تمامًا مفادها أنه يمكننا طوعًا تغيير الاسم المخزن في كائن الشخص ، ولن نحصل على أي شيء مقابل ذلك.



السبب الثاني يتعلق بالممارسة: الحالة المتغيرة (البيانات المخزنة التي يمكن أن تتغير) عرضة للأخطاء. لنأخذ هذا الكائن Personونحدد الواجهة PersonStorage:



interface PersonStorage {
    public void store(Person person);
    public Person getByName(String name);
}


لاحظ أن هذا PersonStorageلا يشير إلى مكان تخزين الكائن بالضبط: في الذاكرة أو على القرص أو في قاعدة البيانات. لا تتطلب الواجهة أيضًا تنفيذًا لإنشاء نسخة من الكائن الذي تخزنه. لذلك ، قد يظهر خطأ مثير للاهتمام:



Person p = new Person("John");
myPersonStorage.store(p);
p.setName("Jane");
myPersonStorage.store(p);


كم عدد الأشخاص الموجودين حاليًا في متجر الأشخاص؟ واحد او اثنين؟ علاوة على ذلك ، إذا طبقنا الطريقة الآن getByName، فمن سيعود؟



كما ترى ، هناك خياران ممكنان هنا: إما أن يقوم PersonStorageبنسخ الكائن Person، وفي هذه الحالة سيتم حفظ سجلين Person، أو لن يقوم بذلك ، وسيحفظ فقط المرجع إلى الكائن الذي تم تمريره ؛ في الحالة الثانية ، سيتم حفظ كائن واحد فقط باسم “Jane”. قد يبدو تنفيذ الخيار الثاني كما يلي:



class InMemoryPersonStorage implements PersonStorage {
    private Set<Person> persons = new HashSet<>();

    public void store(Person person) {
        this.persons.add(person);
    }
}


والأسوأ من ذلك ، أنه يمكن تغيير البيانات المخزنة دون استدعاء الوظيفة store. نظرًا لأن المستودع يحتوي فقط على مرجع للكائن الأصلي ، فإن تغيير الاسم سيؤدي أيضًا إلى تغيير الإصدار المحفوظ:



Person p = new Person("John");
myPersonStorage.store(p);
p.setName("Jane");


لذلك ، في جوهرها ، تتسلل الأخطاء إلى برنامجنا على وجه التحديد لأننا نتعامل مع حالة قابلة للتغيير. ليس هناك شك في أنه يمكن التحايل على هذه المشكلة من خلال تدوين عمل إنشاء نسخة في المستودع بشكل صريح ، ولكن هناك أيضًا طريقة أبسط بكثير: العمل مع الكائنات غير القابلة للتغيير. لنفكر في مثال:



class Person {
    private String name;
    
    public Person(
        String name
    ) {
        this.name = name;
    }
    
    public String getName() {
        return name;
    }
    
    public Person withName(String name) {
        return new Person(name);
    }
}


كما ترى ، بدلاً من طريقة ، يتم setNameالآن استخدام طريقة withNameلإنشاء نسخة جديدة من الكائن Person. إذا أنشأنا نسخة جديدة في كل مرة ، فإننا نستغني عن الحالة المتغيرة وبدون المشاكل المقابلة. بالطبع ، يأتي هذا مع بعض النفقات العامة ، ولكن يمكن للمترجمين المعاصرين التعامل معها وإذا واجهت مشاكل في الأداء يمكنك إصلاحها لاحقًا.



تذكر:

التحسين المبكر هو أصل كل الشرور (دونالد كنوث)


يمكن القول أن مستوى الثبات الذي يشير إلى الكائن الحي هو مستوى ثبات مكسور ، لكن هذا سيناريو واقعي. الشفرة السيئة موجودة بالفعل ، والثبات هو أداة قيمة في المساعدة على منع مثل هذه الأعطال.



في السيناريوهات الأكثر تعقيدًا ، حيث يتم تمرير الكائنات عبر طبقات متعددة من التطبيق ، فإن الأخطاء تغمر الكود بسهولة ، ويمنع الثبات أخطاء الحالة من الحدوث. تتضمن الأمثلة من هذا النوع ، على سبيل المثال ، التخزين المؤقت في الذاكرة أو استدعاءات الوظائف خارج الترتيب.



كيف تساعد الثبات في المعالجة المتوازية



مجال مهم آخر حيث يكون الثبات في متناول اليد هو المعالجة المتوازية. بتعبير أدق ، multithreading. في التطبيقات متعددة مؤشرات الترابط ، يتم تنفيذ عدة أسطر من التعليمات البرمجية بشكل متوازٍ ، والتي في نفس الوقت تصل إلى نفس منطقة الذاكرة. ضع في اعتبارك قائمة بسيطة للغاية:



if (p.getName().equals("John")) {
    p.setName(p.getName() + "Doe");
}


هذا الرمز ليس عربات التي تجرها الدواب في حد ذاته ، ولكن عند تشغيله بشكل متوازٍ ، فإنه يبدأ في الوقاحة ويمكن أن يصبح فوضويًا. تحقق من شكل مقتطف الشفرة أعلاه مع تعليق:



if (p.getName().equals("John")) {

    //     ,     John
    
    p.setName(p.getName() + "Doe");
}


هذه حالة سباق. يتحقق مؤشر الترابط الأول مما إذا كان الاسم متساويًا “John”، ولكن بعد ذلك يقوم مؤشر الترابط الثاني بتغيير الاسم. في الوقت نفسه ، يستمر الخيط الأول في العمل ، مع افتراض تساوي الاسم John.



بالطبع ، يمكن استخدام القفل للتأكد من أن مؤشر ترابط واحد فقط يدخل الجزء المهم من الكود في أي وقت ، ومع ذلك ، قد يكون هذا عنق الزجاجة. ومع ذلك ، إذا كانت الكائنات غير قابلة للتغيير ، فلن يتطور مثل هذا السيناريو ، حيث يتم تخزين نفس الكائن دائمًا في p. إذا أراد مؤشر ترابط آخر التأثير على التغيير ، فإنه يقوم بإنشاء نسخة جديدة لن تكون في السلسلة الأولى.



النتيجة



في الأساس ، ستكون نصيحتي هي التأكد دائمًا من تقليل الحالة القابلة للتغيير في تطبيقك. إذا كنت تستخدمه ، فقم بتقييده بإحكام باستخدام واجهات برمجة التطبيقات المصممة جيدًا ، ولا تدعه يتسرب إلى مناطق أخرى من التطبيق. كلما قل عدد أجزاء التعليمات البرمجية التي تحتوي على حالة ، قل احتمال اكتشافك لأخطاء الحالة.



بالطبع ، لا يمكن حل معظم مشكلات البرمجة إذا لم تلجأ إلى التصريح على الإطلاق. ولكن إذا اعتبرنا جميع هياكل البيانات غير قابلة للتغيير افتراضيًا ، فسيكون هناك عدد أقل من الأخطاء العشوائية في الكود. إذا كنت مضطرًا حقًا إلى إدخال قابلية التغيير في التعليمات البرمجية الخاصة بك ، فسيتعين عليك القيام بذلك بعناية والتفكير في العواقب ، وليس بدء كل الكود بها.



All Articles