Абстракция в ООП: как создать гибкую архитектуру программы

Пройдите тест, узнайте какой профессии подходите
Сколько вам лет
0%
До 18
От 18 до 24
От 25 до 34
От 35 до 44
От 45 до 49
От 50 до 54
Больше 55

Для кого эта статья:

  • Программисты и разработчики, заинтересованные в объектно-ориентированном программировании
  • Студенты и начинающие специалисты в области программирования, обучающиеся концепциям абстракции и ООП
  • Архитекторы программного обеспечения и опытные разработчики, ищущие улучшения в архитектуре своих систем через применение абстракции

    Представьте, что вы управляете бойцовским клубом из одноименного фильма, но вместо бойцов у вас — концепции кода. Абстракция — это тот самый первый боец, который выходит на ринг и устанавливает правила игры для всего объектно-ориентированного программирования. Она позволяет программисту видеть лес, не отвлекаясь на деревья, и работать с сущностями на уровне идей, а не технических деталей. Давайте разберемся, как этот фундаментальный принцип ООП трансформирует хаос кода в элегантную архитектуру. 🧠

Ищете способ перейти от теоретических знаний об абстракции к практическому применению в реальных проектах? Курс Java-разработки от Skypro построен на принципе "от простого к сложному", где вы не просто изучите абстракцию и другие концепции ООП, но и примените их в разработке корпоративных приложений под руководством действующих разработчиков. Получите не только знания, но и опыт проектирования устойчивых программных систем.

Что такое абстракция в ООП и её роль среди принципов программирования

Абстракция — это процесс выделения существенных характеристик объекта, отличающих его от других объектов, и игнорирования несущественных деталей. В контексте ООП абстракция позволяет создать упрощенную модель сложной системы, фокусируясь на том, что объект делает, а не на том, как он это делает.

Этот принцип помогает справиться с растущей сложностью программных систем, позволяя разработчикам мыслить на более высоком уровне и работать с объектами, как с "черными ящиками", которые предоставляют определенные интерфейсы без раскрытия внутренних механизмов их работы.

Дмитрий Ковалев, архитектор программного обеспечения

Несколько лет назад наша команда столкнулась с необходимостью полностью переписать платежную систему, обрабатывающую более миллиона транзакций в день. Исходный код был монолитным, с множеством тесно связанных компонентов и дублирующейся логикой.

Мы решили применить строгий подход к абстракции, создав иерархию платежных провайдеров. Сначала мы определили, что абсолютно все провайдеры должны поддерживать три операции: проверку возможности оплаты, выполнение транзакции и отмену транзакции. Это стало нашим базовым интерфейсом.

Важно отметить, что мы абстрагировались от конкретных технологий, используемых каждым провайдером. Неважно, что внутри — SOAP, REST или вообще прямое подключение к базе данных. Снаружи все провайдеры выглядели одинаково.

Результат оказался впечатляющим. Система стала не только более поддерживаемой — мы смогли добавить семь новых платежных методов всего за квартал, тогда как раньше интеграция одного метода занимала до двух месяцев. А ещё абстракция помогла нам увеличить покрытие кода тестами с 15% до 87% благодаря возможности создавать мок-объекты на основе интерфейсов.

Абстракция не существует в вакууме — она тесно связана с другими принципами ООП и составляет фундамент для их реализации:

Принцип ООП Связь с абстракцией Практическое значение
Инкапсуляция Абстракция определяет, что должно быть инкапсулировано Скрытие реализации и защита данных от внешнего вмешательства
Наследование Абстракция определяет общие свойства и методы, которые наследуются Создание иерархий классов и повторное использование кода
Полиморфизм Абстракция создает основу для полиморфного поведения Возможность использовать объекты разных классов через общий интерфейс

Уровни абстракции в программировании можно представить как своеобразную пирамиду:

  • Высокий уровень — бизнес-объекты и их поведение (пользователь, заказ, корзина)
  • Средний уровень — технические абстракции (хранилище, сессия, подключение)
  • Низкий уровень — системные абстракции (файл, поток, сокет)

Чем выше уровень абстракции, тем меньше технических деталей видно программисту и тем ближе код к бизнес-языку. Именно это и делает принципы ООП в программировании столь ценными для разработки крупных систем. 🧩

Пошаговый план для смены профессии

