DEV Community

nikosst
nikosst

Posted on

Ανάλυση constructors, deconstruction, destructors/finalizers

constructors deconstructors

Τι είναι ο constructor και πότε τρέχει

Ο constructor είναι ένα ειδικό method-like στοιχείο στην κλάση που έχει σαν σκοπό να θέσει το νέο αντικείμενο σε μια έγκυρη, προσδιορισμένη αρχική κατάσταση. Στη C#, ένας instance constructor εκτελείται όταν το runtime δημιουργεί ένα νέο στιγμιότυπο της κλάσης, δηλαδή κατά την κλήση new Type(...). Επιπλέον, κλήσεις σε factory APIs που χρησιμοποιούν reflection (π.χ. Activator.CreateInstance) θα οδηγήσουν επίσης στην εκτέλεση του κατάλληλου constructor. Η χρήση reflection επιτρέπει στον κώδικα να επιθεωρεί τύπους, μεθόδους και constructors δυναμικά. Όταν χρησιμοποιούμε factory APIs όπως το Activator.CreateInstance, μετατοπίζεται η ευθύνη της επιλογής constructor από τον compiler στο runtime. Αυτό σημαίνει ότι η απόφαση για το ποιος constructor θα εκτελεστεί δεν γίνεται στατικά, αλλά δυναμικά με βάση τον τύπο και τα παρεχόμενα ορίσματα.

Ο compiler είναι το στάδιο όπου ο κώδικας ελέγχεται και «κλειδώνει» στατικά: εκεί επαληθεύεται η ορθότητα των τύπων, η ύπαρξη των constructors και η συμβατότητα των κλήσεων μεθόδων, ώστε τα περισσότερα λάθη να εντοπίζονται πριν καν εκτελεστεί το πρόγραμμα. Αντίθετα, το runtime είναι το στάδιο εκτέλεσης του προγράμματος, όπου ο κώδικας τρέχει πραγματικά και όπου λαμβάνονται δυναμικές αποφάσεις, όπως η επιλογή constructor μέσω reflection (Activator.CreateInstance) ή η δημιουργία αντικειμένων χωρίς constructor (π.χ. μέσω FormatterServices). Με απλά λόγια, ο compiler προσπαθεί να εγγυηθεί ασφάλεια πριν την εκτέλεση, ενώ το runtime δίνει ευελιξία κατά την εκτέλεση, με το τίμημα ότι τα λάθη μπορεί να εμφανιστούν μόνο όταν το πρόγραμμα τρέχει.

Ο όρος ότι «κλήσεις σε factory APIs που χρησιμοποιούν reflection (π.χ. Activator.CreateInstance) οδηγούν στην εκτέλεση του κατάλληλου constructor» σημαίνει ότι η δημιουργία αντικειμένων δεν γίνεται μόνο με τον τελεστή new, αλλά μπορεί να γίνει δυναμικά σε χρόνο εκτέλεσης, χωρίς να είναι γνωστός ο τύπος στο compile time. Στο μοντέλο του .NET runtime, μέθοδοι όπως το Activator.CreateInstance χρησιμοποιούν reflection για να επιθεωρήσουν τον τύπο, να εντοπίσουν όλους τους διαθέσιμους constructors και να επιλέξουν αυτόν που ταιριάζει με τα ορίσματα που δίνονται στη μέθοδο. Έτσι, η απόφαση για το ποιος constructor θα εκτελεστεί μεταφέρεται από τον compiler στο runtime, εισάγοντας late binding και καθιστώντας δυνατά αρχιτεκτονικά μοτίβα όπως IoC, Dependency Injection και plugin systems.

Παράδειγμα κλάσης με πολλαπλούς constructors:

public class Person
{
    public string Name { get; }
    public int Age { get; }

    public Person()
    {
        Name = "Unknown";
        Age = 0;
    }

    public Person(string name)
    {
        Name = name;
        Age = 0;
    }

    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }
}
Enter fullscreen mode Exit fullscreen mode

Παράδειγμα χρήσης reflection με factory API:

var p1 = Activator.CreateInstance(typeof(Person));
var p2 = Activator.CreateInstance(typeof(Person), "Alice");
var p3 = Activator.CreateInstance(typeof(Person), "Bob", 42);
Enter fullscreen mode Exit fullscreen mode

