021-88881776

آموزش ساختارهای داده‌ای و شیءگرایی در Swift

آموزش Swift از پایه تا پیشرفته، یکی از مهم‌ترین موضوعات برای هر برنامه‌نویسی است که قصد دارد برنامه‌های iOS، macOS یا حتی سرور ساید (Server-Side) بنویسد. در این مقاله می‌خواهیم به طور جامع به مبحث ساختارهای داده‌ای و شیءگرایی در Swift بپردازیم و مفاهیم مربوط به داده‌ساختارها، کلاس‌ها، استراکچرها، وراثت و چندریختی را از سطح مبتدی تا پیشرفته بررسی کنیم. سعی می‌کنیم با ارائه مثال‌های ساده و شفاف، درک مطلب را برای خوانندگان آسان‌تر سازیم. همچنین در انتهای مقاله، منابع معتبر و مفیدی برای یادگیری بیشتر معرفی خواهیم کرد.

ساختارهای داده‌ای و شیءگرایی در Swift

پیش از آنکه وارد جزئیات بشویم، بهتر است به صورت کلی بدانیم که ساختارهای داده‌ای و شیءگرایی در Swift چه جایگاهی دارند. داده‌ساختارها (Data Structures) تعیین‌کننده نحوه ذخیره و سازمان‌دهی داده‌ها در حافظه هستند. از سوی دیگر، شیءگرایی (Object-Oriented Programming – OOP) رویکردی است که در آن، برنامه بر اساس اشیاء (Objects) و ارتباط بین آن‌ها شکل می‌گیرد.

در زبان Swift، شیءگرایی در کنار داده‌ساختارها نقش کلیدی ایفا می‌کند. Swift از کلاس‌ها (Classes) و استراکچرها (Structures) برای تعریف انواع سفارشی استفاده می‌کند و با بهره‌گیری از مفاهیم شیءگرا مانند وراثت (Inheritance)، چندریختی (Polymorphism) و کپسوله‌سازی (Encapsulation)، توسعه نرم‌افزار را ساخت‌یافته‌تر می‌سازد.

Enumerations (کلاس شمارشی)

تعریف enum با مقادیر مختلف

معرفی و کاربردهای اولیه

در زبان‌های برنامه‌نویسی مختلف، Enumeration یا به اختصار enum، ابزاری برای گروه‌بندی مقادیر مرتبط به یکدیگر در قالب یک نوع سفارشی است. در Swift نیز enumها می‌توانند بسیار قدرتمندتر از نمونه‌های مشابه در زبان‌های دیگر باشند؛ زیرا علاوه بر تعیین مقادیر ثابت، امکاناتی مانند داشتن مقادیر همراه (Associated Values)، مقادیر خام (Raw Values) و حتی متدها و خصوصیات (Properties) را در اختیار ما قرار می‌دهند.

به طور ساده، اگر بخواهید مجموعه‌ای از حالت‌های مختلف را در کد خود مشخص کنید که فقط و فقط همان حالت‌ها معتبر هستند، می‌توانید از enum استفاده کنید. در نتیجه، با محدود کردن مجموعه مقادیر ممکن، از ورود داده‌های نامعتبر جلوگیری می‌شود و کد شما خواناتر و مطمئن‌تر خواهد بود.

مثال کلاسیک این موضوع، تعریف چهار جهت اصلی است:

enum Direction {
    case north
    case south
    case east
    case west
}

در اینجا، نوع داده Direction فقط می‌تواند یکی از چهار مقدار north، south، east، west باشد و هیچ مقدار دیگری پذیرفته نیست.

تشریح مفهوم محدودکردن ورودی‌ها

یکی از مهم‌ترین مزایای استفاده از enum، محدود کردن ورودی‌ها است. فرض کنید در طراحی یک تابع، کاربر بایستی جهت حرکت یک شخصیت در بازی یا یک ربات را مشخص کند. اگر از رشته (String) استفاده کنید، ممکن است کاربر املای اشتباهی برای “north” بنویسد یا هر رشته دیگری را وارد کند که معتبر نیست. اما اگر پارامتر تابع از نوع Direction باشد، تنها می‌تواند یکی از همان مقدارهای تعریف‌شده را بگیرد. این ویژگی از خطاهای منطقی جلوگیری کرده و کد را ایمن‌تر می‌کند.

 استفاده از کلمه case و نحوه تعریف چند مقدار در یک خط

برای تعریف مقادیر مختلف در یک enum، از کلمه case استفاده می‌کنیم و هر مقدار در خط جداگانه یا در یک خط (با استفاده از ویرگول) تعریف می‌شود. شیوه زیر نیز معتبر است:

enum CompassDirection {
    case north, south, east, west
}

حتی می‌توانید mix کرده و بخشی را در یک خط و بخشی را در خطوط دیگر بیاورید، اما برای خوانایی کد بهتر است هر مقدار را در یک خط جداگانه قرار دهید.

مزیت اصلی enum نسبت به متغیرهای ثابت یا رشته

اگر برای تعیین جهت از رشته‌ها استفاده می‌کردید، باید در سرتاسر کد از مقادیر “north”, “south”, “east”, “west” استفاده می‌شد و در چندین نقطه باید مقادیر بررسی یا مقایسه می‌شد. در این شرایط، کوچک‌ترین اشتباه املایی یا تایپی می‌تواند باعث خطا در زمان اجرا شود. اما با enum، کامپایلر سوئیفت هنگام کامپایل بررسی می‌کند و هرگونه اشتباه در نام مقادیر را شناسایی خواهد کرد. در نتیجه:

ایمنی در زمان کامپایل (Compile-time Safety) بالا می‌رود.
خوانایی کد بیشتر می‌شود.
در تست و نگهداری پروژه‌های بزرگ، اشکال‌یابی (Debugging) آسان‌تر خواهد بود.

نمونه‌ای از کاربرد عملی

فرض کنید می‌خواهید تابعی بنویسید که با توجه به جهت حرکتی که کاربر وارد می‌کند، پیامی را چاپ کند یا عملیاتی انجام دهد:

func move(direction: Direction) {
    switch direction {
    case .north:
        print("روبه شمال حرکت می‌کنیم.")
    case .south:
        print("روبـه جنوب حرکت می‌کنیم.")
    case .east:
        print("روبـه شرق حرکت می‌کنیم.")
    case .west:
        print("روبـه غرب حرکت می‌کنیم.")
    }
}

در این مثال، مقدار ورودی تابع move حتماً باید یکی از حالت‌های موجود در Direction باشد و امکان ندارد با مقدار نادرست مواجه شویم. همچنین در دستور switch باید تمام حالت‌های enum را مدیریت کنیم. اگر حتی یکی از حالات مدیریت نشود، کامپایلر خطا می‌دهد و به ما یادآوری می‌کند که ممکن است مقدار خاصی را فراموش کرده باشیم. این موضوع باعث می‌شود هیچ حالت ناخواسته‌ای از قلم نیفتد.

Associated Values (مقادیر همراه)

 مفهوم مقادیر همراه

در برخی مواقع، نیاز داریم زمانی که یک مورد از enum را انتخاب می‌کنیم، بتوانیم مقداری اضافی یا اطلاعات تکمیلی را همراه آن نگهداری کنیم. اینجاست که مفهوم Associated Values (مقادیر همراه) اهمیت پیدا می‌کند. در واقع، هر مورد (case) از enum در Swift می‌تواند نوع داده‌ خاص خود را همراه داشته باشد؛ حتی می‌توان برای هر مورد نوع داده متفاوتی در نظر گرفت.

به عنوان مثال، فرض کنید می‌خواهیم پاسخ یک درخواست (Request) به سرور را مدل‌سازی کنیم. یکی از حالات ممکن پاسخ، موفقیت (success) است و حالت دیگر شکست (failure). در حالت موفقیت ممکن است نیاز به دریافت داده خاصی نداشته باشیم، اما در حالت شکست، کد خطا یا پیامی را به صورت عددی، متنی یا ساختاری همراه داشته باشیم.

enum ServerResponse {
    case success
    case failure(errorCode: Int)
}

در حالت success داده‌ای همراه نداریم.
در حالت failure می‌توانیم یک عدد صحیح (Int) ذخیره کنیم که بیانگر کد خطای سرور یا هر نوع خطای دیگر باشد.

نحوه استفاده از Associated Values

هنگام ساخت نمونه از این enum، می‌توانیم به شکل زیر عمل کنیم:

let response1 = ServerResponse.success
let response2 = ServerResponse.failure(errorCode: 404)

در مثال بالا:

response1 حالتی از نوع ServerResponse است که نشان می‌دهد پاسخ موفقیت‌آمیز بوده و مقدار اضافه‌ای ندارد.
response2 نشان می‌دهد پاسخ با خطای ۴۰۴ مواجه شده است و ما این عدد را به عنوان یک مقدار همراه در اختیار داریم.

دسترسی به مقادیر همراه

برای دسترسی به مقدار همراه، معمولاً از دستور switch استفاده می‌شود. این دستور به ما اجازه می‌دهد با الگوی تطابق (Pattern Matching) مقدار همراه را استخراج کنیم:

switch response2 {
case .success:
    print("عملیات با موفقیت انجام شد.")
case .failure(let errorCode):
    print("درخواست با خطا مواجه شد. کد خطا: \(errorCode)")
}

اینجا let errorCode به عنوان نام متغیری تعریف می‌شود که مقدار همراه case مربوطه را دریافت می‌کند.

کاربردهای متنوع Associated Values

مقادیر همراه می‌توانند انواع مختلفی داشته باشند. برای مثال در یک enum می‌توانیم مقادیر همراه متفاوتی برای هر case داشته باشیم:

enum Shape {
    case circle(radius: Double)
    case rectangle(width: Double, height: Double)
    case triangle(sideA: Double, sideB: Double, sideC: Double)
}

هر کدام از حالات (Case) بالا داده‌های متفاوتی را نگهداری می‌کنند و می‌توان با متدها یا switch به آن‌ها دسترسی داشت. در اصل می‌توانید انواع داده (int, string, array، حتی struct و class) را به عنوان مقادیر همراه در یک enum قرار دهید.

Raw Values (مقادیر خام)

 مفهوم مقادیر خام

برخلاف Associated Values که به هر مورد از enum اجازه می‌دهد داده‌ای منحصر به آن مورد را ذخیره کند، مقادیر خام (Raw Values) برای تمام موارد enum یک نوع داده یکسان و ثابت در نظر می‌گیرد. این مقادیر خام می‌توانند از نوع Int، String، Character یا انواع عددی دیگر مانند Double و Float باشند.

هنگامی که برای enum خود Raw Value تعیین می‌کنید، باید حتماً نوع (Type) آن را به Swift اعلام کنید. برای مثال:

enum Planet: Int {
    case mercury = 1
    case venus
    case earth
    case mars
}

در این مثال:

نوع خام (Raw Type) عدد صحیح (Int) انتخاب شده است.
مقدار ثابت برای mercury برابر ۱ تعیین شده است.
مقادیر بعدی (venus, earth, mars) به صورت خودکار از مقدار اولیه پیروی کرده و هرکدام به‌ترتیب ۲، ۳ و ۴ خواهند شد.

مقداردهی پیش‌فرض

اگر اولین مورد enum مقدار صریحی نداشته باشد، پیش‌فرض برای نوع عددی (Int) از ۰ شروع می‌شود و به‌ترتیب افزایش می‌یابد:

enum Colors: Int {
    case red    // مقدار خام: 0
    case blue   // مقدار خام: 1
    case green  // مقدار خام: 2
}

اگر نوع خام از جنس String باشد و مقداردهی صریح انجام ندهید، نام هر case به عنوان مقدار خام متناظر استفاده می‌شود:

enum CompassDirection: String {
    case north  // مقدار خام: "north"
    case south  // مقدار خام: "south"
    case east   // مقدار خام: "east"
    case west   // مقدار خام: "west"
}

دسترسی به مقدار خام و ایجاد نمونه جدید

برای دسترسی به مقدار خام هر مورد می‌توانید از خصوصیت rawValue استفاده کنید:

let earthCase = Planet.earth
print(earthCase.rawValue)  // خروجی: 3

همچنین برای ایجاد یک instance جدید بر اساس مقدار خام، می‌توانید از سازنده‌ی init?(rawValue:) استفاده کنید:

if let foundPlanet = Planet(rawValue: 3) {
    print("سیاره یافت شده: \(foundPlanet)")  // خروجی: سیاره یافت شده: earth
} else {
    print("مقدار خام نامعتبر است.")
}

توجه کنید که این سازنده یک مقدار اختیاری (Optional) برمی‌گرداند. اگر مقداری که شما وارد کنید در بازه‌ی valid مقادیر خام تعریف‌شده نباشد، مقدار nil دریافت خواهید کرد.

 فرق Raw Values با Associated Values

Raw Values برای تمام موارد enum یک نوع داده ثابت را تعیین می‌کنند و تمامی موارد همان نوع را خواهند داشت.
Associated Values می‌توانند برای هر مورد نوع داده متفاوت و اطلاعات مجزا ذخیره کنند. مثلاً یک case می‌تواند چند عدد صحیح نگه دارد و یک case دیگر رشته یا حتی آبجکت دیگری.

کاربرد رایج Raw Values

در بسیاری از سناریوها مانند کد وضعیت HTTP یا شماره‌گذاری آیتم‌ها که برای هر مقدار یک شناسنامه عددی (یا کاراکتری) ساده نیاز داریم، از Raw Value استفاده می‌شود. همچنین هنگام در تعامل با APIهای قدیمی یا C-based Enums که با اعداد یا رشته‌ها کار می‌کنند، Raw Values راهکاری مناسب برای تطبیق مقادیر در Swift با مقادیر خارجی هستند.

متدهای داخل enum در Swift

 لزوم تعریف متد در enum

در برنامه‌نویسی شیءگرا، تلاش می‌کنیم تا داده‌ها (data) و رفتارها (methods) در کنار هم قرار گیرند و یک مفهوم کامل را ارائه دهند. در بسیاری از زبان‌ها، enum صرفاً نقش فهرستی از مقادیر ثابت را ایفا می‌کند؛ اما در Swift می‌توانید منطق مرتبط با هر مقدار را نیز در همان enum نگه دارید. این کار خوانایی و نگهداری کد را بهبود می‌بخشد، چرا که در زمان مواجهه با enum، هم مقادیر ممکن آن را می‌بینید و هم متدهایی که با آن مقادیر در ارتباط هستند.

برای مثال، در enum زیر که وظیفه محاسبات ساده ریاضی را برعهده دارد، هر Case اطلاعات خود را نگه می‌دارد و یک متد مشترک (compute()) در همان enum تعریف شده است:

enum Calculator {
    case add(Int, Int)
    case multiply(Int, Int)

    func compute() -> Int {
        switch self {
        case .add(let x, let y):
            return x + y
        case .multiply(let x, let y):
            return x * y
        }
    }
}

در این مثال:

