افکاری در مورد SOLID – حرف “I”

در این بخش پنجم از تأملات من، به ترتیب پیشنهاد شده توسط مخفف SOLID، با حرف “I” ادامه می دهیم. من از TypeScript در مثال ها استفاده خواهم کرد.
در این مقاله
اصل جداسازی رابط
مثال انتزاعی
مثال فنی (Front-End)
مثال فنی (Back-End)
مثال شخصی
مثال کاربردی
کاربردها
افکار نهایی
اصل جداسازی رابط
یک نقطه شروع بسیار مهم این واقعیت است که این اصل است تنها اصل متمرکز بر رابط ها، نه کلاس ها. انتظار می رود که خواننده این تفاوت را درک کند، اما یک خلاصه بسیار کاربردی این است: رابط ها تعریف می کنند که یک کلاس باید چه چیزی را پیاده سازی کند.
اصل تفکیک واسط این را پیشنهاد می کند یک کلاس نباید به روش هایی که به آن نیاز ندارد وابسته باشد، و آن شخص باید ترجیح دهد چندین رابط بر روی یک رابط واحد با چندین مسئولیت.
جالب ترین جنبه این است که چگونه این اصل با موضوع اصل اول مطابقت دارد. مسئولیت واحد. هر دو ایده تفکیک مسئولیت ها را ترویج می کنند و توسعه سیستم را به سمت تضمین مقیاس پذیری طبقاتی هدایت می کنند. مدیریت یک کلاس یا رابط با مسئولیتهای زیاد طبیعتاً پیچیدهتر است، زیرا یک تغییر نامناسب میتواند عوارض جانبی نامطلوب زیادی ایجاد کند.
بیایید با مثال هایی بفهمیم که چگونه می توانیم این اصل را شناسایی و اعمال کنیم.
مثال انتزاعی
بیایید به کار خود ادامه دهیم کتابخانه مثال. این بار تصور کنید که این کتابخانه نه تنها کتاب بلکه دی وی دی و بلوری فیلم و سریال نیز دارد. خوب، در این سناریو:
- هر یک مورد کتابخانه باید نقشه برداری شود؛
- ما می خواهیم نام هر مورد را از طریق یک روش بدانیم.
- برای کتاب ها می خواهیم تعداد صفحات را بدانیم.
- برای فیلمها و سریالها میخواهیم مدت زمان آن را بدانیم.
🔴اجرای نادرست
// Let's imagine an interface that requires the implementation of three methods.
interface LibraryItem {
getName(): string; // For both books and series/movies, we want to know the item's name.
getPages(): number; // Only for books, how many pages it has.
getDuration(): number; // Only for series and movies, what the duration is in minutes.
}
// Now, let's create our Book class, implementing the LibraryItem interface.
// This will force us to comply with what the LibraryItem contains. Let's follow along.
class Book implements LibraryItem {
constructor(private title: string, private pages: number) {}
// No problems with this method.
getName() {
return this.title;
}
// No problems with this method.
getPages(): number {
return this.pages;
}
getDuration(): number {
// PRINCIPLE VIOLATION: The getDuration method, although it belongs to library items,
// doesn't make sense in the context of books and will not be implemented.
// Therefore, books are forced to depend on and implement a method they don't use.
throw new Error("Books do not have a duration in minutes");
}
}
class DVD implements LibraryItem {
constructor(private title: string, private duration: number) {}
// No problems with this method.
getName(): string {
return this.title;
}
// PRINCIPLE VIOLATION: Same observation as the above item, but now from the perspective
// of movies and series, which don't have a number of pages but are forced to implement.
getPages(): number {
throw new Error("DVDs do not have a number of pages");
}
// No problems with this method.
getDuration(): number {
return this.duration;
}
}
🢢 اجرای صحیح
// It is preferable to segregate the interfaces. It is better to have multiple interfaces, each with its own responsibility,
// than a single one that forces classes to implement methods they don't need.
interface LibraryItem {
getName(): string; // Common method for all.
}
interface BookItem {
getPages(): number; // Specific method for books.
}
interface DVDItem {
getDuration(): number; // Specific method for DVDs.
}
// Now, each class implements only what it uses.
class Book implements LibraryItem, BookItem {
constructor(private title: string, private pages: number) {}
getName() {
return this.title;
}
getPages(): number {
return this.pages;
}
}
class DVD implements LibraryItem, DVDItem {
constructor(private title: string, private duration: number) {}
getName(): string {
return this.title;
}
getDuration(): number {
return this.duration;
}
}
مثال فنی (Front-End)
فرض کنید 3 نوع دکمه در برنامه خود داریم: دکمه اصلی، IconButton و دکمه تعویض. اگر همه آنها به یک رابط اصلی وابسته باشند، می توانیم مشکلاتی را مشاهده کنیم.
🔴اجرای نادرست
// Interface too generic for the various types of buttons that exist.
interface Button {
render(): void; // Method to render the button.
setLabel(label: string): void; // Method to set the button's label.
setIcon(icon: string): void; // Method to associate an icon with the button.
toggle(): void; // Method for toggle buttons, to switch on/off.
}
class PrimaryButton implements Button {
constructor(private label: string) {}
render(): void {
console.log("Rendering button...", this.label);
}
setLabel(label: string): void {
this.label = label;
}
// PRINCIPLE VIOLATION: PrimaryButton doesn't support icons but is forced to implement the method.
setIcon(icon: string): void {
throw new Error("This button does not support icons");
}
// PRINCIPLE VIOLATION: Same observation as above.
toggle(): void {
throw new Error("This button does not support toggle");
}
}
// PRINCIPLE VIOLATION: Below, we have two more classes, IconButton and ToggleButton, which exemplify the opposite of PrimaryButton.
// Each implements its respective method, setIcon and toggle, but is also forced to implement methods they
// don't use.
class IconButton implements Button {
constructor(private label: string, private icon: string) {}
render(): void {
console.log("Rendering button...", this.label, this.icon);
}
setLabel(label: string): void {
this.label = label;
}
setIcon(icon: string): void {
this.icon = icon;
}
toggle(): void {
throw new Error("This button does not support toggle");
}
}
class ToggleButton implements Button {
constructor(private label: string, private state: boolean) {}
render(): void {
console.log("Rendering button...", this.label, this.state);
}
setLabel(label: string): void {
this.label = label;
}
setIcon(icon: string): void {
throw new Error("This button does not support icons");
}
toggle(): void {
this.state = !this.state;
}
}
🢢 اجرای صحیح
// The simplicity and elegance of the solution lie in segregating into multiple interfaces, unifying in the
// Button interface what is truly generic.
interface Button {
render(): void;
setLabel(label: string): void;
}
interface WithIcon {
setIcon(icon: string): void;
}
interface WithToggle {
toggle(): void;
}
// Classes now only implement the interfaces they need.
class PrimaryButton implements Button {
constructor(private label: string) {}
render(): void {
console.log("Rendering button...", this.label);
}
setLabel(label: string): void {
this.label = label;
}
}
class IconButton implements Button, WithIcon {
constructor(private label: string, private icon: string) {}
render(): void {
console.log("Rendering button...", this.label, this.icon);
}
setLabel(label: string): void {
this.label = label;
}
setIcon(icon: string): void {
this.icon = icon;
}
}
class ToggleButton implements Button, WithToggle {
constructor(private label: string, private state: boolean) {}
render(): void {
console.log("Rendering button...", this.label, this.state);
}
setLabel(label: string): void {
this.label = label;
}
toggle(): void {
this.state = !this.state;
}
}
مثال فنی (Back-End)
بیایید با این فرض شروع کنیم که تراکنشها را میتوان روی پایگاههای داده رابطهای انجام داد اما روی پایگاههای داده غیررابطهای نه. ما تصدیق میکنیم که این یک حقیقت مطلق نیست (به شدت به مدل فروشنده و ذخیرهسازی بستگی دارد)، اما برای اهداف آموزشی، فرض میکنیم که چنین است.
🔴اجرای نادرست
// Generic interface for databases, implementing connections, queries, and transactions.
interface Database {
connect(): void;
disconnect(): void;
runQuery(query: string): unknown;
startTransaction(): void;
commitTransaction(): void;
rollbackTransaction(): void;
}
// For relational databases, all implementations work.
class RelationalDatabase implements Database {
connect(): void {
console.log("Successfully connected");
}
disconnect(): void {
console.log("Successfully disconnected");
}
runQuery(query: string): unknown {
console.log(`Executing query: ${query}`);
return { ... };
}
startTransaction(): void {
console.log("Transaction - Started");
}
commitTransaction(): void {
console.log("Transaction - Committed");
}
rollbackTransaction(): void {
console.log("Transaction - Rolled back");
}
}
class NonRelationalDatabase implements Database {
connect(): void {
console.log("Successfully connected");
}
disconnect(): void {
console.log("Successfully disconnected");
}
runQuery(query: string): unknown {
console.log(`Executing query: ${query}`);
return { ... };
}
// PRINCIPLE VIOLATION: If transactions don't work for all database types, why is it part of the generic interface, forcing classes to implement it?
startTransaction(): void {
throw new Error("Non-relational databases do not support transactions");
}
commitTransaction(): void {
throw new Error("Non-relational databases do not support transactions");
}
rollbackTransaction(): void {
throw new Error("Non-relational databases do not support transactions");
}
}
🢢 اجرای صحیح
// We an still have a generic interface, but we segregate what is considered specific.
interface Database {
connect(): void;
disconnect(): void;
}
interface DatabaseQueries {
runQuery(query: string): unknown;
}
interface DatabaseTransactions {
startTransaction(): void;
commitTransaction(): void;
rollbackTransaction(): void;
}
class RelationalDatabase implements Database, DatabaseQueries, DatabaseTransactions {
connect(): void {
console.log("Successfully connected");
}
disconnect(): void {
console.log("Successfully disconnected");
}
runQuery(query: string): unknown {
console.log(`Executing query: ${query}`);
return { ... };
}
startTransaction(): void {
console.log("Transaction - Started");
}
commitTransaction(): void {
console.log("Transaction - Committed");
}
rollbackTransaction(): void {
console.log("Transaction - Rolled back");
}
}
// Now, our non-relational database only implements what makes sense.
class NonRelationalDatabase implements Database, DatabaseQueries {
connect(): void {
console.log("Successfully connected");
}
disconnect(): void {
console.log("Successfully disconnected");
}
runQuery(query: string): unknown {
console.log(`Executing query: ${query}`);
return { ... };
}
}
مثال شخصی
که در کارت سوپر ماریو، مواردی وجود دارد که مختص شخصیت های خاصی است – رفتاری که ممکن است مشکوک باشد، اما تمرکز این مقاله بر آن نیست. در این سناریو چگونه می توانیم رابط های این موارد را پیاده سازی کنیم و به اصل پایبند باشیم؟
🔴اجرای نادرست
interface Items {
throwShell(): void; // Item that any character can have.
throwFire(): void; // Exclusive item of Bowser, being a fireball.
throwMushroom(): void; // Exclusive item of Peach (at the time Princess Toadstool) and Toad.
}
// PRINCIPLE VIOLATION: We will encounter several errors in each of the specific scenarios.
class Mario implements Items {
throwShell(): void {
console.log('Throwing "Shell" item');
}
throwFire(): void {
throw new Error("Mario does not have access to this item.");
}
throwMushroom(): void {
throw new Error("Mario does not have access to this item.");
}
}
class Bowser implements Items {
throwShell(): void {
console.log('Throwing "Shell" item');
}
throwFire(): void {
console.log('Throwing "Fire" item');
}
throwMushroom(): void {
throw new Error("Bowser does not have access to this item.");
}
}
class Princess implements Items {
throwShell(): void {
console.log('Throwing "Shell" item');
}
throwFire(): void {
throw new Error("Princess does not have access to this item.");
}
throwMushroom(): void {
console.log('Throwing "Mushroom" item');
}
}
🢢 اجرای صحیح
// We can split our single interface into multiple interfaces, separating what's generic from what's specific.
interface CommonItems {
throwShell(): void;
}
interface FireSpecialItems {
throwFire(): void;
}
interface MushroomSpecialItems {
throwMushroom(): void;
}
مثال کاربردی
بیایید تصور کنیم یک رابط پردازشگر داده. ما می خواهیم هر دو فایل JSON و CSV را تجزیه کنیم، اما هر کدام ویژگی خاص خود را دارند. اگر گزینه های هر یک از آنها را به صورت یکپارچه اجرا کنیم، ممکن است اصل را نقض کنیم.
🔴اجرای نادرست
// In this interface, we define a function that processes data.
type DataProcessor = (
data: string, // The data to be processed.
jsonToObject: boolean, // Only for JSON, indicating whether to convert to object.
csvSeparator: string // Only for CSV, indicating the column separator.
) => string[];
// PRINCIPLE VIOLATION: Every function defined based on this interface will be dependent on
// parameters that it may not need. A JSON processor, or a CSV processor, will need to
// implement those parameters regardless.
const jsonProcessor: DataProcessor = (data, jsonToObject, csvSeparator) => {
let result = validateJSON(data);
if (jsonToObject) {
result = transformJSON(result);
}
return result;
};
const csvProcessor: DataProcessor = (data, jsonToObject, csvSeparator) => {
let result = validateJSON(data);
result = transformCSV(result, csvSeparator);
return result;
};
// Note that function calls are forced to pass unnecessary parameters.
const json = jsonProcessor(jsonData, true, false);
const csv = csvProcessor(csvData, false, ",");
🢢 اجرای صحیح
// With functional programming, there are several ways to approach this solution.
// Here, I chose to segregate the interfaces into option objects.
type DataProcessorJSONOptions = {
toObject: boolean;
};
type DataProcessorCSVOptions = {
separator: string;
};
type DataProcessor = (
data: string,
// Now, the second parameter has options for each type, being optional.
options: {
json?: DataProcessorJSONOptions;
csv?: DataProcessorCSVOptions;
}
) => string[];
const jsonProcessor: DataProcessor = (data, { json }) => {
let result = validateJSON(data);
if (json?.toObject) {
result = transformJSON(result);
}
return result;
};
const csvProcessor: DataProcessor = (data, { csv }) => {
let result = validateJSON(data);
result = transformCSV(result, csv?.separator);
return result;
};
// As I mentioned, there are other ways to solve this problem.
// This would be a basic approach, using optional parameters.
// Another way would be to use Union Types or something similar.
const json = jsonProcessor(jsonData, { json: { toObject: true } });
const csv = csvProcessor(csvData, { csv: { separator: "," } });
کاربردها
به عنوان تنها اصل اعمال شده در رابط ها، شخصاً پتانسیل بسیار جالبی را می بینم. موقعیتهای نامطلوب این اصل را میتوان در کلاسهای بسیار پیچیده یا حتی در نتیجه عدم بکارگیری سایر اصول SOLID – همانطور که به آن اشاره کردم، عمدتاً اولی، یافت.
تمرین تعریف رابط های کلاس قبل از اجرای موثر آنها می تواند به شناسایی این مشکلات کمک کند. این مهم است که بپرسیم پیاده سازی چقدر عمومی خواهد بود و روش ها چقدر می توانند در سناریوهای مختلف قابل استفاده مجدد باشند. امروزه ما نیز می توانیم خیلی با آن کار کنیم روش های اختیاری، که می تواند محافظی برای چنین سناریوهایی باشد، اما ممکن است منجر به ایجاد نیاز به “نوع ژیمناستیک” برای بررسی اینکه آیا روش اجرا شده است یا خیر.
افکار نهایی
بحث بسیار خوبی در مورد وجود دارد تعمیم در جنبههای مختلف مهندسی نرمافزار، و این قطعاً ضروری است – مشکل بیش از حد است، و همچنین زمانی که مشخص نیست خط مرزی را کجا ترسیم کنید. اگر همه چیز را عمومی بدانیم، آنگاه آنچه خاص است، تمام سناریوهای دیگر را تحت تأثیر قرار میدهد. در غیر این صورت، اگر همه چیز مشخص باشد، انبوهی از کلاس ها برای نگهداری خواهیم داشت و وجود صحیح آنها را از دست می دهیم.
ایده آل این است که از این اصل به عنوان نقطه شروع برای ساخت کلاس های جدید با اهداف مختلف استفاده کنیم: چه روش هایی خواهم داشت؟ در چه سناریوهایی اعمال خواهند شد؟ آیا کلاس ها را مجبور می کنم چیزی را اجرا کنند که برای آنها مفید نباشد؟ با شروع از این مفروضات، تشخیص نیاز به اصل کمی ساده تر می شود، که به سادگی پیشنهاد می کند که ما باید مراقب باشید که فقط از روی تعهد کد ایجاد نکنید، اما برای یک هدف اجرا شده است.