Υπάρχουν ωστόσο τεχνικά μονοπάτια με τα οποία μπορούμε να προσεγγίσουμε τη δημιουργία αντικειμένου χωρίς να εκτελέσουμε τον constructor για παράδειγμα FormatterServices.GetUninitializedObject δημιουργεί αντικείμενο χωρίς να καλεί τον constructor (χρήσιμο σε serialization frameworks αλλά επικίνδυνο για invariants), και reflection μπορεί να ενεργοποιήσει constructors με συγκεκριμένη visiblity. Αυτό σημαίνει ότι ο developer πρέπει να κατανοεί ότι ο constructor είναι το επίσημο σημείο όπου επιβάλλονται invariants, αλλά υπάρχει και τρόπος παράκαμψης, ο οποίος πρέπει να λαμβάνεται υπ’ όψη όταν σχεδιάζεται ένα API.

«Χρήσιμο σε serialization frameworks αλλά επικίνδυνο για invariants»

Όταν λέμε ότι μέθοδοι όπως FormatterServices.GetUninitializedObject είναι «χρήσιμες σε serialization frameworks», εννοούμε ότι επιτρέπουν την ανακατασκευή ενός αντικειμένου από αποθηκευμένη κατάσταση (π.χ. από binary, JSON, XML) χωρίς να εκτελείται ο constructor, γιατί σε αυτή τη φάση το αντικείμενο δεν πρέπει να αρχικοποιηθεί με «default λογική», αλλά να φορτωθεί ακριβώς όπως ήταν όταν αποθηκεύτηκε. Τα serialization frameworks (παλαιότερα το .NET BinaryFormatter, αλλά και πιο σύγχρονες τεχνικές σε ειδικά frameworks) χρειάζονται αυτό το “παραθυράκι” για να παρακάμψουν τον constructor και να γεμίσουν απευθείας τα fields, ώστε να αποκαταστήσουν πιστά την προηγούμενη κατάσταση του αντικειμένου.

Το πρόβλημα είναι ότι αυτή η πρακτική είναι «επικίνδυνη για invariants». Τα invariants είναι οι αμετάβλητοι κανόνες εγκυρότητας της κλάσης, δηλαδή οι συνθήκες που πρέπει να είναι πάντα αληθείς για να θεωρείται ένα αντικείμενο έγκυρο. Συνήθως αυτές οι συνθήκες επιβάλλονται μέσα στον constructor. Αν παρακαμφθεί ο constructor, τότε αυτές οι συνθήκες δεν ελέγχονται και το αντικείμενο μπορεί να βρεθεί σε μη έγκυρη ή μερικώς αρχικοποιημένη κατάσταση.

Δες το ακόλουθο παράδειγμα:

public class BankAccount
{
    public decimal Balance { get; private set; }

    public BankAccount(decimal initialBalance)
    {
        if (initialBalance < 0)
            throw new ArgumentException("Balance cannot be negative");

        Balance = initialBalance;
    }
}
Enter fullscreen mode Exit fullscreen mode

Εδώ υπάρχει ένα invariant: «Το Balance δεν πρέπει ποτέ να είναι αρνητικό». Αυτός ο κανόνας επιβάλλεται μέσα στον constructor. Αν δημιουργήσουμε αντικείμενο κανονικά, δεν μπορεί να υπάρξει BankAccount με αρνητικό υπόλοιπο.

Αν όμως χρησιμοποιήσουμε:

using System.Runtime.Serialization;

var obj = (BankAccount)FormatterServices.GetUninitializedObject(typeof(BankAccount));
Enter fullscreen mode Exit fullscreen mode

τότε το BankAccount δημιουργείται χωρίς να τρέξει καθόλου ο constructor, άρα δεν γίνεται κανένας έλεγχος. Το αντικείμενο υπάρχει στη μνήμη, αλλά μπορεί να έχει Balance = 0, ή ακόμη χειρότερα, αν κάποιος μέσω reflection γράψει απευθείας στο field, μπορεί να παραβιάσει το invariant.


Static constructors

Όταν μιλάμε για την ακριβή στιγμή εκτέλεσης, υπάρχουν δύο ξεκάθαρες περιπτώσεις, οι static constructors και οι instance constructors. Ο static constructor (ορίζεται ως static MyClass()) εκτελείται μία και μόνο φορά, πριν την πρώτη χρήση του τύπου, αυτό μπορεί να είναι η πρώτη κλήση σε static μέλος ή η πρώτη δημιουργία στιγμιότυπου. Ο static constructor είναι thread-safe από το runtime, το CLR φροντίζει την single-execution εγγύηση. Οι instance constructors εκτελούνται κάθε φορά που καλείται new, με την ειδική σειρά που περιγράφεται παρακάτω όταν υπάρχουν κληρονομήσεις.

Παράδειγμα με static και instance constructors

public class Demo
{
    // Static field
    public static int StaticValue;

    // Instance field
    public int InstanceValue;

    // Static constructor
    static Demo()
    {
        StaticValue = 100;
        Console.WriteLine("Static constructor executed");
    }