Механизмы реализации абстракции: интерфейсы и абстрактные классы

Для реализации абстракции в объектно-ориентированных языках программирования обычно используются два основных механизма: интерфейсы и абстрактные классы. Понимание их различий и правильное применение — ключ к созданию гибкой и поддерживаемой архитектуры.

Интерфейсы

Интерфейс — это контракт, который определяет, какие методы должен реализовать класс, но не указывает, как эти методы должны работать. Это чистая абстракция, своего рода "чертеж" для класса.

Java
Скопировать код
// Пример интерфейса на Java
public interface PaymentProcessor {
boolean validatePayment(Payment payment);
TransactionResult processPayment(Payment payment);
boolean cancelPayment(String transactionId);
}

Основные характеристики интерфейсов:

  • Содержат только объявления методов без реализации (с Java 8 могут содержать default-методы)
  • Могут содержать константы (public static final)
  • Класс может реализовывать множество интерфейсов
  • Не могут содержать конструкторы или состояние (поля)

Абстрактные классы

Абстрактный класс — это промежуточное звено между интерфейсом и конкретным классом. Он может содержать как абстрактные методы (без реализации), так и конкретные методы с реализацией.

Java
Скопировать код
// Пример абстрактного класса на Java
public abstract class AbstractPaymentProcessor {
protected Logger logger = LoggerFactory.getLogger(this.getClass());

public abstract boolean validatePayment(Payment payment);

public TransactionResult processPayment(Payment payment) {
if (!validatePayment(payment)) {
logger.error("Payment validation failed");
return new TransactionResult(false, "Validation failed");
}

// Общая логика обработки платежа
return doProcessPayment(payment);
}

protected abstract TransactionResult doProcessPayment(Payment payment);

public boolean cancelPayment(String transactionId) {
logger.info("Cancelling payment: " + transactionId);
// Общая логика отмены платежа
return doCancelPayment(transactionId);
}

protected abstract boolean doCancelPayment(String transactionId);
}

Основные характеристики абстрактных классов:

  • Могут содержать абстрактные и неабстрактные методы
  • Могут содержать поля, конструкторы и инициализаторы
  • Класс может наследоваться только от одного абстрактного класса
  • Могут определять доступ к членам (public, protected, private)

Сравнение интерфейсов и абстрактных классов:

Критерий Интерфейс Абстрактный класс
Множественное наследование Поддерживается Не поддерживается
Состояние (поля) Только константы Любые поля
Методы Абстрактные (с Java 8 — также default и static) Абстрактные и конкретные
Конструкторы Нет Да
Модификаторы доступа Все методы публичные Любые модификаторы доступа

Выбор между интерфейсом и абстрактным классом зависит от конкретной ситуации:

  • Используйте интерфейс, когда:
  • Нужно определить контракт для несвязанных классов
  • Требуется множественное наследование
  • Определяете тип "способности" (например, Comparable, Serializable)

  • Используйте абстрактный класс, когда:
  • Есть общий код, который должны использовать подклассы
  • Нужно определить состояние (поля), доступное подклассам
  • Классы в иерархии имеют сильную связь "is-a" (является)

В современных ООП языках оба механизма предоставляют мощные инструменты для абстракции, и их комбинированное использование (классы, реализующие интерфейсы и наследующиеся от абстрактных классов) позволяет создавать гибкие и расширяемые архитектуры. ООП: основные понятия и принципы находят свое практическое воплощение именно через эти механизмы. 🔧

Практическое применение абстракции на разных языках программирования

Абстракция реализуется по-разному в различных языках программирования, но основная концепция остается неизменной. Рассмотрим, как этот принцип ООП в программировании применяется на практике в популярных языках.

Java

Java предоставляет мощные инструменты для абстракции через интерфейсы и абстрактные классы.

Java
Скопировать код
// Интерфейс для чтения данных
public interface DataReader {
byte[] read(String source);
void close();
}

// Абстрактная реализация с общей логикой
public abstract class AbstractDataReader implements DataReader {
protected boolean isClosed = false;

@Override
public void close() {
if (!isClosed) {
doClose();
isClosed = true;
}
}

// Абстрактный метод, который должен быть реализован подклассами
protected abstract void doClose();
}

