Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: ООП

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

12 питань

Інтерфейс - це лише контракт: перелік публічних методів, які клас зобов'язується мати. Реалізації в ньому немає (крім констант), і клас може реалізувати скільки завгодно інтерфейсів.

Абстрактний клас - це недобудований клас: у ньому можуть бути властивості, готові методи й абстрактні методи, які мають дописати нащадки. Успадкувати можна лише один клас.

interface PaymentGateway
{
    public function charge(Money $amount): Receipt;
}

abstract class HttpGateway implements PaymentGateway
{
    public function __construct(protected HttpClient $http) {}

    protected function post(string $path, array $data): array
    {
        return $this->http->post($this->baseUrl() . $path, $data);
    }

    abstract protected function baseUrl(): string;
}

Коли що:

  • Інтерфейс - коли потрібна взаємозамінність: код залежить від PaymentGateway, а конкретний шлюз підставляється ззовні.
  • Абстрактний клас - коли кілька реалізацій ділять спільний код і стан.

Часто вони йдуть разом, як у прикладі: залежать від інтерфейсу, а абстрактний клас лише прибирає повтор між реалізаціями.

Докладніше в документації: Інтерфейси об'єктів

Це модифікатори видимості - хто може звертатися до властивості чи методу:

  • public - будь-хто, зокрема код поза класом.
  • protected - сам клас і його нащадки.
  • private - лише сам клас, де член оголошено. Нащадок його не бачить.
class Account
{
    public function __construct(private int $balance = 0) {}

    public function deposit(int $amount): void
    {
        $this->guard($amount);
        $this->balance += $amount;
    }

    protected function guard(int $amount): void
    {
        if ($amount <= 0) {
            throw new InvalidArgumentException('Сума має бути додатною');
        }
    }
}

Тут баланс не можна змінити напряму ззовні - лише через deposit(), який перевіряє суму. У цьому й сенс інкапсуляції: клас сам стежить за тим, щоб його стан лишався коректним.

Правило за замовчуванням: усе private, доки не з'явиться причина відкрити більше. Відкриту властивість потім важко закрити - на неї вже покладається чужий код.

У PHP 8.4 з'явилася асиметрична видимість: public private(set) int $balance - читати можна звідусіль, а змінювати лише всередині класу.

Докладніше в документації: Видимість

Просування властивостей (PHP 8.0) дозволяє оголосити властивість і присвоїти їй значення прямо в параметрах конструктора - додаючи модифікатор видимості.

// До PHP 8
class Invoice
{
    private string $number;
    private int $total;

    public function __construct(string $number, int $total)
    {
        $this->number = $number;
        $this->total = $total;
    }
}

// PHP 8+
class Invoice
{
    public function __construct(
        private string $number,
        private int $total,
    ) {}
}

Обидва варіанти створюють однакові властивості. Друге - втричі коротше, і неможливо забути присвоєння.

Що варто знати:

  • Просувається лише параметр з модифікатором (public, protected, private, readonly). Звичайні параметри поруч лишаються просто параметрами.
  • Можна поєднувати з readonly: public function __construct(public readonly string $email) {} - незмінний value object у кілька рядків.
  • Тип обов'язковий не формально, але без нього властивість буде нетипізованою - на практиці тип пишуть завжди.
  • Значення за замовчуванням дозволені: private int $attempts = 3.
  • Тіло конструктора може бути й не порожнім - там зручно перевіряти інваріанти:
public function __construct(public readonly int $amount)
{
    if ($amount < 0) {
        throw new InvalidArgumentException('Сума не може бути від\'ємною');
    }
}

У Laravel так оголошують залежності сервісів і контролерів: контейнер підставляє їх через параметри конструктора.

Докладніше в документації: Конструктори й деструктори

Звичайний метод працює з конкретним об'єктом і має доступ до $this - його стану.

Статичний метод належить класу, а не об'єкту: викликається без створення екземпляра (Money::fromCents(100)), і $this у ньому немає.

final class Money
{
    private function __construct(private int $cents) {}

    public static function fromCents(int $cents): self   // статичний: створює об'єкт
    {
        return new self($cents);
    }

