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

  1. Телефонный скрининг. Первым шагом в процессе собеседования обычно является телефонный разговор с рекрутером. Это короткая беседа, которая проводится для того, чтобы узнать больше о вашем прошлом опыте. Вы можете задать HR менеджеру любые первоначальные вопросы о текущей роли и компании, которые могут у вас возникнуть. Встречается практически всегда, но даже первый созвон с HR‑ом может превратиться в технический скрининг. Рекрутер по списку заранее заготовленных вопросов будет неловко спрашивать вас о вещах, которые ему самому могут быть совершенно неизвестны, а вы должны попасть как можно точнее в ответ. Иногда становится неловко даже вам, но, как правило, вопросы достаточно базовые и не должны вызывать проблем.

  2. Техническое собеседование. Следующим шагом обычно является техническое собеседование, которое может принимать различные формы. Некоторые компании могут попросить вас выполнить тестовое задание дома или пройти техническую онлайн‑оценку, в то время как другие могут провести Live coding сессию или попросить вас показать предыдущий проект, над которым вы работали. Техническое собеседование — это возможность для интервьюера оценить ваши технические навыки, способность решать проблемы и проверить, насколько хорошо вы знакомы с инструментами и фреймворками разработки.

  3. Onsite встреча. Если вы пройдете техническое собеседование, вас могут пригласить на собеседование в офис. Она может включать встречи с различными членами команды, разработчиками, менеджерами по продуктам и дизайнерами. У вас появится возможность узнать больше о культуре компании и динамике команды, а команда сможет оценить вашу квалификацию при помощи дополнительных сессий. Например, решение алгоритмический задач на whiteboard или system design интервью.

  4. Поведенческое интервью: Вас также могут попросить принять участие в поведенческом интервью. Больше свойственно для западных компаний особенно FAANG MANGA. Это возможность для интервьюера узнать больше о вашем стиле работы, навыках общения, и о том, как вы справляетесь с различными ситуациями. Вас могут попросить привести примеры того, как вы справлялись с трудными или конфликтными ситуациями в прошлом.

Live coding

Найти хорошо собранный список вопросов для теоретической части интервью не составит труда. Выделить для себя топ 50-150 довольно просто:

Top iOS Interview Questions and Answers

Over 150 iOS interview questions

Об этом написано немало статей и даже книг.

В крупных международных компаниях ищут «инженеров». То есть универсальных солдат, которые могут поменять свой стек за пару недель. Live coding интервью в таких компаниях ориентировано на решение алгоритмических задач. Как правило, они не зависят от операционной системы или выбранного языка программирования.

Компании поменьше, особенно компании в СНГ странах предпочитают более предметный подход. Если это собеседование на iOS разработчика, то задачи будут в рамках этой платформы.

Попробуем разобрать наиболее популярные задачи, которые вы можете встретить на live coding этапе. Время, затраченное на решение таких задач, не должно превышать 30 минут, а их количество, обычно, ограничивается 1–2 задачами за одно интервью.

Thread-safe class

Swift дает нам возможность работать в многопоточной среде.

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

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

Тогда возникает вопрос, а зачем нам нужна не потокобезопасные объекты? Ответ: потому что потокобезопасная коллекция/переменная/свойство замедляет выполнение программы.

Вы можете столкнуться с этой проблемой, при работе с коллекциями например: Array, Dictionary, Set

Чтобы получить потокобезопасную коллекцию, нам нужно определить геттер и сеттер. Всякий раз, когда мы устанавливаем какую‑либо коллекцию, мы должны убедиться, что никакие другие потоки в настоящее время не получают доступ к ней.

Для решения этой проблемы можно применить разные подходы. Например:

NSLock, NSRecursiveLock, pthread_mutex_t, DispatchQueue.

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

final class ThreadSafeDictionary {
 
    func removeAll() {
        //Implement me
    }

    func removeValue(forKey key: Key) {
        //Implement me
    }

    func contains(key: Key) -> Bool {
        //Implement me
    }

    var count: Int {
        //Implement me
    }
}

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

Блок с флагом .barrier будет действовать как реальный барьер. Все блоки, которые были отправлены на выполнение до барьерной операции, будут завершены, и только после этого будет выполнен блок с барьером. Все блоки, отправленные после прохождения барьера, не будут запущены до тех пор, пока блок с барьером не будет завершен.