// Конкретная реализация для файлов
public class FileDataReader extends AbstractDataReader {
private FileInputStream inputStream;

public FileDataReader(String filePath) throws FileNotFoundException {
this.inputStream = new FileInputStream(filePath);
}

@Override
public byte[] read(String source) {
// Реализация чтения из файла
// ...
return data;
}

@Override
protected void doClose() {
try {
inputStream.close();
} catch (IOException e) {
// Обработка ошибки
}
}
}

C#

C# также поддерживает абстракцию через интерфейсы и абстрактные классы, но имеет некоторые особенности, например, свойства и явную реализацию интерфейсов.

csharp
Скопировать код
// Интерфейс с определением свойств
public interface ILogger {
LogLevel MinLevel { get; set; }
void Log(string message, LogLevel level);
bool IsEnabled(LogLevel level);
}

// Абстрактная реализация
public abstract class BaseLogger : ILogger {
public LogLevel MinLevel { get; set; } = LogLevel.Info;

public bool IsEnabled(LogLevel level) {
return level >= MinLevel;
}

public void Log(string message, LogLevel level) {
if (IsEnabled(level)) {
WriteLog($"[{level}] {message}");
}
}

// Абстрактный метод для реализации в подклассах
protected abstract void WriteLog(string formattedMessage);
}

// Конкретная реализация
public class ConsoleLogger : BaseLogger {
protected override void WriteLog(string formattedMessage) {
Console.WriteLine($"{DateTime.Now}: {formattedMessage}");
}
}

Python

Python поддерживает абстракцию через модуль abc (Abstract Base Classes) и миксины, хотя формально интерфейсов как таковых в языке нет.

Python
Скопировать код
from abc import ABC, abstractmethod

# Абстрактный базовый класс
class Shape(ABC):
@abstractmethod
def area(self):
pass

@abstractmethod
def perimeter(self):
pass

def describe(self):
return f"This shape has area {self.area()} and perimeter {self.perimeter()}"

# Конкретная реализация
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height

def area(self):
return self.width * self.height

def perimeter(self):
return 2 * (self.width + self.height)

# Использование
rect = Rectangle(5, 10)
print(rect.describe()) # "This shape has area 50 and perimeter 30"

Алексей Петров, ведущий разработчик

Мой опыт работы с крупной системой управления складом показал, насколько важна правильная абстракция. У нас были десятки разных складских операций — приём, отгрузка, перемещение, инвентаризация — и каждая требовала особой обработки.

Изначально система была построена без чёткой абстракции. Каждый тип операции имел свой класс с уникальной логикой, но при этом около 70% кода дублировалось. Когда мне поручили рефакторинг, я решил применить единый интерфейс для всех операций:

Java
Скопировать код
public interface WarehouseOperation {
ValidationResult validate();
void prepare();
OperationResult execute();
void complete();
void rollback();
}

Затем создал абстрактный класс BaseWarehouseOperation, который реализовал общую для всех операций логику. Конкретные классы операций наследовались от него, переопределяя только специфичные методы.

Результат превзошёл ожидания. Объем кода сократился на 40%, а время на разработку новых типов операций — с недели до 1-2 дней. Когда появилось требование добавить аудит всех операций, нам потребовалось изменить только базовый класс, а не модифицировать каждый тип операции по отдельности.

Самым ценным уроком было то, что абстракцию нужно выстраивать на основе поведения, а не структуры данных. Мы абстрагировали жизненный цикл операции (validate → prepare → execute → complete/rollback), а не её содержимое, и это оказалось правильным решением.

TypeScript

TypeScript расширяет JavaScript, добавляя статическую типизацию и поддержку интерфейсов для абстракции.

typescript
Скопировать код
// Интерфейс для HTTP-клиента
interface HttpClient {
get<T>(url: string, options?: RequestOptions): Promise<T>;
post<T>(url: string, data: any, options?: RequestOptions): Promise<T>;
put<T>(url: string, data: any, options?: RequestOptions): Promise<T>;
delete<T>(url: string, options?: RequestOptions): Promise<T>;
}