    // Instance constructor
    public Demo()
    {
        InstanceValue = 42;
        Console.WriteLine("Instance constructor executed");
    }
}
Enter fullscreen mode Exit fullscreen mode

Και ένας κώδικας εκτέλεσης:

class Program
{
    static void Main()
    {
        Console.WriteLine("Before first use");

        // Πρώτη χρήση του τύπου → τρέχει πρώτα ο static constructor
        Console.WriteLine(Demo.StaticValue);

        // Δημιουργία instance → τρέχει ο instance constructor
        var obj = new Demo();
    }
}
Enter fullscreen mode Exit fullscreen mode

Αναμενόμενη σειρά εξόδου:

Before first use
Static constructor executed
Instance constructor executed
Enter fullscreen mode Exit fullscreen mode

Εδώ φαίνεται καθαρά ότι ο static constructor εκτελείται μία φορά, πριν την πρώτη ουσιαστική χρήση του τύπου.

Μία static class ΜΠΟΡΕΙ να έχει static constructor, αλλά δεν μπορεί να έχει instance constructor. Μια static class δεν επιτρέπει δημιουργία αντικειμένων, άρα δεν έχει νόημα instance constructor.

Παράδειγμα static class με static constructor:

public static class Configuration
{
    public static string AppName;

    // Static constructor
    static Configuration()
    {
        AppName = "My Application";
        Console.WriteLine("Static class constructor executed");
    }
}
Enter fullscreen mode Exit fullscreen mode

Χρήση:

Console.WriteLine(Configuration.AppName);
Enter fullscreen mode Exit fullscreen mode

Ο static constructor εκτελείται αυτόματα πριν την πρώτη πρόσβαση στο Configuration.AppName.

Ο static constructor υπάρχει για αρχικοποίηση της κλάσης (type-level initialization) και εκτελείται μία φορά από το CLR με thread-safety. Η static class μπορεί κανονικά να έχει static constructor, αλλά δεν επιτρέπεται να έχει instance constructor, γιατί δεν μπορείς να δημιουργήσεις αντικείμενα από static class.


Σειρά εκτέλεσης σε ιεραρχίες κλάσεων, αρχικοποιήση πεδίων και static members

Η σειρά εκτέλεσης είναι κρίσιμη για την κατανόηση του πώς φτάνει ένα αντικείμενο σε σταθερή κατάσταση. Στην πράξη, όταν δημιουργείται ένα στιγμιότυπο κλάσης Derived που κληρονομεί από Base, το runtime ακολουθεί αυτή τη διαδικασία, πρώτα βεβαιώνεται ότι οι static constructors όλων των τύπων έχουν εκτελεστεί (εάν χρειάζεται), έπειτα γίνεται αρχικοποίηση των instance fields της βάσης (field initializers), στη συνέχεια εκτελείται ο instance constructor της βάσης (ο οποίος μπορεί να δεχτεί παραμέτρους αν η παράγωγος καλεί base(...)), μετά γίνονται οι field initializers της παράγωγου, και τέλος εκτελείται ο instance constructor της παράγωγου. Αυτό το μοντέλο εξασφαλίζει ότι το κομμάτι της βάσης είναι έγκυρο πριν το παράγωγο επιχειρήσει να το χρησιμοποιήσει.
Ειδικότερα, ο compiler/CLR υλοποιεί την ακόλουθη «πρώτα τα πεδία, μετά ο constructor» λογική, για κάθε τύπο, οι initializers των πεδίων μετατρέπονται σε κώδικα που εκτελείται πριν από το κορμό του constructor, με αποτέλεσμα ότι οι τιμές που ορίζουμε inline στα πεδία είναι διαθέσιμες μέσα στον constructor. Η κλήση base(...) στο παράγωγο είναι απαραίτητη όταν ο base constructor χρειάζεται παραμέτρους, αν δεν δηλωθεί ρητά, θα κληθεί ο default parameterless constructor της βάσης, επομένως αν η βάση δεν διαθέτει τον parameterless constructor, ο compiler θα εμφανίσει σφάλμα.

Παράδειγμα που δείχνει την σειρά:

public class Base
{
    protected string BaseField = "base field initialized";

    public Base()
    {
        Console.WriteLine("Base constructor running.");
        Console.WriteLine(BaseField);
    }
}

public class Derived : Base
{
    private string DerivedField = "derived field initialized";

    public Derived() : base()
    {
        Console.WriteLine("Derived constructor running.");
        Console.WriteLine(DerivedField);
    }
}

// Χρήση
var d = new Derived();
// Έξοδος:
// Base constructor running.
// base field initialized
// Derived constructor running.
// derived field initialized
Enter fullscreen mode Exit fullscreen mode