final class ThreadSafeDictionary<Key: Hashable, Value> {
    
    private var dictionary = [Key: Value]()
    private let queue = DispatchQueue(
        label: "com.example.ThreadSafeDictionary",
        attributes: .concurrent
    )

    subscript(key: Key) -> Value? {
        get {
            var value: Value?
            queue.sync {
                value = dictionary[key]
            }
            return value
        }
        set(newValue) {
            queue.async(flags: .barrier) { [weak self] in
                self?.dictionary[key] = newValue
            }
        }
    }

    func removeAll() {
        queue.async(flags: .barrier) { [weak self] in
            self?.dictionary.removeAll()
        }
    }

    func removeValue(forKey key: Key) {
        queue.async(flags: .barrier) { [weak self] in
            self?.dictionary.removeValue(forKey: key)
        }
    }

    func contains(key: Key) -> Bool {
        var contains = false
        queue.sync {
            contains = dictionary[key] != nil
        }
        return contains
    }

    var count: Int {
        var count = 0
        queue.sync {
            count = dictionary.count
        }
        return count
    }
}

В этой реализации класс ThreadSafeDictionary хранит приватные ссылки на словарь для хранения своих внутренних данных и очереди для синхронизации доступа к этим данным. Методы removeAll, removeValue(forKey:), получают доступ к внутреннему словарю через queue.async(flags: .barrier), a contains(key:) и count через queue.sync тем самым, обеспечивая потокобезопасный доступ и модификацию словаря.

Для следующего способа решения этой же задачи мы используем @propertyWrapper, который обеспечивает атомарные операции в Swift с использованием механизма os_unfair_lock:

@propertyWrapper
struct Atomic<Value> {
    private var value: Value
    private var lock = os_unfair_lock()

    var wrappedValue: Value {
        mutating get {
            os_unfair_lock_lock(&lock)
            defer { os_unfair_lock_unlock(&lock) }
            return value
        }
        set {
            os_unfair_lock_lock(&lock)
            defer { os_unfair_lock_unlock(&lock) }
            value = newValue
        }
    }

    init(wrappedValue: Value) {
        self.value = wrappedValue
    }
}
  • Структура Atomic имеет единственное дженерик свойство value, которое представляет тип обернутого значения;

  • Свойство value содержит обернутое значение, а свойство lock содержит объект os_unfair_lock_t, который используется для синхронизации доступа к значению;

  • Свойство wrappedValue имеет get и set, которые используют функции os_unfair_lock_lock и os_unfair_lock_unlock для блокирования и разблокирования доступа к свойству соответственно;

  • Ключевое слово defer используется для обеспечения того, чтобы блокировка всегда снималась, даже если в геттере или сеттере возникает ошибка. Это замыкание будет вызвано после выполнения геттера или сеттера.

В этом подходе реализация потокобезопасного словаря будет выглядеть следующим образом:

final class AtomicDictionary<Key: Hashable, Value> {
    
    private var dictionary = Atomic(wrappedValue: [Key: Value]())

    subscript(key: Key) -> Value? {
        get {
            return dictionary.wrappedValue[key]
        }
        set(newValue) {
            dictionary.wrappedValue[key] = newValue
        }
    }

    func removeAll() {
        dictionary.wrappedValue.removeAll()
    }

    func removeValue(forKey key: Key) {
        dictionary.wrappedValue.removeValue(forKey: key)
    }

    func contains(key: Key) -> Bool {
        dictionary.wrappedValue[key] != nil
    }

    var count: Int {
        dictionary.wrappedValue.count
    }

}

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

Детальней познакомиться с отличиями и скоростью выполнения этих реализаций можно по ссылке.

DispatchGroup

Часто встречается задача на понимание как работать с DispatchGroup и для чего она может пригодится на практике. Рассмотрим проблему:

final class AsyncPerformer {
    
    // Асинхронная тяжелая операция
    func performWork<T>(object: T, complete: @escaping (T) -> Void) {
        ... 
        // Замыкание вызывается ассинхронно,
        // через какой-то промежуток времени
        complete(object)
    }