// Абстрактный класс с базовой реализацией
abstract class BaseHttpClient implements HttpClient {
protected abstract request<T>(method: string, url: string, data?: any, options?: RequestOptions): Promise<T>;

async get<T>(url: string, options?: RequestOptions): Promise<T> {
return this.request<T>('GET', url, null, options);
}

async post<T>(url: string, data: any, options?: RequestOptions): Promise<T> {
return this.request<T>('POST', url, data, options);
}

async put<T>(url: string, data: any, options?: RequestOptions): Promise<T> {
return this.request<T>('PUT', url, data, options);
}

async delete<T>(url: string, options?: RequestOptions): Promise<T> {
return this.request<T>('DELETE', url, null, options);
}
}

// Конкретная реализация
class FetchHttpClient extends BaseHttpClient {
protected async request<T>(method: string, url: string, data?: any, options?: RequestOptions): Promise<T> {
const response = await fetch(url, {
method,
body: data ? JSON.stringify(data) : undefined,
headers: options?.headers,
// Другие параметры
});

if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}

return response.json();
}
}

Несмотря на различия в синтаксисе и механизмах реализации, абстракция в разных языках программирования преследует одни и те же цели:

  • Сокрытие сложности и деталей реализации
  • Определение четких границ и контрактов между компонентами
  • Обеспечение расширяемости и гибкости системы
  • Упрощение тестирования через возможность подмены реализаций

Практическое применение абстракции не ограничивается языковыми конструкциями — это в первую очередь подход к проектированию, который должен соответствовать потребностям конкретной системы и ее эволюции. ООП: основные понятия и принципы требуют тщательного осмысления в каждом проекте. 🏗️

Абстракция и другие принципы ООП: синергия в проектировании

Абстракция не существует в изоляции — она тесно переплетается с другими фундаментальными принципами ООП, создавая синергетический эффект в проектировании программных систем. Понимание этих взаимосвязей помогает создавать более элегантные и поддерживаемые решения.

Абстракция и инкапсуляция

Инкапсуляция и абстракция часто рассматриваются вместе, но между ними есть важные различия:

  • Абстракция фокусируется на том, что должен делать объект (его интерфейс и поведение)
  • Инкапсуляция сосредоточена на том, как скрыть внутреннее устройство объекта

При этом они взаимно дополняют друг друга: абстракция определяет, какие части системы следует выделить и представить в виде классов или интерфейсов, а инкапсуляция обеспечивает защиту их внутренних механизмов.

Java
Скопировать код
// Пример синергии абстракции и инкапсуляции
public interface AccountService {
AccountInfo getAccountInfo(String accountId);
TransactionResult deposit(String accountId, BigDecimal amount);
TransactionResult withdraw(String accountId, BigDecimal amount);
}

public class BankAccountService implements AccountService {
// Инкапсулированное состояние
private AccountRepository accountRepository;
private TransactionRepository transactionRepository;
private SecurityService securityService;

// Конструктор для внедрения зависимостей
public BankAccountService(
AccountRepository accountRepository,
TransactionRepository transactionRepository,
SecurityService securityService
) {
this.accountRepository = accountRepository;
this.transactionRepository = transactionRepository;
this.securityService = securityService;
}

@Override
public AccountInfo getAccountInfo(String accountId) {
// Реализация, использующая инкапсулированные компоненты
Account account = accountRepository.findById(accountId);
if (account == null) {
throw new AccountNotFoundException(accountId);
}
return new AccountInfo(account);
}

// Другие методы...
}

Абстракция и наследование

Наследование позволяет создавать иерархии классов, где базовые классы предоставляют абстрактные понятия, а подклассы их конкретизируют. Это один из способов реализации принципа абстракции.

Ключевые аспекты взаимодействия абстракции и наследования:

  • Абстрактные базовые классы определяют общий интерфейс для всей иерархии
  • Специализация через наследование позволяет уточнять абстракции
  • Наследование позволяет повторно использовать код, определенный в абстракциях

Однако чрезмерное использование наследования может привести к проблемам, таким как "хрупкие" иерархии классов и усложнение понимания кода. Поэтому современные подходы часто предпочитают композицию наследованию, что отражено в принципе "Предпочитайте композицию наследованию".

Абстракция и полиморфизм

Полиморфизм — способность объектов с одинаковым интерфейсом иметь различные реализации — напрямую опирается на абстракцию. Фактически, абстракция создает фундамент для полиморфного поведения.

Рассмотрим, как эти принципы работают вместе:

Java
Скопировать код
// Абстракция через интерфейс
public interface Notifier {
void send(String message, String recipient);
}