add(Int, Int) و multiply(Int, Int) هر دو از نوع Associated Values هستند؛ چرا که اطلاعات اضافی (دو عدد صحیح) را در خود نگه می‌دارند.
متد compute() برای تشخیص اینکه کدام Case فعلی است، از ساختار switch استفاده می‌کند و بر اساس آن عمل جمع یا ضرب را انجام می‌دهد.
در نهایت، یک مقدار صحیح (Int) برمی‌گرداند که نتیجه عملیات محاسباتی است.

امکان تعریف متدهای دیگر (از جمله Static و Computed Properties)

علاوه بر متدهایی که بر اساس مقدار جاری enum عمل می‌کنند، می‌توان درون enum‌ها متدهای ثابت (Static Methods) یا خصوصیات محاسباتی (Computed Properties) نیز تعریف کرد. برای نمونه، اگر بخواهید یک متد یا خصوصیت سطح نوع (Type Level) داشته باشید که روی تمام حالات enum اعمال شود، می‌توانید از static استفاده کنید:

enum Weekday {
    case monday, tuesday, wednesday, thursday, friday, saturday, sunday
    
    func isWeekend() -> Bool {
        return self == .friday || self == .saturday
    }
    
    static var allDays: [Weekday] {
        return [.monday, .tuesday, .wednesday, .thursday, .friday, .saturday, .sunday]
    }
}

متد نمونه isWeekend() مشخص می‌کند آیا روز فعلی آخر هفته است یا خیر.
خصوصیت نوعی allDays فهرست تمام روزهای هفته را در قالب یک آرایه بازمی‌گرداند.

مزایای تعریف متد در enum

کاهش پراکندگی منطق: دیگر نیاز نیست برای هر حالت، در کلاس‌ها یا تابع‌های جداگانه منطق مربوط را بنویسید.
افزایش خوانایی: زمانی که شخصی کد شما را می‌خواند، تمام رفتارهای مربوط به هر Case (همراه با داده‌هایش) در یکجا قابل مشاهده است.
کپسوله‌سازی (Encapsulation) بهتر: enum و متدهایش به شکل هماهنگ کار می‌کنند و امکان خطا نیز کاهش می‌یابد.

استفاده از enum در دستور switch

کارکرد اصلی دستور switch

دستور switch در Swift به ما اجازه می‌دهد مقادیر مختلف را بررسی و منطبق (Match) کنیم. هنگامی که از enum استفاده می‌کنیم، این دستور یکی از مهم‌ترین ابزارها برای کنترل جریان برنامه بر اساس حالت (Case) جاری است. مزیت عمده این است که کامپایلر Swift اطمینان حاصل می‌کند که تمام حالت‌های enum در switch مدیریت (handle) شده‌اند. اگر حتی یک حالت جا بیفتد، خطای کامپایل دریافت می‌کنیم.

 نمونه کد با Direction

بیایید مثالی از یک enum با چهار جهت اصلی را بررسی کنیم. این بار کمی بیشتر درباره‌ی استفاده از switch و همچنین ترکیب آن با متدهای enum صحبت می‌کنیم:

enum Direction {
    case north
    case south
    case east
    case west
    
    // مثال: متد محاسبه حرکت فرضی
    func moveStep(from position: (x: Int, y: Int)) -> (x: Int, y: Int) {
        switch self {
        case .north:
            return (position.x, position.y + 1)
        case .south:
            return (position.x, position.y - 1)
        case .east:
            return (position.x + 1, position.y)
        case .west:
            return (position.x - 1, position.y)
        }
    }
}

// نمونه‌سازی و استفاده در switch
let direction: Direction = .north

switch direction {
case .north:
    print("جهت شمال است.")
case .south:
    print("جهت جنوب است.")
case .east:
    print("جهت شرق است.")
case .west:
    print("جهت غرب است.")
}

ما متدی به نام moveStep(from:) در enum تعریف کرده‌ایم که با توجه به Case انتخابی، قدمی در آن جهت برمی‌دارد.
در دستور switch, تمامی حالات Direction مورد بررسی قرار می‌گیرند.

چرا switch برای enum در Swift ایمن‌تر است؟

عدم فراموشی کیس‌ها: در سایر زبان‌ها، ممکن است هنگام استفاده از switch برخی حالت‌ها را فراموش کنید و در زمان اجرا به مشکل بر بخورید. در Swift، اگر تمام حالت‌های enum را پوشش ندهید، کامپایلر این موضوع را به صورت خطا یا هشدار اعلام می‌کند.
ایمنی زمان کامپایل (Compile-time Safety): هر تغییری در enum (مثلاً اضافه کردن یک حالت جدید) شما را مجبور می‌کند محل‌هایی که از آن enum در switch استفاده شده را بررسی و تکمیل کنید. این امر، احتمال خطاهای ناگهانی را به شدت کاهش می‌دهد.

ساختارها و کلاس‌ها (Structures and Classes)

تفاوت کلاس (Reference Type) و استراکچر (Value Type)

 استراکچرها (Structures): مقدارمحور (Value Type)

وقتی از استراکچر استفاده می‌کنیم، هر گاه یک نمونه (Instance) را به متغیر یا ثابت دیگری منتسب کنیم یا آن را به تابعی ارسال کنیم، یک نسخه‌ی مستقل (کپی) از داده‌ها ساخته می‌شود.
این مستقل بودن باعث می‌شود تغییر در یک نسخه، بر نسخه‌ی دیگر اثر نگذارد.
این موضوع همچنین به کنترل بهتر در برنامه‌های چندنخی (Thread-Safe) کمک می‌کند، چون هر متغیر یا ثابت، داده‌ی مختص به خود را دارد و نگرانی از بابت همگام‌سازی (Synchronization) یا اثرگذاری روی سایر نسخه‌ها وجود ندارد، مگر اینکه از طریق روش‌های دیگر (مثلاً اشاره به کلاس‌های داخلی یا متغیرهای سراسری) داده به اشتراک گذاشته شود.
نمونه کد استراکچر و کپی مستقل:

struct Book {
    var title: String
}

var bookA = Book(title: "Swift Programming")
var bookB = bookA  // یک کپی جدید از bookA
bookB.title = "iOS Development"

print(bookA.title) // "Swift Programming"
print(bookB.title) // "iOS Development"

در این مثال، bookA و bookB دو متغیر کاملاً مستقل هستند. تغییر در bookB روی bookA بی‌تأثیر است.

مزایا و موارد استفاده از استراکچر
ساده و سبک بودن: برای مدل‌سازی داده‌هایی که نمایانگر یک مقدار هستند (مثل مختصات یک نقطه، ابعاد یک شکل هندسی)، استراکچر بسیار مناسب است.
نداشتن سربار مدیریت حافظه ارجاعی: به‌دلیل عدم نیاز به شمارش ارجاع (Reference Counting)، استراکچرها معمولاً کارایی بالاتری دارند.
Thread-Safety ذاتی: چون هر بار کپی ساخته می‌شود، هم‌زمانی روی یک داده واحد کمتر مشکل‌ساز می‌شود.

 کلاس‌ها (Classes): ارجاع‌محور (Reference Type)

بر خلاف استراکچر، کلاس‌ها ارجاع‌محور هستند. این یعنی اگر یک نمونه از کلاس را به متغیر دیگری تخصیص بدهیم، هر دو متغیر به یک شیء مشترک در حافظه اشاره می‌کنند.
در نتیجه، تغییر در یکی از متغیرها بر دیگری نیز اثر خواهد گذاشت، چرا که مرجع (Reference) یکی است.
نمونه کد کلاس و رفتار ارجاع‌محور:

class User {
    var username: String
    init(username: String) {
        self.username = username
    }
}

var userA = User(username: "Alice")
var userB = userA  // userB به همان شیء userA اشاره می‌کند
userB.username = "Bob"

print(userA.username) // "Bob"
print(userB.username) // "Bob"

 

هر دو متغیر userA و userB به یک مکان از حافظه اشاره می‌کنند و با تغییر در یکی، دیگری نیز تغییر می‌کند.

مزایا و موارد استفاده از کلاس

وراثت (Inheritance): کلاس‌ها در Swift قابلیت ارث‌بری دارند، در حالی که استراکچرها این ویژگی را ندارند. اگر به طراحی شیءگرایی با زیرکلاس‌ها و والدها نیاز دارید، کلاس‌ها ضروری‌اند.
اشتراک داده‌ها: در شرایطی که نیاز دارید چندین بخش مختلف از برنامه روی یک شیء مشترک کار کنند و تغییرات همگی قابل رویت باشد، کلاس‌ها انتخاب مناسبی هستند.
چندریختی (Polymorphism): هنگامی که کلاس والد یک متد دارد و کلاس فرزند آن را Override می‌کند، از مفاهیم چندریختی استفاده می‌کنید؛ این قابلیت در استراکچر وجود ندارد.

 تعریف Property و Method در کلاس و استراکچر

هر دو ساختار زبان Swift (کلاس‌ها و استراکچرها) می‌توانند Property (خصوصیت) و Method (متد) داشته باشند. این موضوع آن‌ها را در بحث ساختارهای داده‌ای و شیءگرایی در Swift بسیار قدرتمند می‌سازد، به طوری که می‌توانند مشابه هم رفتار کنند، مگر در مواردی مثل وراثت یا رفتار Value/Reference که پیش‌تر ذکر شد.

تعریف Property

خصوصیات می‌توانند ذخیره‌شده (Stored Property) یا محاسباتی (Computed Property) باشند. برای مثال:

خصوصیت ذخیره‌شده: داده را مستقیماً در حافظه ذخیره می‌کند (مثلاً var width: Double).
خصوصیت محاسباتی: مقدار را بر اساس یک یا چند خصوصیت دیگر محاسبه و برمی‌گرداند (مثلاً محاسبه محیط یا مساحت یک شکل).

استراکچر نمونه:

struct Rectangle {
    var width: Double
    var height: Double
    
    // متد محاسبه مساحت
    func area() -> Double {
        return width * height
    }
}

در این مثال:

width و height خصوصیاتی ذخیره‌شده هستند.
area() یک متد نمونه‌ای (Instance Method) است که خروجی آن نتیجه‌ی یک محاسبه ساده است.
کلاس نمونه:

class Person {
    var name: String = ""
    var age: Int = 0
    
    func introduce() {
        print("سلام، من \(name) هستم و \(age) سال سن دارم.")
    }
}

name و age خصوصیاتی ذخیره‌شده‌اند.
introduce() متدی است که عمل معرفی کردن شخص را انجام می‌دهد.

تعریف Method

متدها در هر دو نوع ساختار می‌توانند متد نمونه (Instance Method) یا متد نوع (Type Method) باشند. یک فرق مهم در استراکچر این است که اگر متدی بخواهد خصوصیات ذخیره‌شده (self) را تغییر دهد، باید با واژه mutating علامت‌گذاری شود:

struct Counter {
    var count = 0
    
    mutating func increment() {
        count += 1
    }
}

در کلاس‌ها به دلیل ارجاع‌محور بودن، نیازی به mutating نیست. زیرا کلاس همواره با مرجع کار می‌کند و تغییر در خصوصیات شیء مجاز است.

 چه زمانی از کلاس استفاده کنیم و چه زمانی از استراکچر؟

نیاز به وراثت دارید؟

اگر بله: کلاس.
اگر خیر: استراکچر را در اولویت قرار دهید.
منظورتان تعریف یک مقدار یا مدل ساده است؟

اگر داده قرار است شبیه یک مدل مستقل باشد (مثل طول، عرض یا مختصات نقطه)، بهتر است از استراکچر استفاده کنید.
اشتراک‌گذاری داده بین چندین بخش از برنامه

اگر مایلید تغییر در یک قسمت، در جای دیگر نیز قابل مشاهده باشد، کلاس گزینه بهتری است چون ارجاع‌محور است.
کارایی و سادگی مدیریت

استراکچرها عموماً کارایی بالاتری دارند (به ویژه در انواع ساده)، و مدیریت آن‌ها با استفاده از semantics مقدارمحور، ساده‌تر و کم‌خطرتر است.

نکات تکمیلی

تفاوت اصلی: استراکچرها (Value Type) داده را کپی می‌کنند؛ کلاس‌ها (Reference Type) ارجاع را کپی می‌کنند.
وراثت: مختص کلاس‌ها است. استراکچرها از وراثت بی‌بهره‌اند، ولی می‌توانند از پروتکل‌ها (Protocols) تبعیت کنند.
کپسوله‌سازی: هردو ساختار اجازه می‌دهند متدها و خصوصیات مربوطه را در قالب یک داده واحد نگه دارید؛ این مسأله یکی از ستون‌های اصلی شیءگرایی در Swift است.
در اکثر سناریوهای معمول و برای مدل‌های ساده، استفاده از استراکچر پیشنهاد می‌شود؛ مگر اینکه واقعاً نیازمند ویژگی‌های کلاس باشید. این روش نه‌تنها به افزایش سازگاری با نوشتار توصیه‌شده‌ی Swift کمک می‌کند، بلکه منجر به کدهای پایدارتر و کارایی بیشتر می‌شود.
با دانستن این تفاوت‌ها و توانایی‌ها، می‌توانید در طراحی نرم‌افزار خود در Swift بهترین تصمیم را بگیرید؛ ساختارهای داده‌ای و شیءگرایی در Swift بسیار منعطف هستند و انتخاب درست بین کلاس و استراکچر، کلیدی است برای نوشتن کدی بهینه، قابل نگهداری و منطبق بر استانداردهای زبان.

سازنده‌ها (Initializers) در کلاس و استراکچر

 سازنده در استراکچر (Struct)

سازنده پیش‌فرض (Memberwise)

اگر تمام خصوصیات (Properties) در یک استراکچر مقدار پیش‌فرض یا Optional داشته باشند، کامپایلر به صورت خودکار سازنده‌ای موسوم به Memberwise Initializer تولید می‌کند.
به عنوان مثال، استراکچر زیر دارای خصوصیات با مقدار پیش‌فرض است، بنابراین بدون نیاز به تعریف سازنده اختصاصی، می‌توان نمونه‌سازی کرد:

struct Person {
    var name: String = "Unknown"
    var age: Int = 0
}

let p = Person(name: "Ali", age: 25) // استفاده از Memberwise Initializer
let q = Person() // استفاده از مقادیر پیش‌فرض

البته اگر می‌خواهید کنترل بیشتری روی مقداردهی اولیه داشته باشید (مثلاً بررسی شرایط خاص)، باید سازنده سفارشی بنویسید.

تعریف سازنده سفارشی

زمانی که تمام یا بعضی از خصوصیات، مقدار پیش‌فرض ندارند، یا نیاز دارید عملیات خاصی در ابتدای ساخت نمونه انجام شود، یک سازنده سفارشی تعریف می‌کنید:

struct Point {
    var x: Int
    var y: Int
    
    // سازنده سفارشی
    init(x: Int, y: Int) {
        self.x = x
        self.y = y
    }
}