Σημείωση: αν ο base constructor τροποποιεί κατάστασης που το παράγωγο περιμένει διαφορετικά, μπορεί να δημιουργηθούν λάθη. Γι’ αυτό είναι σημαντικό οι constructors να μη βασίζονται σε εικασίες για την κατάσταση του παραγώγου.


Overloading constructors, chaining, και αποφυγή duplicated code

Συστήνεται όταν απαιτούνται πολλές «διαδρομές» δημιουργίας αντικειμένου (π.χ. με και χωρίς προεπιλεγμένες τιμές, ή με διαφορετικά επίπεδα πληροφορίας), να χρησιμοποιούνται overloads που ακολουθούν constructor chaining. Το chaining στο C# επιτυγχάνεται με this(...) για να καλέσουμε άλλον constructor της ίδιας κλάσης και με base(...) για να προσπελάσουμε τον constructor της βάσης. Το βασικό κριτήριο είναι η επαναχρησιμοποίηση της λογικής ώστε να μην επαναλαμβάνεται initialization code σε πολλαπλά σημεία, πράγμα που μειώνει τα σφάλματα και διευκολύνει μελλοντικές αλλαγές.

Παράδειγμα με chaining για αποφυγή επανάληψης:

public class ConnectionSettings
{
    public string Host { get; }
    public int Port { get; }
    public TimeSpan Timeout { get; }

    public ConnectionSettings(string host, int port, TimeSpan timeout)
    {
        Host = host ?? throw new ArgumentNullException(nameof(host));
        Port = port;
        Timeout = timeout;
    }

    public ConnectionSettings(string host) : this(host, 80, TimeSpan.FromSeconds(30)) { }

    public ConnectionSettings(string host, int port) : this(host, port, TimeSpan.FromSeconds(30)) { }
}
Enter fullscreen mode Exit fullscreen mode

Εδώ ο κύριος constructor (termed canonical constructor) συγκεντρώνει τη validation logic και τη βασική εκχώρηση. Τα άλλα overloads απλώς κάνουν this(...) με προκαθορισμένες τιμές. Από σχεδιαστική άποψη, είναι καλύτερο να υπάρχει ένα canonical constructor που εφαρμόζει invariants και όλα τα άλλα να οδηγούν σ’ αυτόν.


Πότε δεν πρέπει να κάνεις «βρόμικο» ή βαρύ έργο στον constructor

Ο κανόνας σχεδίασης είναι σαφής, ο constructor πρέπει να είναι υπεύθυνος για την καθιέρωση των invariants του αντικειμένου, δηλαδή έλεγχοι παραμέτρων, εκχώρηση πεδίων, αρχικοποίηση απλών δομών. Αντιθέτως, πρέπει να αποφεύγονται πολύπλοκες, δαπανηρές ή μη deterministic ενέργειες, όπως δικτυακές κλήσεις, πρόσβαση σε βάσεις δεδομένων, ανάγνωση μεγάλων αρχείων ή operations που μπορούν να αποτύχουν για λόγους εξωτερικούς στο περιβάλλον. Υπάρχουν τρεις κύριοι λόγοι γι’ αυτό.

  1. Οι βαριές εργασίες καθιστούν την κατασκευή αντικειμένων αργή και δυσχερείς στο testing.

  2. Αυξάνουν την πιθανότητα exceptions μέσα στον constructor κάνοντας την κατάσταση του αντικειμένου αμφιλεγόμενη (μπορεί να έχει ξεκινήσει η κατασκευή αλλά όχι να ολοκληρωθεί σωστά).

  3. Περιορίζουν την ευελιξία (π.χ. δεν μπορούμε εύκολα να παρέχουμε mocks για εξωτερικές υπηρεσίες που καλούνται μέσα στον constructor).

Όταν απαιτείται ασύγχρονη ή εξωτερική αρχικοποίηση, δύο προσεγγίσεις είναι προτιμότερες.

  1. Χρησιμοποιείς factory methods/objects που εκτελούν την περίπλοκη εργασίας και επιστρέφουν ήδη αρχικοποιημένα αντικείμενα.

  2. Κατασκευάζεις το αντικείμενο με ένα lightweight constructor και παρέχεις ένα InitializeAsync() method που επιστρέφει Task για να εκτελέσει τη βαρύτερη αρχικοποίηση.

Η πρώτη προσέγγιση είναι καλύτερη για αμεταβλητούς/immutable τύπους, η δεύτερη για περιπτώσεις όπου η λογική απαιτεί αρχική κατασκευή ακολουθούμενη από επιπλέον βήματα.


Deconstruct και Tuples