// Различные реализации
public class EmailNotifier implements Notifier {
@Override
public void send(String message, String recipient) {
// Отправка по email
}
}

public class SmsNotifier implements Notifier {
@Override
public void send(String message, String recipient) {
// Отправка через SMS
}
}

public class PushNotifier implements Notifier {
@Override
public void send(String message, String recipient) {
// Отправка через push-уведомления
}
}

// Полиморфное использование
public class NotificationService {
private List<Notifier> notifiers;

public NotificationService(List<Notifier> notifiers) {
this.notifiers = notifiers;
}

public void notifyAll(String message, String recipient) {
for (Notifier notifier : notifiers) {
notifier.send(message, recipient); // Полиморфный вызов
}
}
}

В этом примере NotificationService работает с абстракцией Notifier, не зная о конкретных реализациях. Это позволяет легко добавлять новые типы уведомлений без изменения существующего кода.

SOLID и абстракция

Принципы SOLID тесно связаны с абстракцией и формализуют подходы к её правильному применению:

Принцип SOLID Связь с абстракцией
S — Single Responsibility (Единственная ответственность) Помогает определить правильный уровень абстракции для класса
O — Open/Closed (Открытость/закрытость) Абстракции позволяют расширять функциональность без изменения существующего кода
L — Liskov Substitution (Подстановка Лисков) Гарантирует, что конкретные реализации абстракций могут корректно заменять друг друга
I — Interface Segregation (Разделение интерфейсов) Рекомендует создавать узкие абстракции вместо крупных многофункциональных интерфейсов
D — Dependency Inversion (Инверсия зависимостей) Требует зависеть от абстракций, а не от конкретных реализаций

Особенно важен принцип Dependency Inversion, который напрямую касается абстракций:

  • Модули высокого уровня не должны зависеть от модулей низкого уровня. Оба должны зависеть от абстракций.
  • Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

Синергия абстракции с другими принципами ООП создает прочную основу для проектирования гибких, расширяемых и поддерживаемых систем. Принципы ООП в программировании работают наиболее эффективно именно в комплексе, усиливая друг друга. 🔄

Типичные ошибки при использовании абстракции и способы их исправления

Даже опытные разработчики допускают ошибки при применении абстракции. Рассмотрим наиболее распространенные проблемы и способы их решения, чтобы повысить качество вашего кода.

1. Избыточная абстракция

Одна из самых распространенных ошибок — создание абстракций "на всякий случай" или следуя принципу "больше — лучше". Это приводит к появлению ненужных слоев кода, которые усложняют понимание и поддержку.

Симптомы:

  • Классы с префиксами вроде "Abstract", "Base", "Generic", которые имеют только одну конкретную реализацию
  • Интерфейсы, повторяющие структуру конкретных классов один в один
  • Цепочки делегирования через несколько уровней абстракции

Решение:

  • Следуйте принципу YAGNI (You Ain't Gonna Need It) — не создавайте абстракции, пока не возникла реальная потребность
  • Имейте как минимум две различные реализации, прежде чем вводить абстракцию
  • Регулярно рефакторите код, удаляя неиспользуемые абстракции
Java
Скопировать код
// Плохо: избыточная абстракция
interface UserRepository {
User findById(Long id);
List<User> findAll();
void save(User user);
}

class DatabaseUserRepository implements UserRepository {
// Единственная реализация
}

// Лучше: начать с конкретного класса
class UserRepository {
User findById(Long id) {
// Реализация
}

List<User> findAll() {
// Реализация
}

void save(User user) {
// Реализация
}
}

// И выделить интерфейс только при появлении второй реализации

2. Недостаточная абстракция

Противоположная проблема — недостаточное использование абстракций, когда код тесно связан с конкретными реализациями, что затрудняет его изменение и тестирование.

Симптомы:

  • Прямое создание объектов с использованием оператора new вместо инъекции зависимостей
  • Проверки типов и приведения типов (instanceof, as, is)
  • Большие классы, которые выполняют множество различных функций

Решение:

  • Применяйте принцип инверсии зависимостей — зависьте от абстракций, а не от конкретных классов
  • Используйте инъекцию зависимостей вместо прямого создания объектов
  • Разделяйте классы по принципу единственной ответственности
Java
Скопировать код
// Плохо: недостаточная абстракция
class PaymentProcessor {
void processPayment(Order order) {
if (order.getPaymentMethod() == PaymentMethod.CREDIT_CARD) {
CreditCardProcessor processor = new CreditCardProcessor();
processor.charge(order.getCreditCard(), order.getTotal());
} else if (order.getPaymentMethod() == PaymentMethod.PAYPAL) {
PayPalProcessor processor = new PayPalProcessor();
processor.processPayment(order.getPayPalAccount(), order.getTotal());
}
// И так далее для каждого метода оплаты
}
}

// Лучше: использование абстракций
interface PaymentProvider {
void processPayment(Order order);
}

class CreditCardProvider implements PaymentProvider {
@Override
public void processPayment(Order order) {
// Реализация для кредитной карты
}
}

class PayPalProvider implements PaymentProvider {
@Override
public void processPayment(Order order) {
// Реализация для PayPal
}
}

class PaymentProcessor {
private Map<PaymentMethod, PaymentProvider> providers;

// Внедрение провайдеров через конструктор
PaymentProcessor(Map<PaymentMethod, PaymentProvider> providers) {
this.providers = providers;
}

void processPayment(Order order) {
PaymentProvider provider = providers.get(order.getPaymentMethod());
if (provider == null) {
throw new UnsupportedPaymentMethodException(order.getPaymentMethod());
}
provider.processPayment(order);
}
}

3. "Протекающие" абстракции

Термин "протекающая абстракция" (leaky abstraction) описывает ситуацию, когда детали реализации "просачиваются" через абстракцию, нарушая её целостность.

Симптомы:

  • Методы абстракции, которые имеют смысл только для определенных реализаций
  • Необходимость знать детали реализации для корректного использования абстракции
  • Исключения, связанные с конкретными реализациями, выбрасываются через интерфейс

Решение:

  • Проектируйте абстракции на основе поведения, а не структуры данных
  • Применяйте принцип разделения интерфейсов — создавайте узкие, специализированные интерфейсы
  • Используйте адаптеры и фасады для скрытия несовместимых интерфейсов
Java
Скопировать код
// Плохо: протекающая абстракция
interface DataSource {
Connection getConnection(); // Специфично для SQL баз данных
void executeQuery(String sql); // Привязка к SQL
ResultSet getResults(); // Привязка к JDBC
}

// Лучше: более абстрактный интерфейс
interface DataSource<T> {
List<T> query(QueryCriteria criteria);
Optional<T> findOne(QueryCriteria criteria);
void save(T entity);
void delete(T entity);
}

4. Неправильный уровень абстракции

Выбор неправильного уровня абстракции может привести к неудобным API и сложностям при расширении системы.

Симптомы:

  • Абстракции, которые либо слишком общие (и требуют много кода для конкретных случаев), либо слишком специфичные (и не подходят для других сценариев)
  • Необходимость частого изменения абстракций при добавлении новой функциональности
  • "Божественные" интерфейсы с десятками методов

Решение:

  • Фокусируйтесь на бизнес-требованиях и пользовательских сценариях при проектировании абстракций
  • Применяйте принцип разделения интерфейсов
  • Используйте шаблоны проектирования, соответствующие решаемой проблеме

Абстракция — это мощный инструмент, но как и любой инструмент, требует правильного применения. Понимание типичных ошибок и способов их исправления поможет вам создавать более качественные и поддерживаемые системы, полностью реализуя потенциал принципов ООП в программировании. 🛠️

Абстракция — это та область программирования, где искусство встречается с инженерией. Она требует не только технических знаний, но и развитого абстрактного мышления, способности видеть систему как на высоком уровне, так и в деталях. Помните, что правильная абстракция не возникает сразу — это результат постоянного анализа требований, рефакторинга и глубокого понимания предметной области. Стремитесь к балансу: слишком мало абстракции приводит к дублированию кода и тесным связям, слишком много — к ненужной сложности. И главное — не бойтесь экспериментировать, ведь именно через практику приходит истинное мастерство в создании элегантных абстракций.

Читайте также

Проверь как ты усвоил материалы статьи
Пройди тест и узнай насколько ты лучше других читателей
Что такое абстракция в объектно-ориентированном программировании?
1 / 5

Загрузка...