در این حالت، اگر سازنده سفارشی را تعریف کردید، دیگر Memberwise Initializer پیش‌فرض تولید نخواهد شد (مگر اینکه آن را به طور هم‌زمان بخواهید با extension‌ها و غیره مدیریت کنید).

سازنده‌های چندگانه

می‌توانید بیش از یک سازنده داشته باشید؛ مثلاً یکی با دو پارامتر و دیگری با یک پارامتر پیش‌فرض:

struct Size {
    var width: Double
    var height: Double
    
    init() {
        self.width = 0
        self.height = 0
    }
    
    init(width: Double, height: Double) {
        self.width = width
        self.height = height
    }
}

Failable Initializers

در صورت نیاز، می‌توانید از سازنده‌ای استفاده کنید که ممکن است nil برگرداند (با init?)؛ مثلاً وقتی مقدار دریافت‌شده نامعتبر باشد. این حالت در استراکچرها نیز قابل پیاده‌سازی است:

struct Temperature {
    var celsius: Double
    
    init?(celsius: Double) {
        guard celsius >= -273.15 else {
            return nil
        }
        self.celsius = celsius
    }
}

سازنده در کلاس (Class)

سازنده‌های سفارشی

در کلاس نیز می‌توانید سازنده‌های دلخواه تعریف کنید. تفاوت مهم در اینجاست که کلاس‌ها می‌توانند وراثت داشته باشند و این موضوع بر روند سازنده‌ها (Initializers) تأثیر می‌گذارد:

class Car {
    var model: String
    var year: Int
    
    init(model: String, year: Int) {
        self.model = model
        self.year = year
    }
}

سازنده‌های موروثی (Inherited Initializers)

اگر کلاسی از کلاس دیگری ارث ببرد (مثلاً class SportsCar: Car)، بسته به شرایط خاص و این‌که آیا سازنده‌های والد به صورت convenience یا designated تعریف شده باشند، این سازنده‌ها ممکن است به کلاس فرزند به ارث برسند یا مجبور به Override یا تعریف یک سازنده جدید شوید.
این موضوع زمانی اهمیت پیدا می‌کند که کلاس والد چند سازنده مختلف داشته باشد و کلاس فرزند برای تکمیل پروسه مقداردهی، نیاز به اجرای یکی از آن‌ها (با super.init) داشته باشد.

سازنده راحتی (Convenience Initializers)

در کلاس‌ها می‌توانید سازنده‌های موسوم به convenience init تعریف کنید که طراحی شده‌اند تا عملیات مقداردهی ساده‌تری انجام دهند یا پارامترهای کمتری داشته باشند و در نهایت، یک سازنده اصلی‌تر (designated init) را صدا بزنند.
این سازنده‌ها در استراکچر معنایی ندارند؛ چرا که ساختار وراثت در استراکچر وجود ندارد.

Failable و Deinitializers

مانند استراکچر، در کلاس نیز می‌توان سازنده‌های شکست‌پذیر (init?) تعریف کرد.
علاوه بر آن، کلاس‌ها متدی موسوم به deinit دارند که در زمان آزاد شدن شیء از حافظه (وقتی شمارنده ارجاع به صفر برسد) فراخوانی می‌شود. استراکچرها deinit ندارند، چون Value Type هستند و به شمارش ارجاع نیاز ندارند.

مقایسه‌ی نمونه‌ها (Instances)

مقایسه استراکچرها (Value Type)

استراکچرها بر پایه مقدارمحوری بنا شده‌اند؛ بنابراین برای بررسی برابری دو نمونه از نوع استراکچر، معمولاً باید مقدار تمام خصوصیاتشان یکسان باشد.

اگر می‌خواهید بتوانید از عملگر == استفاده کنید و Swift به شما اجازه دهد دو نمونه را مقایسه کنید، باید استراکچر خود را به پروتکل Equatable متعهد کنید. مثلاً:

struct Point: Equatable {
    var x: Int
    var y: Int
}

let p1 = Point(x: 10, y: 20)
let p2 = Point(x: 10, y: 20)
print(p1 == p2) // true

در حالت پیش‌فرض (اگر Equatable را پیاده‌سازی نکرده باشید)، امکان مقایسه مستقیم با == وجود ندارد؛ مگر برای استراکچرهای ساده‌ای که همه خصوصیاتشان خودبه‌خود Equatable هستند و Swift بتواند کد مقایسه را به‌صورت خودکار تولید کند (این ویژگی در ورژن‌های جدیدتر Swift مهیا است اما گاهی شرایط خاص دارد).

 مقایسه کلاس‌ها (Reference Type)

برای کلاس‌ها، به طور پیش‌فرض مقایسه‌ی ارجاعی (Reference) اتفاق می‌افتد. یعنی دو آبجکت کلاس تنها زمانی برابر در نظر گرفته می‌شوند که به همان آدرس در حافظه اشاره کنند. این مقایسه با عملگر سه‌تایی (=== و !==) انجام می‌گیرد:

let userA = User(username: "Ali")
let userB = userA
let userC = User(username: "Ali")

print(userA === userB) // true
print(userA === userC) // false

اگر بخواهید کلاس‌ها را از لحاظ مقداری (Value) مقایسه کنید (مثلاً دو آبجکت از کلاس User را بر اساس مقادیر داخلی آن بسنجید)، باید پروتکل Equatable را پیاده‌سازی کرده و عملگر == را خودتان تعریف کنید:

class User: Equatable {
    static func == (lhs: User, rhs: User) -> Bool {
        return lhs.username == rhs.username
    }
    
    var username: String
    init(username: String) {
        self.username = username
    }
}

let userX = User(username: "Ali")
let userY = User(username: "Ali")

print(userX == userY) // true (بر اساس مقدار username)
print(userX === userY) // false (مرجع در حافظه متفاوت است)

 

نکته تکمیلی: Reference Equality vs. Value Equality

مقایسه در استراکچرها عملاً همیشه مقایسه‌ی مقداری است (Value Equality).
در کلاس‌ها، در صورت پیاده‌سازی Equatable می‌توانید مقایسه‌ی مقداری داشته باشید، در غیر این صورت با === و !== تنها مقایسه‌ی ارجاعی میسر است.

خصوصیات (Properties)

خصوصیات ذخیره‌شده (Stored Properties)

 تعریف و مفهوم

خصوصیات ذخیره‌شده (Stored Properties) در واقع متغیرها (var) یا ثابت‌ها (let)ی هستند که در بدنه یک کلاس یا استراکچر تعریف می‌شوند.
وقتی یک نمونه (Instance) از آن کلاس یا استراکچر ایجاد می‌کنیم، فضای حافظه‌ای برای هر Stored Property رزرو می‌شود. بنابراین مقادیر این خصوصیات در هر نمونه، منحصربه‌فرد بوده و تغییر در یک نمونه روی نمونه دیگر تأثیری ندارد (در استراکچر به دلیل مقدارمحور بودن، و در کلاس به دلیل داشتن کپی جداگانه از مقادیر مربوط به خصوصیات).
مثال ساده در استراکچر:

struct Person {
    let name: String    // Stored Property (ثابت)
    var age: Int        // Stored Property (متغیر)
}

name: یک خصوصیت ثابت (let) است که نمی‌توان پس از ساخت نمونه تغییرش داد.
age: یک خصوصیت متغیر (var) است و بعد از ساخت نمونه، امکان تغییر مقدار آن وجود دارد.
مثال در کلاس:

class Car {
    var model: String  // Stored Property (متغیر)
    var year: Int      // Stored Property (متغیر)
    
    init(model: String, year: Int) {
        self.model = model
        self.year = year
    }
}

در این مثال، model و year دو خصوصیت ذخیره‌شده هستند که هنگام ساخت نمونه از Car مقداردهی می‌شوند.

 مقدار پیش‌فرض (Default Value)

برای خصوصیات ذخیره‌شده می‌توانید مقدار پیش‌فرض نیز تعیین کنید:

struct Rectangle {
    var width: Double = 0
    var height: Double = 0
}

در این حالت، اگر در زمان نمونه‌سازی مقداری را صراحتاً تعیین نکنید، از مقادیر پیش‌فرض (۰ و ۰) استفاده می‌شود.

خصوصیت ذخیره‌شده در استراکچر در مقابل کلاس

در استراکچر، از آنجا که Value Type است، کپی و انتساب این خصوصیات باعث ایجاد نسخه جداگانه از داده‌ها می‌شود.
در کلاس (Reference Type)، انتساب به متغیر دیگر یا ارسال به تابع، یک مرجع (Reference) مشترک را منتقل می‌کند؛ اما همچنان خود خصوصیت ذخیره‌شده متعلق به نمونه است.

Lazy Stored Property

در کنار خصوصیات ذخیره‌شده عادی، Swift قابلیت دیگری به نام Lazy Stored Property فراهم کرده است:

struct DataLoader {
    lazy var data: [String] = {
        // عملیات طولانی (مثلاً بارگذاری از شبکه)
        return ["Item1", "Item2", "Item3"]
    }()
}

کلیدواژه lazy باعث می‌شود مقداردهی این خصوصیت تا زمان اولین دسترسی به آن به تأخیر بیفتد.
استفاده از lazy زمانی مفید است که محاسبه یا مقداردهی یک خصوصیت پرهزینه باشد و نخواهید در لحظه ساخت نمونه، آن هزینه متحمل شود.
توجه: خصوصیت‌های Lazy فقط می‌توانند در متغیرها (var) تعریف شوند، نه ثابت‌ها (let)، چون مقداردهی آن‌ها در زمان ساخت نمونه انجام نمی‌شود.

 خصوصیات محاسباتی (Computed Properties)

 تعریف و مفهوم

خصوصیات محاسباتی (Computed Properties) برخلاف خصوصیات ذخیره‌شده، مستقیماً مقداری را در حافظه ذخیره نمی‌کنند. در عوض، مقدار آن‌ها در هر بار فراخوانی از طریق محاسبه (فراخوانی تابع، عملیات ریاضی، دسترسی به سایر خصوصیات یا ساختارهای داده) به دست می‌آید و برگردانده می‌شود.
می‌توانید این خصوصیات را خواندنی یا خواندنی/نوشتنی تعریف کنید. اگر می‌خواهید قابلیت تغییر مقدار داشته باشید، باید بلوک set را نیز پیاده‌سازی کنید.
مثال ساده:

struct Circle {
    var radius: Double
    
    var diameter: Double {
        get {
            return radius * 2
        }
        set {
            radius = newValue / 2
        }
    }
}

getter: هنگام دسترسی به circle.diameter، مقدار (radius * 2) محاسبه و برگردانده می‌شود.
setter: اگر circle.diameter = 10 را فراخوانی کنید، مقدار radius را برابر با 10 / 2 می‌گذارد؛ یعنی ۵.
بنابراین خصوصیت diameter مقداری در حافظه نگه نمی‌دارد، بلکه محاسبه می‌کند و سپس نتیجه را بازمی‌گرداند.

خصوصیت محاسباتی فقط خواندنی

اگر نیازی به set نداشته باشید، می‌توانید یک خصوصیت محاسباتی را صرفاً خواندنی (Read-Only) تعریف کنید. در این صورت می‌توانید حتی get را حذف و بدنه را به شکل مختصر بنویسید:

struct Square {
    var side: Double
    
    var area: Double {
        return side * side
    }
}

این خصوصیت فقط خواندنی است و نمی‌توان از آن برای تغییر مقدار area استفاده کرد (شبیه تابعی که خروجی آن صرفاً محاسبه شده است).

 ترکیب با دیگر خصوصیات

یک خصوصیت محاسباتی می‌تواند از چندین خصوصیت ذخیره‌شده در همان کلاس/استراکچر استفاده کرده و نتیجه دلخواه را محاسبه کند. برای مثال، در مدل سه‌بعدی می‌توان طول، عرض و ارتفاع (Stored Properties) داشته باشیم و حجم (Volume) را به عنوان یک خصوصیت محاسباتی ارائه دهیم:

struct Box {
    var length: Double
    var width: Double
    var height: Double
    
    var volume: Double {
        return length * width * height
    }
}

تفاوت کلی Stored و Computed Properties

ذخیره‌شده (Stored):

یک مقدار واقعی در حافظه نگه می‌دارد.
با هر نمونه (Instance) از نوع، یک فضای حافظه برای آن خصوصیت رزرو می‌شود.
در صورت استفاده از let، خصوصیت به شکل ثابت (Immutable) خواهد بود و در صورت استفاده از var، متغیر (Mutable) می‌ماند.

محاسباتی (Computed):

مستقیماً مقداری در حافظه ذخیره نمی‌کند.
در هر بار فراخوانی، مقداری را محاسبه و تحویل می‌دهد (از طریق بلاک get).
می‌تواند از سایر خصوصیات ذخیره‌شده برای محاسبه مقدار استفاده کند.
اگر بخواهید مقداردهی از بیرون داشته باشید، باید بلاک set را نیز پیاده‌سازی کنید.

مثال ترکیبی

برای نشان دادن ترکیب این دو نوع خصوصیت در کنار هم، مثال زیر را در نظر بگیرید:

struct Employee {
    let firstName: String       // Stored
    let lastName: String        // Stored
    var monthlySalary: Double   // Stored
    
    // Computed
    var yearlySalary: Double {
        get {
            return monthlySalary * 12
        }
        set {
            // فرض می‌کنیم کاربر می‌خواهد سالیانه را تنظیم کند، ما آن را به ۱۲ تقسیم می‌کنیم
            monthlySalary = newValue / 12
        }
    }
    
    // Computed - Read-Only
    var fullName: String {
        return "\(firstName) \(lastName)"
    }
}

var emp = Employee(firstName: "Ali", lastName: "Rezaei", monthlySalary: 2000)
print(emp.yearlySalary)   // خروجی: 24000
emp.yearlySalary = 36000  // حالا monthlySalary می‌شود 3000
print(emp.monthlySalary)  // خروجی: 3000
print(emp.fullName)       // خروجی: "Ali Rezaei"

 

var emp = Employee(firstName: “Ali”, lastName: “Rezaei”, monthlySalary: 2000)
print(emp.yearlySalary) // خروجی: 24000
emp.yearlySalary = 36000 // حالا monthlySalary می‌شود 3000
print(emp.monthlySalary) // خروجی: 3000
print(emp.fullName) // خروجی: “Ali Rezaei”
firstName, lastName و monthlySalary خصوصیات ذخیره‌شده‌اند.
yearlySalary خصوصیت محاسباتی است که هم get دارد و هم set.
fullName یک خصوصیت محاسباتی صرفاً خواندنی است.