    public function add(Money $other): self               // звичайний: працює зі станом
    {
        return new self($this->cents + $other->cents);
    }
}

Коли static доречний:

  • Іменовані конструктори й фабричні методи: Money::fromCents(), Carbon::parse().
  • Чисті допоміжні функції без стану, логічно прив'язані до класу: Str::slug().
  • Константи й кеш на рівні класу - обережно (див. нижче).

Чому зі статикою обережні:

  • Важко підмінити в тестах. Код, що викликає PaymentGateway::charge(), жорстко прив'язаний до класу; залежність, передана в конструктор, легко замінюється на фейк.
  • Статичні властивості - глобальний стан. Вони живуть увесь процес: у довгоживучих воркерах і Octane значення «перетікає» між запитами.
  • Приховані залежності: зі сигнатури класу не видно, що він користується статикою іншого класу.

Фасади Laravel (Cache::get()) лише виглядають статичними: насправді це виклик методу об'єкта з контейнера, і в тестах його можна підмінити (Cache::fake(), Cache::shouldReceive()).

Докладніше в документації: Ключове слово static

Трейт - це шматок коду (методи, властивості), який підставляється в клас так, ніби його написали там. Механізм повторного використання без успадкування: клас може взяти кілька трейтів.

trait HasSlug
{
    public function slug(): string
    {
        return Str::slug($this->title);
    }
}

class Post
{
    use HasSlug;
}

Підводні камені:

  • Неявні залежності. Трейт вище звертається до $this->title, але ніде цього не оголошує. Клас без title зламається лише під час виконання. Частково рятує абстрактний метод у трейті: abstract public function title(): string;.
  • Конфлікти імен. Два трейти з однаковим методом - фатальна помилка, доки не розв'язати її через insteadof і as.
  • Трейт - не тип. Не можна написати function f(HasSlug $x): перевірити, що об'єкт має поведінку трейту, можна лише через інтерфейс.
  • Прихована складність. Клас із п'ятьма трейтами важко читати: щоб зрозуміти, що в ньому є, треба відкрити шість файлів.

Добре працюють трейти для вузької допоміжної поведінки, як SoftDeletes чи HasFactory у Laravel. Погано - як спосіб розкидати велику модель по файлах: складність лишається, вона просто гірше видна.

Докладніше в документації: Трейти

self:: завжди вказує на клас, у якому написано код. static:: - на клас, через який метод викликали насправді. Друге називають пізнім статичним зв'язуванням.

class Model
{
    public static function create(): static
    {
        return new static();   // клас виклику
    }

    public static function make(): self
    {
        return new self();     // завжди Model
    }
}

class User extends Model {}

User::create(); // User
User::make();   // Model

Де це важливо:

  • Фабричні методи в базовому класі. Eloquent User::create() повертає саме User, бо всередині new static.
  • Перевизначені константи й методи. static::TABLE візьме константу нащадка, self::TABLE - базового класу.
  • Тип повернення static (PHP 8.0) каже аналізатору й IDE, що метод повертає клас виклику - зручно для fluent-інтерфейсів.

Коли self: коли поведінка не має змінюватися в нащадках, наприклад виклик приватного методу. static:: до приватного методу нащадка не дістанеться.

Докладніше в документації: Пізнє статичне зв'язування

Магічні методи - методи з подвійним підкресленням, які PHP викликає сам у певних ситуаціях:

  • __construct, __destruct - створення й знищення об'єкта;
  • __get, __set, __isset, __unset - звернення до недоступної (неіснуючої чи приватної) властивості;
  • __call, __callStatic - виклик недоступного методу;
  • __toString - перетворення на рядок;
  • __invoke - виклик об'єкта як функції: $validator($value);
  • __clone - після clone;
  • __serialize, __unserialize - серіалізація;
  • __debugInfo - що показувати у var_dump().

__get і __call - основа «магії» Eloquent: $user->email читає атрибут з масиву, $user->posts() - зв'язок, User::where(...) через __callStatic перенаправляється в query builder.