Από το C# 7, η γλώσσα εισήγαγε την deconstruction syntax, επιτρέποντας την ανάθεση πολλαπλών τιμών απευθείας σε tuple-like μεταβλητές, π.χ. var (x, y) = obj;. Για να υποστηρίξει ένας custom τύπος αυτή τη σύνταξη, πρέπει να παρέχει μια δημόσια μέθοδο Deconstruct με κατάλληλη υπογραφή void Deconstruct(out T1 a, out T2 b, ...). Αυτή η μέθοδος δεν έχει κάποιο μαγικό runtime behavior είναι απλά πρότυπο (convention) που ο compiler αναγνωρίζει και χρησιμοποιεί για την αποσύνθεση.

Όταν λέμε ότι το Deconstruct είναι «απλά πρότυπο (convention)», εννοούμε κάτι πολύ συγκεκριμένο σε επίπεδο γλώσσας και compiler, όχι κάτι «μαγικό» στο runtime.

Το convention εδώ σημαίνει ότι δεν υπάρχει κάποιο ειδικό keyword ή ειδικός τύπος στο CLR που να δηλώνει «αυτό είναι deconstructor». Αντίθετα, ο compiler της C# έχει έναν κανόνα: όταν δει σύνταξη όπως

var (x, y) = obj;
Enter fullscreen mode Exit fullscreen mode

ψάχνει στον τύπο του obj να βρει μια μέθοδο με ακριβές όνομα Deconstruct και συμβατή υπογραφή.

Αν βρει κάτι σαν:

public void Deconstruct(out int x, out int y)
Enter fullscreen mode Exit fullscreen mode

τότε ο compiler δεν εκτελεί κάτι ιδιαίτερο στο runtime. Απλώς μεταφράζει τον κώδικα σου σε κάτι ισοδύναμο σαν αυτό:

int x;
int y;
obj.Deconstruct(out x, out y);
Enter fullscreen mode Exit fullscreen mode

Αυτό είναι το νόημα του «δεν είναι μαγικό runtime behavior». Το CLR δεν έχει ειδική έννοια «deconstruction». Είναι καθαρά συντακτική μετατροπή (syntactic sugar) που κάνει ο compiler βασισμένος σε μια σύμβαση: «αν υπάρχει μέθοδος με όνομα Deconstruct και σωστά out parameters, θα τη χρησιμοποιήσω».

Με απλά λόγια:
Δεν υπάρχει κάποια «ιερή» λέξη-κλειδί.
Δεν υπάρχει ειδικός τύπος ή attribute.
Υπάρχει μόνο ένας κανόνας του compiler που λέει: “αν βρω Deconstruct με αυτή τη μορφή, θα τη φωνάξω”.

Για παράδειγμα:

public class Point
{
    public int X { get; }
    public int Y { get; }
    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }
    public void Deconstruct(out int x, out int y)
    {
        x = X;
        y = Y;
    }
}
Enter fullscreen mode Exit fullscreen mode

Και χρήση:

var p = new Point(10, 20);
var (a, b) = p;
Enter fullscreen mode Exit fullscreen mode

Ο compiler το μετατρέπει αυτόματα σε:

int a;
int b;
p.Deconstruct(out a, out b);
Enter fullscreen mode Exit fullscreen mode

Άρα, το «convention» σημαίνει:
Όχι ειδικός μηχανισμός του runtime, αλλά κανόνας ονοματοδοσίας και υπογραφής που καταλαβαίνει ο compiler.

Το Deconstruct χρησιμεύει όταν θέλεις να δώσεις στους χρήστες του τύπου σου εύκολο, σύντομο syntax για να πάρουν τις εσωτερικές τιμές, π.χ. στα value objects, στα σχεσιακά αποτελέσματα ή σε τύπους που εκπροσωπούν συνδυασμένες πληροφορίες. Δεν έχει σχέση με finalizers/destructors· πρόκειται για διαφορετικό concept, τοDeconstruct εξάγει τιμές, ενώ finalizer απελευθερώνει πόρους.

Παράδειγμα:

public class Point
{
    public int X { get; }
    public int Y { get; }

    public Point(int x, int y) { X = x; Y = y; }

    public void Deconstruct(out int x, out int y)
    {
        x = X;
        y = Y;
    }
}

// Χρήση
var p = new Point(3, 4);
var (x, y) = p; // x==3, y==4
Enter fullscreen mode Exit fullscreen mode

Επιπλέον, ValueTuple και Tuple ήδη υποστηρίζουν deconstruction, επομένως αν μια μέθοδος επιστρέφει (int, string) ο κώδικας που καλεί μπορεί αμέσως να κάνει var (id, name) = Get();. Για σχεδιασμό API, το Deconstruct είναι εργαλείο readability και ergonomics, αλλά δεν πρέπει να χρησιμοποιείται ως υποκατάστατο για καλά ορισμένα properties ή για να κρύβει επικίνδυνες side-effects.