خصوصیات ذخیره‌شده (Stored Properties): داده را در حافظه نگه می‌دارند و در زمان ساخت هر نمونه به آن‌ها فضا اختصاص داده می‌شود. می‌توانند مقدار پیش‌فرض داشته باشند، let (ثابت) یا var (متغیر) باشند، و حتی به صورت lazy تعریف شوند.
خصوصیات محاسباتی (Computed Properties): هیچ داده‌ای را مستقیماً ذخیره نمی‌کنند، بلکه بر اساس منطق یا فرمول خاصی هربار مقدارشان را محاسبه می‌کنند. می‌توانند فقط خواندنی یا خواندنی/نوشتنی باشند.
شناخت تفاوت و نحوه‌ی به‌کارگیری Stored Properties و Computed Properties بسیار مهم است؛ زیرا در طراحی کلاس‌ها و استراکچرها، انتخاب درست بین این دو نوع خصوصیت منجر به کد تمیزتر، بهینه‌تر و قابل فهم‌تری می‌شود. همچنین، این ویژگی‌ها بخشی مهم از ساختارهای داده‌ای و شیءگرایی در Swift بوده و در پروژه‌های متوسط تا بزرگ، نقش حیاتی در نگهداری و توسعه‌ی بهتر کد ایفا می‌کنند.

خصوصیات نوع (Type Properties)

 تعریف و مفهوم

Type Properties برای ذخیره یا محاسبه داده‌هایی استفاده می‌شوند که مخصوص کل نوع (Class یا Structure یا Enumeration) هستند، نه یک نمونه خاص از آن.
بر خلاف خصوصیات نمونه، که برای هر شیء (Object) یا نمونه (Instance) تعریف می‌شوند، خصوصیات نوع فقط یک بار در حافظه رزرو شده و توسط همه نمونه‌های آن نوع مشترک هستند (یا حتی بدون نیاز به نمونه‌سازی قابل استفاده‌اند).
مثال پایه:

struct MathConstants {
    static let pi = 3.14159
}

در اینجا، pi یک خصوصیت نوعی (Stored Type Property) است و می‌توان آن را با MathConstants.pi فراخوانی کرد، بدون اینکه نیازی به ساخت شیءای از MathConstants داشته باشیم.

 شیوه تعریف در استراکچر و کلاس

برای استراکچر و enum، از کلیدواژه static برای تعریف یک Type Property استفاده می‌شود:

enum Weekdays {
    static let daysCount = 7
}

در کلاس، علاوه بر static می‌توانید از class نیز استفاده کنید. تفاوت اساسی این است که اگر با class تعریف شود، می‌توان در زیرکلاس‌ها آن را Override کرد؛ اما static چنین امکانی را نمی‌دهد:

class Animal {
    class var speciesName: String {
        return "Unknown Species"
    }
}

class Dog: Animal {
    override class var speciesName: String {
        return "Canine"
    }
}

نوع ذخیره‌شده (Stored) و نوع محاسباتی (Computed)

مانند خصوصیات نمونه، خصوصیات نوع نیز می‌توانند ذخیره‌شده یا محاسباتی باشند:

Stored Type Property: یک مقدار ثابت یا متغیر است که در سطح نوع ذخیره می‌شود. به عنوان مثال، تعداد حداکثر اتصالات مجاز به یک سرور، یا مقادیر کنستانت ریاضی.

Computed Type Property: مقداری را بر اساس محاسبه برمی‌گرداند و داده‌ای را در حافظه نگه نمی‌دارد. به عنوان مثال، می‌توانید یک آرایه‌ای از موارد enum را به صورت محاسباتی برگردانید:

enum Direction: CaseIterable {
    case north, south, east, west

    static var allDirections: [Direction] {
        return Direction.allCases.map { $0 }
    }
}

کاربردهای رایج

نگهداری از مقادیر ثابتی که جنبه‌ی جهانی در آن نوع دارند (نظیر مقادیر ریاضی مثل pi).
ایجاد Counting یا Tracking برای تعداد نمونه‌های ایجاد شده از یک کلاس یا استراکچر.
بازگرداندن داده‌های ثابتی که صرفاً یک بار لازم است مقداردهی شوند (مثلاً بارگذاری فایل پیکربندی) و توسط تمام نمونه‌ها استفاده می‌شوند.
ارائه‌ی یک نقطه دسترسی واحد برای زیرکلاس‌ها (وقتی از class استفاده می‌کنید) تا منطق مشترک را در آن Override کنند.

 Property Observers (willSet, didSet)

 تعریف و اهمیت

Property Observers (ناظران خصوصیت) مکانیزمی هستند که با کمک دو متد داخلی willSet و didSet، می‌توانیم به تغییرات یک خصوصیت واکنش نشان دهیم. این موضوع از ابعاد شیءگرایی در Swift بسیار مهم است؛ زیرا به ما اجازه می‌دهد:

منطق کنترلی یا عملیات جانبی را قبل یا بعد از تغییر یک خصوصیت انجام دهیم.
تغییر ناخواسته خصوصیت را رصد کرده و متناسب با آن، رابط کاربری یا داده‌های دیگر را به‌روز کنیم.
مثال ساده:

var temperature: Double {
    willSet {
        print("دمای جدید برابر خواهد بود با \(newValue)")
    }
    didSet {
        print("دمای قدیم برابر بود با \(oldValue)")
    }
}

willSet قبل از انتساب مقدار جدید فراخوانی می‌شود. مقدار جدید از طریق پارامتر پیش‌فرض newValue در دسترس است (می‌توانید نام دیگری برای پارامتر نیز انتخاب کنید).
didSet بعد از انتساب مقدار جدید فراخوانی می‌شود. مقدار قدیمی از طریق پارامتر پیش‌فرض oldValue در دسترس است (باز هم امکان تغییر نام پارامتر وجود دارد).
فراخوانی این Observerها زمانی رخ می‌دهد که مقدار خصوصیت واقعاً تغییر کند. در برخی موارد (مثل مقداردهی اولیه در سازنده)، این متدها فراخوانی نمی‌شوند.

محدودیت‌ها و نکات مهم

Property Observers مختص خصوصیات ذخیره‌شده (Stored Properties) هستند. برای خصوصیات محاسباتی، شما خودتان می‌توانید عملیات مشابه را داخل get/set پیاده کنید.
اگر یک خصوصیت با willSet و didSet در سازنده (Initializer) مقداردهی شود، این Observerها در طول همان سازنده فراخوانی نمی‌شوند. دلیلش این است که Swift انتظار دارد شما ابتدا تمام خصوصیات را مقداردهی کنید و سپس نمونه آماده استفاده باشد؛ در نتیجه logic مربوط به تغییر (اگرچه از نگاه کدنویس تغییر است) در آن زمان اجرا نمی‌شود.
در صورت تعریف نکردن نام‌های پارامتر، newValue و oldValue به صورت پیش‌فرض قابل استفاده‌اند. اگر دوست داشته باشید، می‌توانید آن‌ها را مثلاً willSet(newTemperature) و didSet(oldTemperature) بنامید.

مثال پیچیده‌تر

var score: Int = 0 {
    willSet(newScore) {
        print("نمره جدید = \(newScore)")
    }
    didSet(oldScore) {
        if score > oldScore {
            print("نمره افزایش یافت.")
        } else {
            print("نمره کاهش یافت یا تغییری نکرد.")
        }
    }
}

score = 10
// خروجی:
// نمره جدید = 10
// نمره افزایش یافت.

اینجا قبل از تغییر مقدار score، پیامی با مقدار جدید چاپ می‌کنیم و بعد از تغییر، روند را بررسی می‌کنیم که آیا نمره صعودی بوده است یا خیر.

تفاوت با Computed Properties

در خصوصیات محاسباتی، بلوک‌های get و set صرفاً برای تعیین و بازگرداندن مقدار هستند؛ در حالی که در Property Observers برای نظارت بر تغییر و انجام عملیات جانبی استفاده می‌شوند. می‌توانید در کنار یک خصوصیت محاسباتی نیز منطق نظارت را در set پیاده‌سازی کنید، اما در عمل از آنجا که Computed Property هیچ مکانی برای ذخیره دیتا ندارد، کاربرد اصلی Observerها روی Stored Propertyهاست.

متدها (Methods)

متدهای نمونه (Instance Methods)

تعریف و کاربرد

متدهای نمونه آن دسته از متدهایی هستند که روی یک نمونه (Instance) از کلاس یا استراکچر فراخوانی می‌شوند.
برای استفاده از یک متد نمونه، ابتدا باید نمونه‌ای از نوع مورد نظر (کلاس یا استراکچر) ساخته شود و سپس با استفاده از آن نمونه، متد صدا زده شود.
 مثال در استراکچر

struct Counter {
    var count = 0
    
    mutating func increment() {
        count += 1
    }
}

متد increment() از نوع متد نمونه است؛ زیرا ابتدا باید یک متغیر از جنس Counter بسازید و سپس آن متد را فراخوانی کنید.
در استراکچر (و همچنین enum، که آن هم Value Type است)، اگر یک متد بخواهد self (نمونه جاری) را تغییر دهد، باید با واژه کلیدی mutating مشخص شود. این به Swift اجازه می‌دهد مقدارمحور بودن استراکچر را کنترل کند و بداند که قرار است مقادیر داخلی متغیر تغییر کنند.
بدون mutating،‌ متد فقط حق خواندن داده‌های درون استراکچر را دارد و نمی‌تواند آن‌ها را دست‌کاری کند.
طرز استفاده از این استراکچر:

var myCounter = Counter()
myCounter.increment()  
print(myCounter.count) // خروجی: 1

مثال در کلاس

در کلاس‌ها (Reference Type)، تغییر در متدهای نمونه باعث تغییر مقادیر نمونه فعلی می‌شود؛ اما نیاز به mutating نداریم، زیرا کلاس‌ها از ارجاع استفاده می‌کنند:

class BankAccount {
    var balance: Double = 0
    
    func deposit(amount: Double) {
        balance += amount
    }
}

let account = BankAccount()
account.deposit(amount: 1000)
print(account.balance) // خروجی: 1000

در اینجا متد deposit به عنوان متد نمونه عمل می‌کند و دارایی (balance) نمونه خاص account را تغییر می‌دهد.

متدهای نوع (Type Methods)

 تعریف و کاربرد

متدهای نوع در سطح نوع (Class، Struct یا Enum) تعریف می‌شوند و برای فراخوانی آن‌ها نیازی به ایجاد نمونه ندارید.
این متدها می‌توانند برای مقاصد مختلفی به کار روند؛ از جمله ارائه‌ی توابع عمومی که روی داده‌های استاتیک یا مشترک بین تمام نمونه‌ها عمل می‌کنند یا برای ساخت نمونه‌های سفارشی (Factory Methods).

مثال در استراکچر (static)

struct MathUtils {
    static func add(_ a: Int, _ b: Int) -> Int {
        return a + b
    }
}

// فراخوانی متد نوع (بدون نیاز به ساخت نمونه)
let result = MathUtils.add(5, 7)
print(result) // خروجی: 12

در استراکچر و enum، متد نوع با static تعریف می‌شود.
هنگام فراخوانی، نیازی به ساخت MathUtils() ندارید و از طریق نام نوع (MathUtils.add(…)) می‌توانید مستقیماً آن را صدا بزنید.

مثال در کلاس (static و class)

در کلاس‌ها، هم می‌توانید از static و هم از class برای متد نوع استفاده کنید؛ اما تفاوتی وجود دارد:

static: متد نهایی (Final) تلقی می‌شود و قابل Override شدن در زیرکلاس‌ها نیست.
class: متد قابل Override است.

class Math {
    // متد نوع با امکان Override
    class func square(_ number: Int) -> Int {
        return number * number
    }
    
    // متد نوع بدون امکان Override
    static func cube(_ number: Int) -> Int {
        return number * number * number
    }
}

class AdvancedMath: Math {
    override class func square(_ number: Int) -> Int {
        let result = super.square(number)
        print("Result of square: \(result)")
        return result
    }
}

// استفاده:
print(Math.square(3)) // خروجی: 9
print(Math.cube(3))   // خروجی: 27

let adv = AdvancedMath()
adv.dynamicType.square(5)  // در Swift مدرن: type(of: adv).square(5)

در مثال بالا:

square با class func تعریف شده است و در کلاس فرزند (AdvancedMath) قابلیت Override دارد.
cube با static func تعریف شده است و از این رو نهایی (Final) است و نمی‌تواند در زیرکلاس‌ها تغییر یابد.

تفاوت متدها در کلاس و استراکچر

 تغییر self

در استراکچر (یا Enum)، اگر متد نیاز به تغییر self (یعنی مقادیر داخلی نمونه) داشته باشد، باید آن متد را mutating تعریف کنید. این به Swift می‌گوید تغییر در خود استراکچر مجاز است و نیازمند کپی جدیدی نیست.
در کلاس، به دلیل Reference Type بودن، تغییر روی خصوصیات نمونه به شکل پیش‌فرض مجاز است و نیازی به mutating نیست.

متدهای نوع (static و class)

در استراکچر و enum حتماً باید متدهای نوع را با static تعریف کنید (قابلیت Override وجود ندارد).
در کلاس، انتخاب بین static و class به قابلیت Override بستگی دارد:
class func -> قابل Override
static func -> نهایی (Final)، غیرقابل Override

ارث‌بری و چندریختی (Polymorphism)

در استراکچر، مفاهیم ارث‌بری مطرح نیست (استراکچر نمی‌تواند از استراکچر دیگر ارث ببرد). از این رو، چندریختی به سبک کلاس‌ها در آن وجود ندارد.
در کلاس، با ارث‌بری می‌توانید متدهای والد را Override کرده یا گسترش دهید. از این ویژگی برای ایجاد رفتارهای متفاوت در فرزندان یک کلاس والد استفاده می‌شود (Polymorphism).

 

زیرنویس‌ها (Subscripts)

در زبان Swift، Subscript قابلیتی است که امکان دسترسی به مقادیر داخلی یک نوع (کلاس، استراکچر یا حتی enum) را با استفاده از سینتکس آرایه‌ای (براکت [ ]) فراهم می‌کند. این قابلیت، در کنار سایر مباحث ساختارهای داده‌ای و شیءگرایی در Swift، روشی قدرتمند و خوانا برای نوشتن کدی فراهم می‌آورد که از بیرون شبیه کار با آرایه یا دیکشنری به نظر می‌رسد. در ادامه، این مفهوم را با جزئیات بیشتری بررسی خواهیم کرد.

 تعریف Subscript در کلاس و استراکچر

 ایده اصلی Subscript

Subscriptها مانند عملگر دسترسی هستند که می‌توانند ورودی بگیرند (معمولاً یک یا چند پارامتر) و مقدار خروجی برگردانند.
در حقیقت، Subscript‌ها شبیه توابع محاسباتی هستند که از سینتکس با براکت برای فراخوانی استفاده می‌کنند، اما به صورت خاص برای دسترسی به داده‌های داخلی یک نوع طراحی شده‌اند.

مثال در استراکچر (Matrix)

مثالی که در صورت سؤال آمده، یک ساختار Matrix است که آرایه‌ای یک‌بعدی (grid) را در درون خود دارد و با استفاده از Subscript اجازه می‌دهد تا دسترسی به داده‌های ماتریس، با مختصات row و column انجام شود:

struct Matrix {
    let rows: Int
    let columns: Int
    var grid: [Double]
    