Чим вони небезпечні:

  • Опечатки не ловляться. $user->emial не дасть помилки компіляції - лише null чи виняток під час виконання.
  • IDE й статичний аналіз сліпі. Автодоповнення, «перейти до визначення», перевірка типів - не працюють без додаткових підказок (@property, @method у PHPDoc, ide-helper, Larastan).
  • Складно налагоджувати: незрозуміло, звідки береться значення.
  • Продуктивність: магічний виклик повільніший за прямий (рідко має значення, але в гарячих циклах помітно).

Правило: у власному коді віддавати перевагу явним методам і властивостям. Магію - лише там, де вона дає великий виграш у зручності API (як ORM), і з PHPDoc-підказками для інструментів.

Докладніше в документації: Магічні методи

  • final class - від класу не можна успадкуватися.
  • final public function - метод не можна перевизначити в нащадку.
  • final const (PHP 8.1) - константу не можна перевизначити.
final class InvoiceNumberGenerator
{
    public function next(): string { /* ... */ }
}

class Report extends InvoiceNumberGenerator {}  // Fatal error

Чому final за замовчуванням:

  • Успадкування - найсильніший зв'язок. Нащадок залежить від усіх деталей предка. Будь-яка зміна в класі може зламати чужого нащадка, про якого автор навіть не знає.
  • Свобода змінювати клас. Якщо клас final, його внутрішню будову (protected-методи, порядок викликів) можна змінювати вільно: зовні доступний лише публічний API.
  • Свідомі рішення. Прибрати final, коли з'явилася реальна потреба в успадкуванні, - одна правка. Повернути final, коли на клас уже успадковуються, - ламаюча зміна.
  • Підштовхує до композиції й інтерфейсів замість ієрархій.

Типове заперечення - «а як мокати в тестах?». Мокати слід інтерфейси, а не конкретні класи. Якщо клас треба підмінити, - він реалізує інтерфейс, і тест підставляє іншу реалізацію. Для зовнішніх final-класів бібліотек є обхідні шляхи (пакет dg/bypass-finals), але це сигнал щодо дизайну.

Де final не ставлять: базові класи, призначені для успадкування (Model, Controller, абстрактні класи), і класи, які фреймворк проксує чи розширює під час виконання (наприклад, моделі Doctrine з лінивими проксі).

Докладніше в документації: Ключове слово final

Успадкування - найсильніший зв'язок між класами: нащадок залежить від усіх деталей предка, зокрема захищених. Будь-яка зміна в базовому класі може тихо зламати десяток нащадків - це називають проблемою крихкого базового класу.

Типові симптоми:

  • Ієрархія росте вглиб: Report → PdfReport → ColoredPdfReport → ... Нова комбінація ознак - новий клас.
  • Нащадок перевизначає метод, щоб «вимкнути» поведінку предка, яка йому не потрібна.
  • Базовий клас обростає protected-хелперами, бо так зручніше нащадкам.

Композиція - об'єкт отримує інші об'єкти і делегує їм роботу:

final class ReportExporter
{
    public function __construct(
        private Formatter $formatter,   // PDF, CSV, HTML
        private Storage $storage,       // диск, S3
    ) {}

    public function export(Report $report): string
    {
        return $this->storage->put($this->formatter->format($report));
    }
}

Тепер формат і сховище комбінуються вільно, кожне тестується окремо, і змінити одне можна, не чіпаючи іншого.

Успадкування доречне, коли є справжнє «є різновидом» і спільний контракт стабільний: власні винятки від RuntimeException, моделі від Model. Корисна звичка - робити класи final за замовчуванням: тоді успадкування стає свідомим рішенням, а не випадковістю.

Докладніше в документації: Успадкування об'єктів

readonly-властивість (PHP 8.1) можна присвоїти лише один раз і лише всередині класу - зазвичай у конструкторі. Далі будь-яка спроба змінити її кидає Error. readonly class (PHP 8.2) робить такими всі властивості класу.

final readonly class Money
{
    public function __construct(
        public int $amount,
        public string $currency,
    ) {}
}

Що варто знати:

  • readonly захищає саму властивість, а не вміст об'єкта в ній: якщо там лежить змінюваний об'єкт, його поля змінювати можна.
  • Властивість мусить мати тип, і значення за замовчуванням у неї бути не може.