Destructor / Finalizer και IDisposable

Στη C# ο «destructor» είναι ένας σύντακτικός ζυγός πάνω στον finalizer (~ClassName()), και καθοδηγεί τη δημιουργία του finalizer method που θα κληθεί από τον garbage collector σε μη-προσδιορισμένο χρόνο. Ο finalizer εκτελείται μόνο όταν το αντικείμενο είναι έτοιμο να συλλεχθεί και μόνο αν έχει δηλωθεί finalizer για τον τύπο ή κάποιον base τύπο. Το σημαντικό ζήτημα είναι ότι η εκτέλεση του finalizer είναι μη-προσδιοριστική ως προς το χρόνο, κοστίζει επιπλέον στην GC pipeline (το αντικείμενο μπαίνει στη finalization queue και συνήθως χρειάζεται δύο GC κύκλους για να απελευθερωθεί πλήρως) και ότι ενδέχεται να μην εκτελεστεί αν η διαδικασία τερματιστεί απότομα. Για αυτούς τους λόγους, η πρακτική σύσταση είναι, finalizers μόνο εάν η κλάση κατέχει μη-managed πόρους (π.χ. native handles) και δεν υπάρχει κάποιος άλλος ασφαλής τρόπος να απελευθερωθούν.

Το ισχυρό pattern είναι IDisposable με deterministic cleanup μέσω Dispose(), και finalizer μόνο ως safety net. Το κλασικό pattern (Disposable Pattern) έχει δύο μορφές, απλό όταν η κλάση έχει μόνο managed disposables, και πλήρες όταν υπάρχουν μη-managed πόροι και χρειάζεται finalizer. Στο πλήρες pattern ο Dispose() καλεί Dispose(true) και GC.SuppressFinalize(this), ενώ ο finalizer καλεί Dispose(false). Η χρήση using statement (ή using declaration) στον κώδικα που καταναλώνει το αντικείμενο εξασφαλίζει deterministic disposal.

Παράδειγμα πλήρους μοτίβου:

public class UnmanagedWrapper : IDisposable
{
    private IntPtr _nativeHandle;
    private bool _disposed;

    public UnmanagedWrapper()
    {
        _nativeHandle = /* allocate native resource */;
    }

    ~UnmanagedWrapper()
    {
        Dispose(false);
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;

        if (disposing)
        {
            // release managed resources here
        }

        // release unmanaged resources
        if (_nativeHandle != IntPtr.Zero)
        {
            /* free native handle */
            _nativeHandle = IntPtr.Zero;
        }

        _disposed = true;
    }
}
Enter fullscreen mode Exit fullscreen mode

Σημείωση για tuples: τα tuples (ειδικά ValueTuple) δεν κατέχουν συνήθως μη-managed πόρους, επομένως δεν έχουν finalizer/IDisposable. Αν ένα tuple περιέχει μέσα του ένα αντικείμενο που είναι IDisposable, τότε ο χρήστης του tuple πρέπει να διαχειριστεί το Dispose του εσωτερικού αντικειμένου — το tuple δεν το κάνει αυτό αυτόματα.


Constructors σε structs (value types) και default καταστάσεις

Τα structs στη C# έχουν διαφορετική συμπεριφορά: υπάρχει πάντα ένας implicit parameterless default constructor που ορίζει όλα τα πεδία σε zero/default(T). Μέχρι παλαιότερες εκδόσεις, ο developer δεν μπορούσε να ορίσει explicit parameterless constructor σε struct, αλλά σε νεώτερες εκδόσεις (C# 10/11 αλλαγές) υπάρχει περιορισμένη υποστήριξη. Σε κάθε περίπτωση, όταν καλείς new MyStruct() με parameterless new, καλείται ο compiler-generated default constructor που μηδενίζει τα πεδία. Όταν γράφεις explicit constructor σε struct με παραμέτρους, πρέπει να αρχικοποιείς όλα τα πεδία μέσα στον constructor πριν τα χρησιμοποιήσεις — ο compiler θα το απαιτήσει.

Παράδειγμα struct:

public struct PointStruct
{
    public int X { get; }
    public int Y { get; }

    public PointStruct(int x, int y)
    {
        X = x;
        Y = y;
    }
}

// Χρήση
var p1 = new PointStruct(1, 2); // καλός
var p2 = default(PointStruct); // X==0, Y==0
Enter fullscreen mode Exit fullscreen mode