    init(rows: Int, columns: Int) {
        self.rows = rows
        self.columns = columns
        grid = Array(repeating: 0.0, count: rows * columns)
    }
    
    subscript(row: Int, column: Int) -> Double {
        get {
            return grid[(row * columns) + column]
        }
        set {
            grid[(row * columns) + column] = newValue
        }
    }
}

نکات مهم در این مثال

ورودی Subscript: دو مقدار row و column (از نوع Int) دریافت می‌کند.
بلوک get: زمان دسترسی (خواندن) یک خانه از ماتریس، مقدار مناسب از آرایه grid را بازمی‌گرداند.
بلوک set: در صورت انتساب (مثلاً matrix[row, col] = 5.0)، مقدار در مکان مناسب grid ذخیره می‌شود.
محاسبه‌ی اندیس (Index): این مثال نشان می‌دهد چگونه می‌توان داده‌ها را از یک ساختار یک‌بعدی (آرایه) طوری مدیریت کرد که گویا چندبعدی هستند.

 Subscript در کلاس

در کلاس نیز دقیقاً همین شیوه قابل پیاده‌سازی است. تفاوت عمده‌ای ندارد؛ جز اینکه اگر نیاز داشته باشید در یک کلاس فرزند (زیرکلاس) Subscript کلاس والد را Override کنید، می‌توانید با استفاده از کلیدواژه override این کار را انجام دهید. برای مثال:

class StringList {
    private var items = ["Apple", "Banana", "Orange"]
    
    subscript(index: Int) -> String {
        get {
            return items[index]
        }
        set {
            items[index] = newValue
        }
    }
}

class CapitalizedStringList: StringList {
    override subscript(index: Int) -> String {
        get {
            return super[index].uppercased()
        }
        set {
            super[index] = newValue.uppercased()
        }
    }
}

 

کاربرد در Collectionها و دسترسی سفارشی

 شبیه‌سازی رفتار آرایه و دیکشنری

با استفاده از Subscript، می‌توانید رفتاری مشابه یک آرایه یا دیکشنری برای نوع سفارشی خود پیاده‌سازی کنید.
به‌عنوان مثال، اگر استراکچری داشته باشید که نقش یک Cache یا ذخیره‌سازی کلید-مقدار خاص را ایفا می‌کند، می‌توانید به جای تعریف توابع getValue(forKey:) یا setValue(_:forKey:)، از سینتکس [key] سود ببرید که هم کوتاه‌تر و هم خواناتر است.
مثال دیکشنری سفارشی:

struct SimpleCache {
    private var storage: [String: Any] = [:]
    
    subscript(key: String) -> Any? {
        get {
            return storage[key]
        }
        set {
            storage[key] = newValue
        }
    }
}

حالا می‌توانید به شکل زیر با یک نمونه از SimpleCache کار کنید:

var cache = SimpleCache()
cache["username"] = "AliReza"
print(cache["username"] ?? "نامشخص")

 

Subscript‌ چندبعدی یا چندپارامتری

مثال Matrix یک نمونه‌ی چندبعدی بود؛ اما به شکل کلی، در Swift محدودیتی برای نوع و تعداد پارامترهای Subscript وجود ندارد:

struct MultiDictionary<Key1: Hashable, Key2: Hashable, Value> {
    private var storage: [Key1: [Key2: Value]] = [:]
    
    subscript(key1: Key1, key2: Key2) -> Value? {
        get {
            return storage[key1]?[key2]
        }
        set {
            if storage[key1] == nil {
                storage[key1] = [:]
            }
            storage[key1]?[key2] = newValue
        }
    }
}

به عنوان نمونه، این ساختار ترکیبی شبیه یک دیکشنری دو سطحی را فراهم می‌کند.

انواع ورودی سفارشی

ورودی Subscript می‌تواند هر نوعی باشد که بخواهید؛ از Int، String گرفته تا نوع سفارشی. فقط کافی است در تعریف Subscript مشخص کنید پارامتر چه نوعی دارد.
همچنین خروجی (نوع برگشتی) نیز می‌تواند هر چیزی باشد؛ مثلاً اگر یک Subscript read-only دارید که محاسبه‌ای را انجام می‌دهد، می‌توانید از نوعی مانند Double، String یا حتی نوع ژنریک (Generic) استفاده کنید.

نکات تکمیلی درباره Subscript

الزام به داشتن get و set:

اگر تنها قصد دارید داده‌ای را محاسبه کرده و برگردانید (بدون امکان انتساب از بیرون)، می‌توانید فقط بلوک get را بنویسید و set را حذف کنید؛ آن وقت Subscript صرفاً Read-Only خواهد بود.

در این صورت، نحوه دسترسی کاملاً مانند متغیری است که فقط get دارد:

subscript(index: Int) -> Int {
    return index * 2
}

تعریف چندین Subscript:

در Swift می‌توانید بیش از یک Subscript را در یک کلاس یا استراکچر تعریف کنید، به شرطی که امضای آن‌ها (تعداد و نوع پارامترها) متفاوت باشد. این موضوع به شما اجازه می‌دهد Method Overloading را روی Subscriptها نیز داشته باشید.

Overriding در کلاس‌ها:

اگر یک کلاس سوپرکلاس دارای Subscript باشد، زیرکلاس می‌تواند با رعایت مطابقت امضا (Signature) و استفاده از override آن را بازنویسی کند (مشابه متدهای دیگر).
محدودیت در استراکچر/enum:

در استراکچر یا enum، موضوع ارث‌بری مطرح نیست و بنابراین Overriding هم وجود ندارد. اما تعریف Subscript برای دسترسی سفارشی به داده یا محاسبه خروجی، کاملاً ممکن و معمول است.
کاربرد گسترده در Collectionها:

بسیاری از ساختارهای استاندارد در Swift، مانند Array، Dictionary و حتی String، از Subscript برای دسترسی به مقادیر خود استفاده می‌کنند. همین رفتار برای تایپ‌های سفارشی نیز قابل شبیه‌سازی است.

Subscript ابزاری در Swift است که برای ارائه‌ی سینتکس آرایه‌ای (براکت [ ]) به کار می‌رود، تا دسترسی و تنظیم مقادیر در یک نوع سفارشی، خوانا و شبیه کار با Collectionها باشد.
در کلاس‌ها و استراکچرها می‌توانید Subscript تعریف کنید؛ تفاوت خاصی جز قابلیت Overriding در کلاس‌ها وجود ندارد.
Subscriptها می‌توانند چندپارامتری باشند، خواندنی/نوشتنی یا صرفاً خواندنی باشند و حتی نوع برگشتی و پارامترهای سفارشی دلخواه داشته باشند.
این قابلیت بخش مهمی از ساختارهای داده‌ای و شیءگرایی در Swift را تشکیل می‌دهد و اجازه می‌دهد مدل‌سازی داده‌ی پیشرفته و در عین حال ساده‌ای داشته باشیم.
با یادگیری و به‌کارگیری Subscriptها، دست شما برای طراحی تایپ‌های سفارشی و در عین حال ارایه رابط کاربری آسان (API) به روی توسعه‌دهندگان دیگر یا هم‌تیمی‌هایتان بازتر می‌شود.

وراثت (Inheritance)

وراثت یکی از ویژگی‌های کلیدی شیءگرایی است که به شما اجازه می‌دهد از یک کلاس پایه (Parent) خصوصیات و متدهایی را به کلاس فرزند (Child) منتقل کنید. این مفهوم به ما امکان می‌دهد تا ساختارهای پیچیده‌تر و قابل توسعه‌تری را ایجاد کنیم بدون اینکه نیاز به نوشتن مجدد کدهای مشابه داشته باشیم.

 کلاس پایه (Base Class)

 تعریف کلاس پایه

یک کلاس پایه (Base Class) یا کلاس والد (Parent Class) کلاسی است که خصوصیات و متدهایی را تعریف می‌کند که می‌خواهیم در کلاس‌های فرزند (Child Classes) ازآن‌ها استفاده کنیم. کلاس‌های فرزند می‌توانند از کلاس پایه ارث‌بری کنند و ویژگی‌ها و رفتارهای آن را به ارث ببرند.

مثال کلاس پایه و کلاس فرزند

بیایید یک مثال ساده از وراثت در Swift را بررسی کنیم:

class Vehicle {
    var currentSpeed: Double = 0.0
    
    func description() -> String {
        return "سرعت فعلی: \(currentSpeed) کیلومتر بر ساعت"
    }
}

class Car: Vehicle {
    var gear: Int = 1
    
    override func description() -> String {
        return super.description() + ", دنده: \(gear)"
    }
}

توضیح مثال:

کلاس پایه Vehicle:
خصوصیات:
currentSpeed: سرعت فعلی وسیله نقلیه.
متد:
description(): متدی که یک رشته توضیحی از سرعت فعلی بازمی‌گرداند.
کلاس فرزند Car:
خصوصیات:
gear: دنده فعلی خودرو.
متد:
description(): متدی که از متد والد (Vehicle) استفاده می‌کند و اطلاعات دنده را نیز اضافه می‌کند.

ایجاد نمونه از کلاس فرزند

let myCar = Car()
myCar.currentSpeed = 80.0
myCar.gear = 4
print(myCar.description()) // خروجی: "سرعت فعلی: 80.0 کیلومتر بر ساعت, دنده: 4"

در اینجا، نمونه myCar از کلاس Car دارای هر دو خصوصیت currentSpeed و gear است و متد description() آن اطلاعات کامل‌تری ارائه می‌دهد.

 Override کردن متد، خصوصیت یا Subscript

Override کردن به معنای بازنویسی یا تغییر رفتار یک متد، خصوصیت یا Subscript در کلاس فرزند است تا عملکرد متفاوتی نسبت به کلاس پایه داشته باشد. برای انجام این کار، باید از کلمه کلیدی override استفاده کنیم تا کامپایلر Swift از صحت عملکرد آن اطمینان حاصل کند.

Override کردن متد

در مثال قبلی، متد description() در کلاس Car با استفاده از override بازنویسی شده است:

override func description() -> String {
    return super.description() + ", دنده: \(gear)"
}

super.description(): فراخوانی متد کلاس پایه.
افزودن اطلاعات دنده: با افزودن “, دنده: \(gear)” به خروجی متد پایه، اطلاعات بیشتری ارائه می‌شود.

 Override کردن خصوصیت

می‌توانیم خصوصیات را نیز بازنویسی کنیم تا رفتار آن‌ها تغییر کند. به عنوان مثال:

class Vehicle {
    var description: String {
        return "یک وسیله نقلیه"
    }
}

class Bike: Vehicle {
    override var description: String {
        return "یک دوچرخه"
    }
}

توضیح مثال:

در کلاس Vehicle، خصوصیت محاسباتی description تعریف شده است.
در کلاس Bike، این خصوصیت با استفاده از override بازنویسی شده و مقدار متفاوتی را برمی‌گرداند.

Override کردن Subscript

می‌توانیم Subscript‌های کلاس پایه را نیز بازنویسی کنیم تا دسترسی به داده‌ها را تغییر دهیم:

class StringList {
    private var items = ["Apple", "Banana", "Orange"]
    
    subscript(index: Int) -> String {
        get {
            return items[index]
        }
        set {
            items[index] = newValue
        }
    }
}

class CapitalizedStringList: StringList {
    override subscript(index: Int) -> String {
        get {
            return super[index].uppercased()
        }
        set {
            super[index] = newValue.uppercased()
        }
    }
}

let list = CapitalizedStringList()
print(list[0]) // خروجی: "APPLE"
list[1] = "grape"
print(list[1]) // خروجی: "GRAPE"

توضیح مثال:

در کلاس CapitalizedStringList، Subscript کلاس پایه بازنویسی شده است تا مقادیر به حروف بزرگ تبدیل شوند.

متد init و deinit در وراثت

 متد init در کلاس‌های فرزند

هنگامی که یک کلاس فرزند از کلاس پایه ارث‌بری می‌کند، باید سازنده (Initializer) خود را به گونه‌ای تعریف کند که ابتدا تمامی خصوصیات خود و سپس خصوصیات کلاس پایه را مقداردهی کند. برای این کار از تابع super.init() استفاده می‌شود.

مثال:

class Vehicle {
    var currentSpeed: Double
    
    init(speed: Double) {
        self.currentSpeed = speed
    }
    
    func description() -> String {
        return "سرعت فعلی: \(currentSpeed) کیلومتر بر ساعت"
    }
}

class Car: Vehicle {
    var gear: Int
    
    init(speed: Double, gear: Int) {
        self.gear = gear
        super.init(speed: speed)
    }
    
    override func description() -> String {
        return super.description() + ", دنده: \(gear)"
    }
}

توضیح مثال:

در کلاس Car, سازنده‌ی جدید ابتدا مقدار gear را تنظیم می‌کند و سپس با super.init(speed: speed) سازنده‌ی کلاس پایه را فراخوانی می‌کند تا currentSpeed را مقداردهی کند.

متد deinit در وراثت

متد deinit زمانی فراخوانی می‌شود که یک نمونه از حافظه آزاد می‌شود. این متد برای انجام عملیات تمیزکاری، آزادسازی منابع یا خاتمه دادن به فرآیندهایی که نیاز به پایان دارند، استفاده می‌شود.

مثال:

class FileHandler {
    init() {
        print("FileHandler initialized")
    }
    
    deinit {
        print("FileHandler deinitialized")
    }
}

var handler: FileHandler? = FileHandler() // خروجی: "FileHandler initialized"
handler = nil // خروجی: "FileHandler deinitialized"

توضیح مثال:

زمانی که متغیر handler به nil اختصاص داده می‌شود، شمارنده ارجاع به صفر می‌رسد و متد deinit فراخوانی می‌شود.

نقش init و deinit در وراثت

سازنده‌ها (init):
در کلاس‌های فرزند، باید ابتدا خصوصیات خود و سپس خصوصیات کلاس پایه را مقداردهی کنید.
اگر کلاس پایه دارای سازنده‌های سفارشی باشد، کلاس فرزند باید آن‌ها را فراخوانی کند یا سازنده‌های جدیدی تعریف کند.
متدهای deinit:
هر کلاسی می‌تواند متد deinit خود را داشته باشد.
اگر کلاس فرزند نیز متد deinit داشته باشد، ابتدا متد deinit کلاس فرزند و سپس کلاس پایه اجرا می‌شود.
مثال پیچیده‌تر:

class Animal {
    var name: String
    
    init(name: String) {
        self.name = name
        print("\(name) is initialized")
    }
    
    deinit {
        print("\(name) is deinitialized")
    }
}

class Dog: Animal {
    var breed: String
    
    init(name: String, breed: String) {
        self.breed = breed
        super.init(name: name)
        print("Breed is set to \(breed)")
    }
    
    deinit {
        print("Breed \(breed) is deinitialized")
        super.deinit()
    }
}

var dog: Dog? = Dog(name: "Rex", breed: "German Shepherd")
// خروجی:
// Rex is initialized
// Breed is set to German Shepherd