Змінені копії («withers»). Незмінний об'єкт не редагують, а створюють новий з іншим значенням:

public function withAmount(int $amount): static
{
    return new static($amount, $this->currency);
}

Через clone раніше так не виходило: копія мала ту саму вже ініціалізовану властивість. З PHP 8.3 readonly-властивості можна переприсвоїти в __clone(), а PHP 8.5 додав clone з переліком нових значень - clone($this, ['amount' => $amount]), без ручного конструктора.

Навіщо це все: незмінні value objects (гроші, діапазони дат, адреси) можна спокійно передавати куди завгодно - ніхто не змінить їх «під ногами» в іншому місці коду.

Докладніше в документації: Readonly-властивості

clone $obj створює новий об'єкт з тими самими значеннями властивостей. Але копія поверхнева: якщо властивість містить інший об'єкт, копіюється лише посилання на нього - обидва об'єкти ділять той самий вкладений об'єкт.

final class Order
{
    public function __construct(public DateTime $createdAt, public array $items) {}
}

$a = new Order(new DateTime('2026-01-01'), ['book']);
$b = clone $a;

$b->items[] = 'pen';                 // масив скопійовано: $a->items не змінився
$b->createdAt->modify('+1 day');     // об'єкт спільний: $a->createdAt теж змінився!

Масиви копіюються (за значенням), об'єкти всередині - ні.

__clone() викликається на копії після клонування - там роблять глибоку копію вкладених об'єктів:

public function __clone(): void
{
    $this->createdAt = clone $this->createdAt;
}

Де це стає проблемою:

  • «Незмінні» об'єкти з змінюваними частинами. Wither, що робить clone $this і змінює одне поле, ділить усі вкладені змінювані об'єкти з оригіналом.
  • Ресурси й з'єднання. Клон об'єкта з відкритим файлом чи з'єднанням з базою ділить той самий ресурс; закриття в одному ламає інший.
  • Ідентичність: клон Eloquent-моделі зберігає той самий первинний ключ. Для копії запису в базі є replicate().

Як уникнути проблем: робити вкладені значення незмінними (DateTimeImmutable, readonly value objects) - тоді поверхнева копія безпечна. З PHP 8.3 readonly-властивості можна переприсвоїти в __clone(), а PHP 8.5 додав clone($obj, ['prop' => $value]) для «зміненої копії» без ручного __clone.

Докладніше в документації: Клонування об'єктів

Property hooks (PHP 8.4) дозволяють прив'язати логіку до читання й запису властивості - без геттерів і сеттерів і без магічних __get/__set.

final class User
{
    public string $email {
        set (string $value) {
            if (! filter_var($value, FILTER_VALIDATE_EMAIL)) {
                throw new InvalidArgumentException('Некоректний email');
            }
            $this->email = mb_strtolower($value);
        }
    }

    public string $displayName {
        get => trim("{$this->firstName} {$this->lastName}");   // віртуальна властивість
    }

    public function __construct(public string $firstName, public string $lastName, string $email)
    {
        $this->email = $email;
    }
}

$user->email = 'Olia@Example.com';   // спрацює set-хук
echo $user->displayName;            // спрацює get-хук

Властивість, що має лише get-хук і не звертається до власного значення, - віртуальна: місця для неї не виділяється.

Асиметрична видимість - різні модифікатори для читання й запису:

final class Order
{
    public private(set) string $status = 'new';   // читати - всім, змінювати - лише класу

    public function ship(): void
    {
        $this->status = 'shipped';
    }
}

Скорочення: private(set) string $status означає public private(set).

Що це змінює:

  • Публічна властивість більше не означає «будь-хто може записати що завгодно» - валідацію й нормалізацію можна додати пізніше, не змінюючи API на методи.
  • Відпадає шаблонний код геттерів для «читається всім, змінюється лише всередині».
  • Хуки можна оголошувати в інтерфейсах: public string $name { get; }.

Обмеження: хуки й readonly не поєднуються, а посилання на властивість з set-хуком (&$obj->prop) заборонене.

Докладніше в документації: Property hooks