    // Асинхронно выполняет несколько операций
    func performWorks<T>(objects: [T], complete: @escaping ([T]) -> Void) {
        ...
    }
}
  • Требуется реализовать метод performWorks;

  • Замыкание complete должно вызываться по завершению всех операций;

  • Последовательность элементов внутри замыкания должна оставаться как в исходном массиве;

  • Метод performWork требует много ресурсов.

Возможное решение:

final class AsyncPerformer {
    
    func performWork<T>(object: T, complete: @escaping (T) -> Void) {
        ... 
        complete(object)
    }

    // Асинхронно выполняет несколько операций
    func performWorks<T>(objects: [T], complete: @escaping ([T]) -> Void) {
        //1
        let group = DispatchGroup()        
        let queue = DispatchQueue(
            label: "perform.queue", 
            attributes: .concurrent
        )
        //2
        var result = objects
        //3
        group.notify(queue: DispatchQueue.main) {
            complete(result)
        }
        //4
        for (index, object) in objects.enumerated() {
            queue.async {
                //5
                group.enter()
                performWork(object: object) { response in
                    //6
                    queue.async(flags: .barrier) {
                        result[index] = response
                        //7
                        group.leave() 
                    }
                }
            }
        }
    }
}
  1. Создаем объект типа DispatchGroup и кастомную параллельную очередь;

  2. Копируем исходный массив в локальную переменную — чтобы в последствии использовать для сохранения порядка;

  3. Устанавливаем notify для группы. Когда все операции выйдут из группы, будет вызвано замыкание complete;

  4. В цикле выполняем операции. Код внутри цикла исполняется асинхронно на параллельной очереди т.к. по условию метод performWork требует много ресурсов;

  5. group.enter() вызывается до вызова метода performWork;

  6. Чтобы обезопасить доступ к результирующему массиву, следует записывать в него значения используя флаг .barrier;

  7. После сохранения результата выполнения замыкания выходим из группы с помощью group.leave().

Навыки работы с DispatchGroup необходимы для работы с набором ассинхронных операций. Также вы можете попробовать решить эту задачу используя фреймворк Combine и оператор collect.

High-order functions

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

Вот некоторые из них и их назначение:

  1. map — преобразует каждый элемент коллекции с помощью замыкания и возвращает новую коллекцию того же размера;

  2. filter — возвращает новую коллекцию, содержащую только те элементы, которые удовлетворяют заданному предикату;

  3. reduce — объединяет элементы коллекции в одно значение, используя замыкание, которое принимает два аргумента: накопленное значение и следующий элемент коллекции;

  4. compactMap — возвращает новую коллекцию с ненулевыми результатами вызова замыкания для каждого элемента исходной коллекции.

На интервью вас могут попросить написать реализацию этих методов. Рассмотрим пример:

extension Collection {
    func map() {
        // implement me!
    }
}

Как может выглядеть ваше решение:

extension Collection {
    func map<T>(_ transform: (Element) throws -> T) rethrows -> [T] {
        var result = [T]()
        result.reserveCapacity(count)
        for element in self {
            result.append(try transform(element))
        }
        return result
    }
}

В этом примере функция map определена как расширение типа Collection. Функция map принимает замыкание, которое преобразует элементы типа Element в элементы типа T. Замыкание помечено ключевым словом throws, чтобы указать, что оно может вызвать ошибку. Сама функция map также помечена ключевым словом rethrows, чтобы указать, что она может повторно вызывать любые ошибки, вызванные замыканием.

Внутри функции map создается новый пустой массив типа [T] для хранения преобразованных элементов. Метод reserveCapacity вызывается у нового массива, чтобы предварительно выделить достаточно места для всех элементов исходного массива, что может повысить производительность. Затем цикл for используется для перебора каждого элемента исходного массива. Замыкание применяется к каждому элементу с помощью ключевого слова try, а преобразованный элемент добавляется к новому массиву. Наконец, новый массив возвращается.

Остальные функций высшего порядка попробуйте реализовать сами =)

Type erasure

Предположим, есть протокол Request с associated type. Он позволяет нам скрывать различные формы запросов данных (например, сетевые запросы, запросы к базе данных и работы с кэшом) за одним унифицированным интерфейсом:

protocol Request {
    associatedtype Response
    associatedtype Error: Swift.Error
    
    typealias Handler = (Result<Response, Error>) -> Void
    
    var handler: Handler { get }
        
    func perform(then handler: @escaping Handler)
}

Задача реализовать класс‑очередь, которая позволяет работать с разными запросами по правилам FIFO.

Исходные данные выглядят следующим образом: Есть класс RequestQueue и метод add(_ :). Нужно реализовать всю логику класса.

class RequestQueue {
    // Error: protocol 'Request' can only be used as a generic
    // constraint because it has Self or associated type requirements
    //
    // Or in Swift 5.7
    // Use of protocol 'Request' as a type must be written 'any Request'
    func add(_ request: Request) {
        ...
    }
}

Мы не можем просто так передать параметр типа Request т.к. он имеет assoсiated type. В таком случае, для хранения запросов в очереди нам придется применить технику TypeErasure.

class RequestQueue {
    private var queue = [() -> Void]()
    private var isPerformingRequest = false

    func add<R: Request>(_ request: R) {
        // 1
        let typeErased = {
            request.perform { [weak self] result in
                request.handler(result)
                self?.isPerformingRequest = false
                self?.performNextIfNeeded()
            }
        }

        //2
        queue.append(typeErased)
        performNextIfNeeded()
    }

    //3
    private func performNextIfNeeded() {
        guard !isPerformingRequest && !queue.isEmpty else {
            return
        }

        //4
        isPerformingRequest = true
        let closure = queue.removeFirst()
        closure()
    }
}
  1. Замыкание typeErased будет захватывать как запрос (request), так и его обработчик (handler), не раскрывая никакой информации об этом типе за его пределами, обеспечивая полное стирание типа (type erasure);

  2. Замыкания сохраняются в массив с типом [() -> Void];

  3. Метод performNextIfNeeded проверяет, нет ли выполнения в данный момент и есть ли в массиве еще запросы на очереди;

  4. Если запрос есть, выставляем флаг isPerformingRequest в true, достаем из массива следующий запрос на очереди и выполняем его, вызывая замыкание.

Any and Some

В Swift 5.6 и 5.7 произошло много изменений относительно концепции type erasure, и теперь она выглядит более нативно. Подробности можно посмотреть здесь и здесь. Давайте посмотрим, как решение задачи с очередью запросов может выглядеть при использовании ключевых слов any и some:

class RequestQueue {
    private var queue = [any NewRequest]()
    private var isPerformingRequest = false

    func add(request: some NewRequest) {
        queue.append(request)
        performNextIfNeeded()
    }
    
    private func performNextIfNeeded() {
        guard !isPerformingRequest && !queue.isEmpty else { return }
        isPerformingRequest = true
        
        let outgoing = queue.removeFirst()
        perform(request: outgoing)
    }
    
    private func perform(request: some NewRequest) {
        request.perform { [weak self] result in
            request.handler(result)
            self?.isPerformingRequest = false
            self?.performNextIfNeeded()
        }
    }
}

Теперь нам не нужно хранить в массиве typeErased замыкания. Ключевые слова some и any позволяют использовать type erasure технику с помощью opaque type и existential type соответственно.

Заметьте, что класс RequestQueue не является потокобезопасным, что в реальном проекте, скорее всего, вызвало бы проблемы.

Заключение

Список приведенных мною задач отражает темы, которые важны для любого iOS разработчика. Цель проверить знания и умения работать с той или иной технологией. Разумеется, список неполный и может быть добавлен и содержать другие вариации задач. Вы можете использовать эти примеры при проведении интервью в своих компаниях или для подготовки к собеседованиям. Если даете эти примеры, постарайтесь, прежде всего, понять, как кандидат размышляет во время решения, а не получить точную имплементацию с соблюдением синтаксиса языка, особенно если вы используете IDE без подсказок. Будьте снисходительны, принимая во внимание стресс и волнение на интервью, а также тот факт, что решение кандидата может отличаться стилистически от вашего.

Комментарии (2)


  1. Julia_cat
    00.00.0000 00:00
    +1

    Вадим, спасибо за статью! Мне, как junior developer-у было очень полезно прочитать)


  1. Meskalin1337
    00.00.0000 00:00

    Очень круто!