dog = nil
// خروجی:
// Breed German Shepherd is deinitialized
// Rex is deinitialized

توضیح مثال:

در هنگام ساخت شیء Dog, ابتدا سازنده‌ی Dog اجرا می‌شود که ابتدا breed را مقداردهی می‌کند، سپس سازنده‌ی کلاس پایه Animal را فراخوانی می‌کند.
در هنگام آزادسازی شیء، ابتدا متد deinit کلاس Dog و سپس deinit کلاس پایه Animal اجرا می‌شود.

اصول مهم در وراثت

 وراثت تنها از یک کلاس پایه

در Swift، یک کلاس تنها می‌تواند از یک کلاس پایه ارث‌بری کند. این مفهوم وراثت تک‌پایه (Single Inheritance) نامیده می‌شود و برای جلوگیری از پیچیدگی‌های ناشی از وراثت چندگانه طراحی شده است.

ناپایدار بودن خصوصیات در وراثت

خصوصیات ذخیره‌شده کلاس پایه نمی‌توانند در کلاس فرزند بازنویسی شوند؛ تنها می‌توان آن‌ها را محاسباتی یا خصوصیات مستقل در کلاس فرزند تعریف کرد.
خصوصیات محاسباتی می‌توانند در کلاس فرزند با استفاده از override بازنویسی شوند.

کلیدواژه‌های مهم در وراثت

override: برای بازنویسی متدها، خصوصیات یا Subscriptهای کلاس پایه در کلاس فرزند.
super: برای دسترسی به متدها و خصوصیات کلاس پایه از داخل کلاس فرزند.
مثال:

class Shape {
    var numberOfSides: Int {
        return 0
    }
    
    func area() -> Double {
        return 0.0
    }
}

class Triangle: Shape {
    var base: Double
    var height: Double
    
    init(base: Double, height: Double) {
        self.base = base
        self.height = height
        super.init()
    }
    
    override var numberOfSides: Int {
        return 3
    }
    
    override func area() -> Double {
        return (base * height) / 2
    }
}

توضیح مثال:

کلاس Shape دارای یک خصوصیت محاسباتی numberOfSides و متد area() است.
کلاس Triangle از کلاس Shape ارث‌بری می‌کند و خصوصیت و متد کلاس پایه را بازنویسی می‌کند تا رفتار خاص مثلث را نمایش دهد.

 Final Classes

اگر نمی‌خواهید کلاس شما قابل ارث‌بری باشد، می‌توانید آن را با کلمه کلیدی final تعریف کنید. این کار باعث می‌شود که هیچ کلاس فرزندی نتواند از آن ارث‌بری کند و از Overriding متدها جلوگیری می‌کند.

مثال:

final class Singleton {
    static let shared = Singleton()
    
    private init() {}
    
    func doSomething() {
        print("Doing something...")
    }
}

class AnotherClass: Singleton { // خطا: Cannot inherit from a final class 'Singleton'
}

چندریختی (Polymorphism)

چندریختی یکی دیگر از اصول اساسی شیءگرایی است که اجازه می‌دهد متدهایی با همان نام در کلاس‌های مختلف رفتارهای متفاوتی داشته باشند. این ویژگی به ما امکان می‌دهد تا از کلاس پایه به عنوان نوع داده‌ای استفاده کنیم و به صورت داینامیک، متدهای مناسب کلاس فرزند را اجرا کنیم.

مثال:

class Animal {
    func makeSound() {
        print("Animal sound")
    }
}

class Dog: Animal {
    override func makeSound() {
        print("Woof!")
    }
}

class Cat: Animal {
    override func makeSound() {
        print("Meow!")
    }
}

let animals: [Animal] = [Dog(), Cat(), Animal()]
for animal in animals {
    animal.makeSound()
}
// خروجی:
// Woof!
// Meow!
// Animal sound

توضیح مثال:

در اینجا، آرایه‌ای از نوع Animal داریم که شامل نمونه‌های Dog, Cat و Animal است.
با فراخوانی makeSound() بر روی هر شیء، متد مناسب کلاس واقعی آن شیء اجرا می‌شود، حتی اگر نوع متغیر آن Animal باشد.

استفاده از چندریختی در برنامه‌نویسی

چندریختی به ما اجازه می‌دهد تا کدهای انعطاف‌پذیر و قابل توسعه‌ای بنویسیم. مثلاً می‌توانیم متدهایی بنویسیم که از کلاس پایه استفاده می‌کنند و بر اساس کلاس‌های فرزند، رفتارهای متفاوتی داشته باشند.

مثال:

func performSound(animal: Animal) {
    animal.makeSound()
}

let dog = Dog()
let cat = Cat()

performSound(animal: dog) // خروجی: "Woof!"
performSound(animal: cat) // خروجی: "Meow!"

دسترسی به متدها و خصوصیات کلاس پایه

هنگامی که یک کلاس فرزند از کلاس پایه ارث‌بری می‌کند، می‌تواند به متدها و خصوصیات کلاس پایه دسترسی داشته باشد. برای این کار از کلیدواژه super استفاده می‌کنیم.

مثال:

class Vehicle {
    var currentSpeed: Double = 0.0
    
    func description() -> String {
        return "سرعت فعلی: \(currentSpeed) کیلومتر بر ساعت"
    }
}

class Car: Vehicle {
    var gear: Int = 1
    
    override func description() -> String {
        return super.description() + ", دنده: \(gear)"
    }
    
    func accelerate() {
        currentSpeed += 10
        print(description())
    }
}

let myCar = Car()
myCar.accelerate() // خروجی: "سرعت فعلی: 10.0 کیلومتر بر ساعت, دنده: 1"

توضیح مثال:

در کلاس Car, متد description() از کلاس پایه با استفاده از super.description() فراخوانی شده و اطلاعات دنده اضافه می‌شود.
متد accelerate() سرعت خودرو را افزایش می‌دهد و سپس توضیحات کامل را چاپ می‌کند.

پروتکل‌ها (Protocols) و وراثت

در Swift، علاوه بر وراثت از کلاس‌های پایه، می‌توانیم از پروتکل‌ها نیز استفاده کنیم تا رفتارهای مشترکی را در کلاس‌ها و استراکچرها تعریف کنیم. پروتکل‌ها مانند رابط‌های (Interfaces) در سایر زبان‌های برنامه‌نویسی عمل می‌کنند و امکان پیاده‌سازی چندگانه از انواع مختلف را فراهم می‌کنند.

مثال:

protocol Drivable {
    func drive()
}

class Vehicle {
    var currentSpeed: Double = 0.0
}

class Car: Vehicle, Drivable {
    func drive() {
        print("در حال رانندگی با سرعت \(currentSpeed) کیلومتر بر ساعت")
    }
}

let myCar = Car()
myCar.currentSpeed = 60.0
myCar.drive() // خروجی: "در حال رانندگی با سرعت 60.0 کیلومتر بر ساعت"

توضیح مثال:

پروتکل Drivable متدی به نام drive() را تعریف می‌کند.
کلاس Car از کلاس Vehicle ارث‌بری می‌کند و پروتکل Drivable را نیز پیاده‌سازی می‌کند.
با این کار، کلاس Car می‌تواند هم از قابلیت‌های کلاس پایه بهره‌مند شود و هم رفتارهای تعریف شده در پروتکل را پیاده‌سازی کند.

سازنده‌های ailable و وراثت

همانند سازنده‌های معمولی، سازنده‌های شکست‌پذیر (failable initializers) نیز می‌توانند در کلاس‌های پایه و فرزند استفاده شوند. اگر سازنده‌ی کلاس پایه شکست‌پذیر باشد، کلاس فرزند نیز باید این خاصیت را حفظ کند.

مثال:

class Person {
    var name: String
    
    init?(name: String) {
        if name.isEmpty {
            return nil
        }
        self.name = name
    }
}

class Employee: Person {
    var employeeID: Int
    
    init?(name: String, employeeID: Int) {
        if employeeID <= 0 {
            return nil
        }
        self.employeeID = employeeID
        super.init(name: name)
    }
}

let employee = Employee(name: "Sara", employeeID: 123)
if let emp = employee {
    print("نام: \(emp.name), شناسه کارمند: \(emp.employeeID)")
} else {
    print("مقادیر نامعتبر بودند.")
}
// خروجی: "نام: Sara, شناسه کارمند: 123"

توضیح مثال:

سازنده‌ی کلاس پایه Person تنها در صورتی موفقیت‌آمیز است که نام غیر خالی باشد.
سازنده‌ی کلاس فرزند Employee علاوه بر چک کردن معتبر بودن employeeID، سازنده‌ی کلاس پایه را نیز فراخوانی می‌کند.
اگر هر دو مقدار نام و employeeID معتبر باشند، شیء Employee ساخته می‌شود؛ در غیر این صورت، nil برمی‌گردد.

مثال‌های پیشرفته‌تر از وراثت

استفاده از وراثت با کلاس‌های متعدد

فرض کنید می‌خواهیم چندین نوع وسیله نقلیه داشته باشیم که هر کدام ویژگی‌ها و رفتارهای خاص خود را دارند:

class Vehicle {
    var currentSpeed: Double = 0.0
    
    func makeSound() {
        print("Some generic vehicle sound")
    }
}

class Car: Vehicle {
    var gear: Int = 1
    
    override func makeSound() {
        print("Vroom Vroom")
    }
}

class Bicycle: Vehicle {
    override func makeSound() {
        print("Ring Ring")
    }
}

let myCar = Car()
myCar.currentSpeed = 100.0
myCar.gear = 5
myCar.makeSound() // خروجی: "Vroom Vroom"

let myBike = Bicycle()
myBike.currentSpeed = 20.0
myBike.makeSound() // خروجی: "Ring Ring"

توضیح مثال:

هر کلاس فرزند Car و Bicycle متد makeSound() را بازنویسی کرده‌اند تا صدای مخصوص به خود را تولید کنند.
با این کار، ما می‌توانیم از کلاس پایه Vehicle به عنوان نوع داده‌ای استفاده کنیم و با فراخوانی متد makeSound()، صدای مناسب نوع واقعی وسیله نقلیه را دریافت کنیم.

استفاده از Subclassing برای اضافه کردن ویژگی‌های جدید

می‌توانیم کلاس‌های فرزند را طوری طراحی کنیم که ویژگی‌های جدیدی نسبت به کلاس پایه داشته باشند:

class Animal {
    var name: String
    
    init(name: String) {
        self.name = name
    }
    
    func makeSound() {
        print("\(name) makes a sound.")
    }
}

class Dog: Animal {
    var breed: String
    
    init(name: String, breed: String) {
        self.breed = breed
        super.init(name: name)
    }
    
    override func makeSound() {
        print("\(name) says Woof!")
    }
}

let dog = Dog(name: "Buddy", breed: "Golden Retriever")
dog.makeSound() // خروجی: "Buddy says Woof!"

توضیح مثال:

کلاس Dog ویژگی جدید breed را اضافه کرده و متد makeSound() را بازنویسی می‌کند تا صدای خاص سگ را تولید کند.

Final Classes و Final Methods

گاهی اوقات ممکن است بخواهیم از وراثت جلوگیری کنیم یا مطمئن شویم که متدهای خاصی در کلاس‌های فرزند بازنویسی نشوند. برای این کار، می‌توانیم از کلیدواژه final استفاده کنیم.

 Final Classes

با تعریف یک کلاس به عنوان final، اجازه وراثت از آن کلاس را نمی‌دهیم.

مثال:

final class Singleton {
    static let shared = Singleton()
    
    private init() {}
    
    func doSomething() {
        print("Doing something...")
    }
}

class SubClass: Singleton { // خطا: Cannot inherit from a final class 'Singleton'
}

 Final Methods

می‌توانیم متدهای خاصی را به عنوان final تعریف کنیم تا در کلاس‌های فرزند قابل بازنویسی نباشند.

مثال:

class Parent {
    final func cannotOverride() {
        print("This method cannot be overridden.")
    }
    
    func canOverride() {
        print("This method can be overridden.")
    }
}

class Child: Parent {
    override func canOverride() {
        print("Overridden method.")
    }
    
    override func cannotOverride() { // خطا: Instance method overrides a 'final' method
        print("Attempting to override a final method.")
    }
}

توضیح مثال:

متد cannotOverride() در کلاس Parent با استفاده از final مشخص شده و نمی‌توان آن را در کلاس‌های فرزند بازنویسی کرد.
متد canOverride() بدون final قابل بازنویسی است.

محدودیت‌های وراثت در Swift

یک کلاس تنها می‌تواند از یک کلاس پایه ارث‌بری کند: Swift از وراثت چندگانه پشتیبانی نمی‌کند، که به جلوگیری از پیچیدگی‌ها و مشکلات ناشی از وراثت چندگانه کمک می‌کند.
استراکچرها وراثت ندارند: تنها کلاس‌ها در Swift می‌توانند از وراثت بهره‌مند شوند. استراکچرها و enumها از این قابلیت بی‌بهره‌اند، اما می‌توانند از پروتکل‌ها استفاده کنند.

استفاده از پروتکل‌ها به جای وراثت

در برخی موارد، ممکن است بهتر باشد از پروتکل‌ها (Protocols) به جای وراثت برای تعریف رفتارهای مشترک استفاده کنیم. پروتکل‌ها انعطاف‌پذیری بیشتری فراهم می‌کنند و به ما امکان می‌دهند تا از Composition به جای Inheritance استفاده کنیم.

مثال:

protocol Drivable {
    func drive()
}

class Vehicle {
    var currentSpeed: Double = 0.0
}

class Car: Vehicle, Drivable {
    func drive() {
        print("در حال رانندگی با سرعت \(currentSpeed) کیلومتر بر ساعت")
    }
}

class Bicycle: Vehicle, Drivable {
    func drive() {
        print("در حال دوچرخه‌سواری با سرعت \(currentSpeed) کیلومتر بر ساعت")
    }
}

let cars: [Drivable] = [Car(), Bicycle()]
for car in cars {
    car.drive()
}

توضیح مثال:

پروتکل Drivable متدی به نام drive() را تعریف می‌کند.
هر دو کلاس Car و Bicycle از کلاس پایه Vehicle ارث‌بری کرده و پروتکل Drivable را پیاده‌سازی می‌کنند.
این رویکرد به ما امکان می‌دهد تا مجموعه‌ای از انواع مختلف که پروتکل Drivable را پیاده‌سازی کرده‌اند، را مدیریت کنیم بدون نیاز به استفاده از وراثت چندگانه.