Προσοχή: επειδή τα structs είναι value types, η αντιγραφή γίνεται κατά τιμή, και συνεπώς complex initialization patterns ή heavy resources μέσα σε struct μπορεί να είναι λάθος σχεδιασμός. Γενικά structs προορίζονται για μικρά, immutable value objects.


Καταλληλότητα constructors για dependency injection και SOLID αρχές

Στις αρχές SOLID, ο constructor έχει σημαντικό ρόλο, ιδίως στο Dependency Inversion Principle (DIP) και στο Single Responsibility Principle (SRP). Το constructor injection (παρέχοντας τις εξαρτήσεις ως παραμέτρους) είναι ο προτιμητέος τρόπος να εισάγουμε εξαρτήσεις, διότι καθιστά explicit τις dependencies, αυξάνει την testability και διευκολύνει την αντικατάσταση με mocks. Ο constructor πρέπει να αποθηκεύει τις εξαρτήσεις και να ελέγχει το null, αλλά να μην μετατραπεί σε orchestrator.

Αναλυτικά: το SRP υπαγορεύει ότι ο constructor έχει μία ευθύνη: την κατασκευή/αρχικοποίηση της στιγμής. Αν μέσα του ξεκινά σύνθετες business δραστηριότητες, τότε παραβιάζεται το SRP. Το DIP γίνεται πρακτικό με constructor injection: η κλάση δεν εξαρτάται από concrete υλοποιήσεις αλλά από interfaces που παρέχονται ως παραμέτρους.

Παράδειγμα DIP με constructor injection:

public interface ILogger { void Log(string message); }

public class Processor
{
    private readonly ILogger _logger;
    private readonly IRepository _repo;

    public Processor(ILogger logger, IRepository repo)
    {
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
        _repo = repo ?? throw new ArgumentNullException(nameof(repo));
    }

    public void Run()
    {
        _logger.Log("Starting");
        // χρήση _repo
    }
}
Enter fullscreen mode Exit fullscreen mode

Αυτό το pattern είναι απολύτως ευθυγραμμισμένο με SOLID: οι εξαρτήσεις ορίζονται ρητά, η κλάση είναι testable, και μπορούμε να χρησιμοποιήσουμε έναν DI container για τη σύνθεση.


Patterns και design alternatives: factories, builders, and InitializeAsync

Υπάρχουν περιπτώσεις όπου η χρήση πολλαπλών constructors ή η εισαγωγή εξαρτήσεων στον constructor δεν είναι αρκετή. Όταν η κατασκευή απαιτεί πολύπλοκη λογική, παραμετροποίηση πολλών πεδίων ή ασύγχρονη αρχικοποίηση, δύο patterns είναι ιδιαίτερα χρήσιμα: factory methods/objects και builder pattern. Τα factory methods κρύβουν τη σύνθετη λογική κατασκευής σε μία static μέθοδο ή σε έναν ξεχωριστό factory type, ενώ το builder επιτρέπει βηματική σύνθεση του αντικειμένου με fluent API. Για ασύγχρονη αρχικοποίηση, συνηθίζεται να επιστρέφεται Task από έναν asynchronous factory, π.χ. public static async Task CreateAsync(...).

Παράδειγμα async factory:

public class RemoteConfig
{
    private RemoteConfig() { }

    public static async Task<RemoteConfig> CreateAsync(string endpoint)
    {
        var data = await HttpClient.GetStringAsync(endpoint);
        // parse and build
        var config = new RemoteConfig();
        // initialize fields from data
        return config;
    }
}
Enter fullscreen mode Exit fullscreen mode

Αυτή η προσέγγιση κρατάει τον constructor ελαφρύ και απομονώνει το async I/O σε μία single responsibility factory.


Testing και constructors

Για unit testing, constructors που δέχονται dependencies ως παραμέτρους (constructor injection) είναι ο πιο φιλικός σχεδιαστικά τρόπος. Αν ο constructor δημιουργεί μέσα του εξαρτήσεις (π.χ. new HttpClient()), γίνεται δύσκολο να κάνεις mock ή stub τις εξαρτήσεις. Αν δυστυχώς έχεις legacy κώδικα με βαριά constructors, τεχνικές όπως dependency refactoring, χρήση abstraction wrappers ή test-specific factories μπορούν να διευκολύνουν μεταβατικά τη testability.

Επιπλέον, πρέπει να αποφευχθεί η τοποθέτηση λογικής που μπορεί να προκαλέσει checked exceptions ή εξωτερικά failures μέσα στον constructor, αυτό δυσκολεύει τη δημιουργία fixtures για tests και καθιστά τα unit tests ασταθή.


Πλήρες παράδειγμα που συνδυάζει constructor chaining, Deconstruct και IDisposable

Για να δείξουμε την πρακτική χρήση πολλών τεχνικών, ακολουθεί ένα σύνθετο παράδειγμα που συνδυάζει injection, chaining, deconstruction και disposable pattern:

public interface IDatabaseConnection : IDisposable
{
    void Open();
    // other members
}

public class DatabaseConnection : IDatabaseConnection
{
    private bool _opened;
    public DatabaseConnection(string connectionString) { /* store string */ }
    public void Open() { _opened = true; /* open connection */ }
    public void Dispose() { if (_opened) { /* close */ _opened = false; } }
}

public sealed class UserProfile : IDisposable
{
    public string Username { get; }
    public string Email { get; }
    private readonly IDatabaseConnection _connection;
    private bool _disposed;

    // canonical constructor - πλήρης injection
    public UserProfile(string username, string email, IDatabaseConnection connection)
    {
        Username = username ?? throw new ArgumentNullException(nameof(username));
        Email = email ?? throw new ArgumentNullException(nameof(email));
        _connection = connection ?? throw new ArgumentNullException(nameof(connection));
    }

    // συντομότερος overload - chaining προς canonical constructor
    public UserProfile(string username, string email)
        : this(username, email, new DatabaseConnection("default-connection-string"))
    {
        // Σημείωση: αυτός ο overload κάνει βαριά δουλειά (δημιουργεί σύνδεση). 
        // Προτιμήστε την injection παραπάνω για testability.
    }

    // Deconstruct για ergonomic usage
    public void Deconstruct(out string username, out string email)
    {
        username = Username;
        email = Email;
    }

    public void Initialize()
    {
        _connection.Open();
        // other initialization
    }

    public void Dispose()
    {
        if (_disposed) return;
        _connection.Dispose();
        _disposed = true;
    }
}
Enter fullscreen mode Exit fullscreen mode

Σε αυτό το παράδειγμα, ο canonical constructor ακολουθεί DIP και κάνει validation μόνο. Ο δεύτερος overload προσφέρει εύκολη χρήση αλλά θυσιάζει testability και μπορεί να παράγει side effects — αυτό πρέπει να τεκμηριώνεται και να αποφεύγεται στις περισσότερες παραγωγικές σχεδιάσεις.


Συχνά λάθη, παγίδες και προτεινόμενες πρακτικές

Πρώτον, είναι απαραίτητο να αντιληφθούμε τη σειρά εκτέλεσης σε κληρονομήσεις και να πειραματιστούμε με field initializers που αναφέρονται σε virtual members (που είναι επικίνδυνο, μην καλείτε virtual members μέσα στον constructor επειδή το παράγωγο μπορεί να μην έχει ολοκληρωθεί).

Δεύτερον, να είναι αντιληπτό γιατί constructor injection οδηγεί σε καλύτερο κώδικα (testability, explicitness) και πώς να εφαρμόζουμε DI containers στην πράξη.

Τρίτον, να γνωρίζουμε το Disposable pattern και ότι οι finalizers θεωρούνται «ακραίο μέτρο» και χρησιμοποιούνται μόνο για μη-managed πόρους ως safety net.

Τέταρτον, να γνωρίζουμε ότι οι factory methods ή builders είναι προτιμότεροι από μεγάλο αριθμό overloads, ειδικά όταν η λογική κατασκευής είναι σύνθετη ή ασύγχρονη.

Τέλος, να γνωρίζουμε το Deconstruct ως sugar για readability και πώς μπορεί να βελτιώσει την API ergonomics.


Να θυμάσαι..

Ο constructor είναι το θεμέλιο της έγκυρης κατάστασης ενός αντικειμένου. Χρησιμοποιούμε constructors για να εγγυηθούμε invariants, να δεσμεύσουμε εξαρτήσεις (constructor injection), και να παρέχουμε ergonomically σωστό API μέσω overloads και chaining. Ταυτόχρονα, πρέπει να αποφεύγουμε να βάζουμε βαριά, μη-deterministic ή I/O εργασίες μέσα σε constructors, προτιμώντας factories ή async initialization όταν χρειάζεται. Στο πλαίσιο των SOLID αρχών, ο constructor υποστηρίζει DIP και SRP όταν χρησιμοποιείται σωστά: δηλωτικές dependencies, ελάχιστη λογική, και σαφές contract. Το Deconstruct είναι εργαλείο για αποσύνθεση τιμών και όχι mechanism για resource management. Το IDisposable και ο finalizer είναι οι μηχανισμοί για διαχείριση πόρων, με το finalizer να είναι safety net για μη-managed πόρους και το Dispose τον τρόπο deterministic cleanup.


nikosstit

Top comments (0)