وراثت (Inheritance) یکی از اصول اساسی ساختارهای داده‌ای و شیءگرایی در Swift است که به ما اجازه می‌دهد تا از کلاس‌های پایه بهره‌مند شویم و ویژگی‌ها و رفتارهای مشترک را به کلاس‌های فرزند منتقل کنیم.
کلاس‌های پایه نقش مهمی در تعریف خصوصیات و متدهایی دارند که می‌خواهیم در کلاس‌های فرزند به اشتراک بگذاریم.
Override کردن متدها، خصوصیات یا Subscriptها به ما اجازه می‌دهد تا رفتارهای کلاس پایه را در کلاس‌های فرزند تغییر دهیم و رفتارهای خاص‌تری را پیاده‌سازی کنیم.
متدهای init و deinit در فرآیند وراثت نقش کلیدی دارند؛ سازنده‌های کلاس فرزند باید سازنده‌های کلاس پایه را فراخوانی کنند و متد deinit امکان انجام عملیات تمیزکاری را فراهم می‌کند.
چندریختی (Polymorphism) به ما امکان می‌دهد تا متدهای مختلف را با استفاده از کلاس‌های فرزند فراخوانی کنیم و از رفتارهای متفاوت آن‌ها بهره‌مند شویم.
پروتکل‌ها (Protocols) به عنوان جایگزینی برای وراثت در برخی موارد می‌توانند انعطاف‌پذیری بیشتری فراهم کنند و به ما اجازه دهند تا از Composition استفاده کنیم.
در نهایت، درک عمیق از وراثت و نحوه استفاده بهینه از آن در Swift، به شما کمک می‌کند تا کدهایی سازمان‌یافته‌تر، قابل نگهداری‌تر و توسعه‌پذیرتر بنویسید. این مفاهیم پایه‌ای، بخش مهمی از ساختارهای داده‌ای و شیءگرایی در Swift را تشکیل می‌دهند و در پروژه‌های بزرگ‌تر و پیچیده‌تر نقش حیاتی دارند.

مقدمه‌ای بر چندریختی (Polymorphism)

چندریختی به معنای توانایی یک متد یا خصوصیت مشترک در انواع مختلف است. این مفهوم اجازه می‌دهد تا یک نوع پایه (Parent Class) تعریف کرده و کلاس‌های فرزند (Child Classes) از آن ارث‌بری کنند و متدها یا خصوصیات را به صورت خاص خود پیاده‌سازی نمایند. این امر باعث می‌شود که کدهای ما انعطاف‌پذیرتر، قابل گسترش‌تر و خواناتر باشند.

 Upcasting و Downcasting

Upcasting (بالاگشتن نوع)

Upcasting به معنای تبدیل یک نمونه از کلاس فرزند به نوع کلاس والد است. این فرآیند در زبان‌های شیءگرا معمولاً بدون نیاز به عملگرهای خاص انجام می‌شود و کامپایلر به طور خودکار این تبدیل را انجام می‌دهد. هدف از Upcasting این است که بتوانیم از قابلیت‌های کلاس والد برای نمونه‌های کلاس فرزند استفاده کنیم.

مثال:

class Animal {
    func makeSound() {
        print("Animal makes a sound")
    }
}

class Dog: Animal {
    override func makeSound() {
        print("Dog barks")
    }
    
    func fetch() {
        print("Dog is fetching the ball")
    }
}

let dog = Dog()
dog.makeSound() // خروجی: "Dog barks"
dog.fetch()     // خروجی: "Dog is fetching the ball"

// Upcasting: تبدیل dog به نوع Animal
let animal: Animal = dog
animal.makeSound() // خروجی: "Dog barks"

// animal.fetch() // خطا: 'Animal' has no member 'fetch'

توضیح مثال:

در اینجا، شیء dog از کلاس Dog به نوع Animal تبدیل شده است.
با انجام Upcasting، می‌توانیم از متدهای تعریف‌شده در کلاس Animal استفاده کنیم.
اما متد fetch() که خاص کلاس Dog است، در نوع Animal قابل دسترسی نیست.

 Downcasting (پایینگشتن نوع)

Downcasting به معنای تبدیل یک ارجاع از نوع کلاس والد به نوع کلاس فرزند است. این فرآیند معمولاً نیازمند استفاده از عملگرهای as? یا as! است و می‌تواند با خطراتی همراه باشد، به خصوص اگر تبدیل نامعتبر باشد. Downcasting برای دسترسی به متدها و خصوصیات خاص کلاس فرزند مورد استفاده قرار می‌گیرد.

مثال:

class Animal {
    func makeSound() {
        print("Animal makes a sound")
    }
}

class Dog: Animal {
    override func makeSound() {
        print("Dog barks")
    }
    
    func fetch() {
        print("Dog is fetching the ball")
    }
}

let animal: Animal = Dog()
animal.makeSound() // خروجی: "Dog barks"

// Downcasting به صورت اختیاری با استفاده از as?
if let dog = animal as? Dog {
    dog.fetch() // خروجی: "Dog is fetching the ball"
} else {
    print("Downcasting failed")
}

// Downcasting به صورت اجبار با استفاده از as!
let forcedDog = animal as! Dog
forcedDog.fetch() // خروجی: "Dog is fetching the ball"

توضیح مثال:

در ابتدا، شیء animal از نوع Animal است اما در واقع یک نمونه از Dog است.
با استفاده از as?، ابتدا بررسی می‌کنیم که آیا تبدیل به نوع Dog موفقیت‌آمیز است یا خیر. اگر موفق باشد، می‌توانیم به متدهای خاص Dog دسترسی پیدا کنیم.
با استفاده از as!، تبدیل را به صورت اجبار انجام می‌دهیم. اگر تبدیل ناموفق باشد، برنامه کرش می‌کند.

Type Casting (as, as?, as!)

در Swift، Type Casting فرآیندی است که طی آن می‌توانیم یک شیء را از یک نوع به نوع دیگر تبدیل کنیم. برای انجام این کار از عملگرهای as, as?, و as! استفاده می‌شود.

 عملگر as

as برای تبدیل‌های مطمئن که در آن‌ها نوع هدف کاملاً با نوع اصلی سازگار است، استفاده می‌شود. معمولاً در مواردی که به طور واضح از صحت نوع اطمینان داریم، مانند تبدیل به پروتکل‌هایی که نوع قبلاً از آن پیروی کرده است.

مثال:

protocol Drivable {
    func drive()
}

class Vehicle {
    func description() -> String {
        return "This is a vehicle"
    }
}

class Car: Vehicle, Drivable {
    func drive() {
        print("Car is driving")
    }
}

let car = Car()
let drivableVehicle = car as Drivable
drivableVehicle.drive() // خروجی: "Car is driving"

توضیح مثال:

در اینجا، شیء car از نوع Car است که از پروتکل Drivable پیروی می‌کند.
با استفاده از as, شیء car را به نوع Drivable تبدیل می‌کنیم و می‌توانیم متد drive() را فراخوانی کنیم.

عملگر as?

as? برای تبدیل‌های اختیاری استفاده می‌شود که ممکن است موفقیت‌آمیز نباشند. این عملگر یک نوع اختیاری (Optional) برمی‌گرداند و اگر تبدیل موفقیت‌آمیز نباشد، nil برمی‌گرداند. این امر به جلوگیری از کرش‌های ناخواسته کمک می‌کند.

مثال:

class Animal {
    func makeSound() {
        print("Animal makes a sound")
    }
}

class Dog: Animal {
    func fetch() {
        print("Dog is fetching the ball")
    }
}

class Cat: Animal {
    func scratch() {
        print("Cat is scratching")
    }
}

let animals: [Animal] = [Dog(), Cat(), Animal()]

for animal in animals {
    if let dog = animal as? Dog {
        dog.fetch()
    } else if let cat = animal as? Cat {
        cat.scratch()
    } else {
        print("Unknown animal")
    }
}

// خروجی:
// Dog is fetching the ball
// Cat is scratching
// Unknown animal

توضیح مثال:

در اینجا، آرایه‌ای از Animal داریم که شامل نمونه‌های Dog, Cat و Animal است.
با استفاده از as?, بررسی می‌کنیم که آیا هر شیء قابل تبدیل به Dog یا Cat است یا خیر و متدهای خاص آن‌ها را فراخوانی می‌کنیم.
اگر تبدیل موفقیت‌آمیز نباشد، پیام “Unknown animal” را چاپ می‌کنیم.

عملگر as!

as! برای تبدیل‌های اجبارآمیز استفاده می‌شود که در آن‌ها فرض می‌کنیم تبدیل همیشه موفقیت‌آمیز خواهد بود. اگر تبدیل ناموفق باشد، برنامه کرش می‌کند. بنابراین باید با احتیاط از این عملگر استفاده شود.

مثال:

class Animal {
    func makeSound() {
        print("Animal makes a sound")
    }
}

class Dog: Animal {
    func fetch() {
        print("Dog is fetching the ball")
    }
}

let animal: Animal = Dog()
let dog = animal as! Dog
dog.fetch() // خروجی: "Dog is fetching the ball"

let anotherAnimal: Animal = Animal()
// let anotherDog = anotherAnimal as! Dog // کرش می‌کند

توضیح مثال:

در ابتدا، شیء animal از نوع Animal اما در واقع یک Dog است. تبدیل به Dog با استفاده از as! موفقیت‌آمیز است.
در ادامه، شیء anotherAnimal از نوع Animal است و تبدیل به Dog با استفاده از as! باعث کرش برنامه می‌شود، زیرا anotherAnimal یک Animal ساده است و نه Dog.

کاربردهای چندریختی در برنامه‌نویسی

چندریختی به ما امکان می‌دهد تا کدهای انعطاف‌پذیر و قابل گسترش بنویسیم. این قابلیت به ویژه در مواردی که از نوع پایه برای نگهداری شیءهای مختلف استفاده می‌کنیم، بسیار مفید است. به عنوان مثال، می‌توانیم توابعی بنویسیم که با انواع مختلفی از اشیاء کار می‌کنند بدون اینکه به نوع خاص آن‌ها وابسته باشیم.

مثال:

class Animal {
    func makeSound() {
        print("Animal makes a sound")
    }
}

class Dog: Animal {
    override func makeSound() {
        print("Dog barks")
    }
}

class Cat: Animal {
    override func makeSound() {
        print("Cat meows")
    }
}

func performAnimalSound(animal: Animal) {
    animal.makeSound()
}

let dog = Dog()
let cat = Cat()

performAnimalSound(animal: dog) // خروجی: "Dog barks"
performAnimalSound(animal: cat) // خروجی: "Cat meows"

توضیح مثال:

تابع performAnimalSound از نوع Animal به عنوان پارامتر استفاده می‌کند.
با استفاده از چندریختی، می‌توانیم هر شیء از نوع Animal یا فرزندان آن را به این تابع ارسال کنیم و متد مناسب برای نوع واقعی شیء اجرا می‌شود.
این روش باعث می‌شود که تابع performAnimalSound نیازی به دانستن جزئیات خاص هر نوع فرزند نداشته باشد و همچنان عملکرد صحیحی داشته باشد.

چندریختی (Polymorphism) یکی از اصول اساسی شیءگرایی در Swift است که به ما اجازه می‌دهد تا از یک رابط یا کلاس پایه برای کار با انواع مختلف اشیاء استفاده کنیم.
Upcasting و Downcasting ابزارهایی هستند که در این فرآیند به ما کمک می‌کنند تا نوع اشیاء را به گونه‌ای مدیریت کنیم که بتوانیم از ویژگی‌ها و متدهای مشترک بهره ببریم.
Type Casting با استفاده از عملگرهای as, as?, و as! امکان تبدیل اشیاء بین انواع مختلف را فراهم می‌کند و در مواقعی که نیاز به دسترسی به متدها و خصوصیات خاص کلاس‌های فرزند داریم، بسیار مفید است.
چندریختی به ما کمک می‌کند تا کدهای ما انعطاف‌پذیرتر و قابل گسترش‌تر باشند، به طوری که بتوانیم با استفاده از یک نوع پایه، انواع مختلفی از اشیاء را مدیریت کنیم بدون اینکه نیاز به تعریف جداگانه برای هر نوع داشته باشیم.
با درک عمیق‌تر از مفهوم چندریختی و نحوه استفاده از Upcasting و Downcasting در Swift، می‌توانید کدهای منظم‌تر، قابل خواندن‌تر و پایدارتر بنویسید که در پروژه‌های بزرگ‌تر و پیچیده‌تر بسیار مفید خواهند بود.

مقدمه‌ای بر معماری شیءگرا

 تعریف معماری شیءگرا

معماری شیءگرا روشی برای طراحی و سازمان‌دهی سیستم‌های نرم‌افزاری است که بر پایه مفاهیم شیءها (Objects) و کلاس‌ها (Classes) بنا شده است. این رویکرد به برنامه‌نویسان اجازه می‌دهد تا با مدل‌سازی جهان واقعی به‌صورت شیءها و تعاملات آن‌ها، سیستم‌هایی قابل فهم‌تر و مدیریت‌پذیرتر ایجاد کنند.

اصول اصلی معماری شیءگرا شامل:

انسجام (Encapsulation): مخفی‌سازی جزئیات پیاده‌سازی داخلی شیء و ارائه رابط‌های مشخص برای تعامل با آن.
وراثت (Inheritance): امکان ایجاد کلاس‌های جدید بر اساس کلاس‌های موجود برای به اشتراک‌گذاری ویژگی‌ها و رفتارها.
چندریختی (Polymorphism): توانایی استفاده از یک رابط مشترک برای اشیاء از انواع مختلف.
انتزاع (Abstraction): تمرکز بر ویژگی‌های ضروری و حذف جزئیات غیرضروری.
این اصول به همراه سایر مفاهیم، پایه‌ای محکم برای طراحی سیستم‌های نرم‌افزاری پیچیده و قابل نگهداری فراهم می‌کنند.

 اصول SOLID در معماری شیءگرا

SOLID مجموعه‌ای از پنج اصل طراحی شیءگرا است که توسط رابرت سی. مارتین (Robert C. Martin) معرفی شده‌اند. این اصول به برنامه‌نویسان کمک می‌کنند تا کدهایی بنویسند که قابل فهم، قابل نگهداری و توسعه‌پذیر باشند. در ادامه به توضیح هر یک از این اصول می‌پردازیم:

 Single Responsibility Principle (SRP) – اصل مسئولیت واحد

تعریف: هر کلاس یا ماژول باید تنها یک مسئولیت مشخص داشته باشد و تغییرات آن باید به یک دلیل خاص مرتبط باشد.

مزایا:

افزایش خوانایی کد
تسهیل در تست و نگهداری
کاهش وابستگی‌ها بین کلاس‌ها
مثال در Swift: فرض کنید یک کلاس User داریم که هم مسئولیت مدیریت داده‌های کاربر و هم مسئولیت نمایش آن‌ها را دارد. طبق SRP، باید این مسئولیت‌ها را جدا کنیم.

// قبل از اعمال SRP
class User {
    var name: String
    var email: String
    
    init(name: String, email: String) {
        self.name = name
        self.email = email
    }
    
    func displayUserInfo() {
        print("Name: \(name), Email: \(email)")
    }
}

// بعد از اعمال SRP
class User {
    var name: String
    var email: String
    
    init(name: String, email: String) {
        self.name = name
        self.email = email
    }
}

class UserView {
    func display(user: User) {
        print("Name: \(user.name), Email: \(user.email)")
    }
}

Open/Closed Principle (OCP) – اصل باز/بسته

تعریف: کلاس‌ها باید برای توسعه باز و برای تغییر بسته باشند. به این معنی که باید بتوان رفتار آن‌ها را بدون تغییر در کد موجود، گسترش داد.

مزایا:

تسهیل در افزودن قابلیت‌های جدید
کاهش احتمال ایجاد باگ‌های جدید هنگام تغییر کدهای موجود
مثال در Swift: فرض کنید نیاز داریم محاسبه مساحت اشکال هندسی مختلف را انجام دهیم. با استفاده از OCP، می‌توانیم این قابلیت را بدون تغییر در کلاس‌های موجود گسترش دهیم.

// قبل از اعمال OCP
class Rectangle {
    var width: Double
    var height: Double
    
    init(width: Double, height: Double) {
        self.width = width
        self.height = height
    }
    
    func area() -> Double {
        return width * height
    }
}

class Circle {
    var radius: Double
    
    init(radius: Double) {
        self.radius = radius
    }
    
    func area() -> Double {
        return Double.pi * radius * radius
    }
}

// بعد از اعمال OCP با استفاده از پروتکل
protocol Shape {
    func area() -> Double
}

class Rectangle: Shape {
    var width: Double
    var height: Double
    
    init(width: Double, height: Double) {
        self.width = width
        self.height = height
    }
    
    func area() -> Double {
        return width * height
    }
}

class Circle: Shape {
    var radius: Double
    
    init(radius: Double) {
        self.radius = radius
    }
    
    func area() -> Double {
        return Double.pi * radius * radius
    }
}

Liskov Substitution Principle (LSP) – اصل جایگزینی لیسکوف

تعریف: هر کلاس فرزند باید بتواند جایگزین کلاس والد خود شود بدون اینکه رفتار برنامه تغییر کند.

مزایا:

حفظ سازگاری بین کلاس‌های والد و فرزند
افزایش قابلیت استفاده مجدد از کد
مثال در Swift: اگر کلاس فرزند نتواند تمام رفتارهای کلاس والد را حفظ کند، اصل LSP نقض می‌شود.

// صحیح: کلاس فرزند تمام رفتارهای والد را حفظ می‌کند
class Bird {
    func fly() {
        print("Bird is flying")
    }
}

class Sparrow: Bird {
    override func fly() {
        print("Sparrow is flying")
    }
}

func makeBirdFly(bird: Bird) {
    bird.fly()
}

let sparrow = Sparrow()
makeBirdFly(bird: sparrow) // خروجی: "Sparrow is flying"

// ناصحیح: کلاس فرزند رفتار والد را نقض می‌کند
class Bird {
    func fly() {
        print("Bird is flying")
    }
}

class Ostrich: Bird {
    override func fly() {
        fatalError("Ostriches can't fly")
    }
}

let ostrich = Ostrich()
makeBirdFly(bird: ostrich) // کرش برنامه

 

Interface Segregation Principle (ISP) – اصل جداسازی رابط

تعریف: باید از ایجاد رابط‌های بزرگ و پیچیده خودداری کرده و آن‌ها را به رابط‌های کوچک‌تر و خاص‌تر تقسیم کرد که هر کدام فقط نیازهای مشخصی را برطرف می‌کنند.

مزایا:

کاهش وابستگی‌ها بین کلاس‌ها
افزایش انعطاف‌پذیری در پیاده‌سازی رابط‌ها
مثال در Swift: فرض کنید یک پروتکل بزرگ داریم که چندین رفتار را تعریف می‌کند. طبق ISP، باید آن را به چند پروتکل کوچک‌تر تقسیم کنیم.

// قبل از اعمال ISP
protocol Worker {
    func work()
    func eat()
}

// بعد از اعمال ISP
protocol Workable {
    func work()
}

protocol Eatable {
    func eat()
}

class Human: Workable, Eatable {
    func work() {
        print("Human is working")
    }
    
    func eat() {
        print("Human is eating")
    }
}

class Robot: Workable {
    func work() {
        print("Robot is working")
    }
}

Dependency Inversion Principle (DIP) – اصل معکوس‌سازی وابستگی

تعریف: باید ماژول‌های سطح بالا (High-Level Modules) به ماژول‌های سطح پایین (Low-Level Modules) وابسته نباشند، بلکه هر دو باید به تجرید‌ها (Abstractions) وابسته باشند. همچنین تجرید‌ها نباید به جزئیات وابسته باشند، بلکه جزئیات باید به تجرید‌ها وابسته باشند.

مزایا:

افزایش قابلیت تعویض و تست کد
کاهش وابستگی‌ها بین ماژول‌ها
مثال در Swift: استفاده از پروتکل‌ها به عنوان تجرید‌ها برای کاهش وابستگی‌های بین کلاس‌ها.

// قبل از اعمال DIP
class LightBulb {
    func turnOn() {
        print("LightBulb turned on")
    }
    
    func turnOff() {
        print("LightBulb turned off")
    }
}

class Switch {
    let bulb = LightBulb()
    
    func operate(on: Bool) {
        if on {
            bulb.turnOn()
        } else {
            bulb.turnOff()
        }
    }
}

// بعد از اعمال DIP با استفاده از پروتکل
protocol Switchable {
    func turnOn()
    func turnOff()
}

class LightBulb: Switchable {
    func turnOn() {
        print("LightBulb turned on")
    }
    
    func turnOff() {
        print("LightBulb turned off")
    }
}

class Fan: Switchable {
    func turnOn() {
        print("Fan turned on")
    }
    
    func turnOff() {
        print("Fan turned off")
    }
}

class SwitchDevice {
    let device: Switchable
    
    init(device: Switchable) {
        self.device = device
    }
    
    func operate(on: Bool) {
        if on {
            device.turnOn()
        } else {
            device.turnOff()
        }
    }
}

let light = LightBulb()
let fan = Fan()

let lightSwitch = SwitchDevice(device: light)
lightSwitch.operate(on: true)  // خروجی: "LightBulb turned on"

let fanSwitch = SwitchDevice(device: fan)
fanSwitch.operate(on: true)    // خروجی: "Fan turned on"

الگوهای طراحی (Design Patterns) در معماری شیءگرا

الگوهای طراحی راه‌حل‌های استاندارد و قابل استفاده مجدد برای مشکلات رایج در طراحی نرم‌افزار هستند. این الگوها به برنامه‌نویسان کمک می‌کنند تا کدهایی سازگارتر، قابل نگهداری‌تر و مقیاس‌پذیرتر بنویسند. در ادامه به معرفی برخی از الگوهای طراحی پرکاربرد در Swift می‌پردازیم:

Singleton

تعریف: الگوی Singleton تضمین می‌کند که تنها یک نمونه از یک کلاس وجود داشته باشد و یک نقطه دسترسی جهانی به آن فراهم کند.

مثال در Swift:

class Logger {
    static let shared = Logger()
    
    private init() {}
    
    func log(message: String) {
        print("Log: \(message)")
    }
}

// استفاده:
Logger.shared.log(message: "Application started")

Observer

تعریف: الگوی Observer اجازه می‌دهد تا اشیاء (Observers) به تغییرات وضعیت یک شیء دیگر (Subject) واکنش نشان دهند بدون اینکه به پیاده‌سازی داخلی آن وابسته باشند.

مثال در Swift:

protocol Observer: AnyObject {
    func update(subject: Subject)
}

class Subject {
    private var observers = [Observer]()
    var state: Int = 0 {
        didSet {
            notify()
        }
    }
    
    func attach(observer: Observer) {
        observers.append(observer)
    }
    
    private func notify() {
        for observer in observers {
            observer.update(subject: self)
        }
    }
}

class ConcreteObserver: Observer {
    func update(subject: Subject) {
        print("Observer notified with state: \(subject.state)")
    }
}

// استفاده:
let subject = Subject()
let observer = ConcreteObserver()

subject.attach(observer: observer)
subject.state = 10 // خروجی: "Observer notified with state: 10"

Factory

تعریف: الگوی Factory یک رابط برای ایجاد اشیاء فراهم می‌کند، اما اجازه نمی‌دهد که کلاس‌های فرزند نوع شیء ایجاد شده را مشخص کنند.

مثال در Swift:

protocol Product {
    func use()
}

class ConcreteProductA: Product {
    func use() {
        print("Using Product A")
    }
}

class ConcreteProductB: Product {
    func use() {
        print("Using Product B")
    }
}

class Factory {
    static func createProduct(type: String) -> Product? {
        switch type {
        case "A":
            return ConcreteProductA()
        case "B":
            return ConcreteProductB()
        default:
            return nil
        }
    }
}

// استفاده:
if let product = Factory.createProduct(type: "A") {
    product.use() // خروجی: "Using Product A"
}

 MVVM (Model-View-ViewModel)

تعریف: الگوی MVVM یک الگوی معماری برای جداسازی منطق کسب‌وکار و نمایش است. این الگو با استفاده از ViewModel ارتباط بین Model و View را مدیریت می‌کند.

مثال در Swift:

// Model
struct User {
    let name: String
    let age: Int
}

// ViewModel
class UserViewModel {
    private let user: User
    
    var displayName: String {
        return "Name: \(user.name)"
    }
    
    var displayAge: String {
        return "Age: \(user.age)"
    }
    
    init(user: User) {
        self.user = user
    }
}

// View (مثلاً در یک ViewController)
class UserViewController: UIViewController {
    var viewModel: UserViewModel!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        print(viewModel.displayName) // خروجی: "Name: Ali"
        print(viewModel.displayAge)  // خروجی: "Age: 30"
    }
}

// استفاده:
let user = User(name: "Ali", age: 30)
let viewModel = UserViewModel(user: user)
let viewController = UserViewController()
viewController.viewModel = viewModel

 

معماری‌های رایج در Swift

MVC (Model-View-Controller)

تعریف: الگوی MVC یکی از رایج‌ترین الگوهای معماری در توسعه اپلیکیشن‌های iOS است که به جداسازی منطق کسب‌وکار، نمایش و کنترل تعاملات کاربر می‌پردازد.

ساختار:

Model: داده‌ها و منطق کسب‌وکار.
View: رابط کاربری و نمایش داده‌ها.
Controller: مدیریت ارتباط بین Model و View و واکنش به تعاملات کاربر.
مزایا:

ساده و قابل فهم
جداسازی واضح مسئولیت‌ها
معایب:

کنترلرها ممکن است بسیار بزرگ شوند (Massive View Controller)
پیچیدگی در مدیریت ارتباطات بین اجزا

MVVM (Model-View-ViewModel)

تعریف: الگوی MVVM برای رفع برخی از محدودیت‌های MVC معرفی شده و با افزودن لایه ViewModel، جداسازی بهتری بین منطق کسب‌وکار و رابط کاربری فراهم می‌کند.

ساختار:

Model: داده‌ها و منطق کسب‌وکار.
View: رابط کاربری.
ViewModel: مدیریت داده‌ها برای نمایش و انجام عملیات بر روی آن‌ها.
مزایا:

جداسازی بهتر مسئولیت‌ها
آسان‌تر شدن تست و نگهداری
کاهش حجم Controller
معایب:

پیچیدگی بیشتر نسبت به MVC
نیاز به مدیریت بهتر داده‌ها و ارتباطات بین اجزا

VIPER (View-Interactor-Presenter-Entity-Router)

تعریف: VIPER یک الگوی معماری پیشرفته‌تر است که برای توسعه اپلیکیشن‌های بزرگ و پیچیده طراحی شده و تمرکز بر جداسازی مسئولیت‌ها و بهبود قابلیت تست دارد.

ساختار:

View: نمایش داده‌ها و دریافت تعاملات کاربر.
Interactor: منطق کسب‌وکار و پردازش داده‌ها.
Presenter: مدیریت ارتباط بین View و Interactor و آماده‌سازی داده‌ها برای نمایش.
Entity: مدل‌های داده‌ای.
Router: مدیریت ناوبری و انتقال بین صفحات.
مزایا:

جداسازی بسیار دقیق مسئولیت‌ها
قابلیت تست بالا
مقیاس‌پذیری بهتر
معایب:

پیچیدگی بالا
نیاز به زمان بیشتر برای پیاده‌سازی

اهمیت رعایت اصول SOLID و الگوهای طراحی در Swift

رعایت اصول SOLID و استفاده از الگوهای طراحی در توسعه اپلیکیشن‌های Swift به دلایل زیر بسیار مهم است:

قابلیت نگهداری: کدهایی که اصول SOLID را رعایت می‌کنند، آسان‌تر قابل فهم و نگهداری هستند.
قابلیت توسعه: با رعایت این اصول، افزودن قابلیت‌های جدید به کدهای موجود بدون تاثیر منفی بر ساختار کلی امکان‌پذیر است.
کاهش خطا: جداسازی مسئولیت‌ها و استفاده از الگوهای طراحی باعث کاهش احتمال بروز خطاهای منطقی و ساختاری می‌شود.
تست‌پذیری: کدهای منظم و جداشده آسان‌تر قابل تست هستند و این امر به افزایش کیفیت نرم‌افزار کمک می‌کند.
توسعه تیمی: در پروژه‌های بزرگ‌تر و تیم‌های توسعه‌دهنده متعدد، رعایت این اصول باعث می‌شود که همه اعضای تیم بتوانند به‌راحتی با کد کار کنند و تغییرات را اعمال کنند.

نتیجه‌گیری

در نهایت، تسلط بر ساختارهای داده‌ای و شیءگرایی در Swift نه تنها به شما امکان می‌دهد کدهایی کارآمد و بهینه بنویسید، بلکه پایه‌ای قوی برای طراحی سیستم‌های پیچیده و مقیاس‌پذیر فراهم می‌کند. با درک عمیق تفاوت‌ها و کاربردهای کلاس‌ها و استراکچرها، استفاده صحیح از خصوصیات و متدها، و پیاده‌سازی اصول SOLID و الگوهای طراحی، می‌توانید نرم‌افزارهایی با کیفیت بالا، قابل نگهداری و توسعه‌پذیر ایجاد کنید. همچنین، انتخاب صحیح معماری شیءگرا مانند MVC، MVVM یا VIPER می‌تواند به سازمان‌دهی بهتر کدها و افزایش بهره‌وری تیم توسعه‌دهنده کمک شایانی کند.

با ادامه مطالعه و تمرین مداوم، همراه با استفاده از منابع آموزشی معتبر و مشارکت در جوامع برنامه‌نویسی، می‌توانید به تسلط کامل‌تری بر مفاهیم شیءگرایی در Swift دست یابید و آن‌ها را به‌طور مؤثر در پروژه‌های خود به کار ببرید. این دانش، کلید موفقیت در توسعه نرم‌افزارهای مدرن و پاسخگو به نیازهای کاربران در دنیای رقابتی امروز است.

آموزش ساختارهای داده‌ای و شیءگرایی در Swift

دیدگاه های شما

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *