Процесс технического собеседования для разработчиков может различаться в зависимости от компании и конкретной роли. Какие этапы все же стоит ожидать:
Телефонный скрининг. Первым шагом в процессе собеседования обычно является телефонный разговор с рекрутером. Это короткая беседа, которая проводится для того, чтобы узнать больше о вашем прошлом опыте. Вы можете задать HR менеджеру любые первоначальные вопросы о текущей роли и компании, которые могут у вас возникнуть. Встречается практически всегда, но даже первый созвон с HR-ом может превратиться в технический скрининг. Рекрутер по списку заранее заготовленных вопросов будет неловко спрашивать вас о вещах, которые ему самому могут быть совершенно неизвестны, а вы должны попасть как можно точнее в ответ. Иногда становится неловко даже вам, но, как правило, вопросы достаточно базовые и не должны вызывать проблем.
Техническое собеседование. Следующим шагом обычно является техническое собеседование, которое может принимать различные формы. Некоторые компании могут попросить вас выполнить тестовое задание дома или пройти техническую онлайн-оценку, в то время как другие могут провести Live coding сессию или попросить вас показать предыдущий проект, над которым вы работали. Техническое собеседование — это возможность для интервьюера оценить ваши технические навыки, способность решать проблемы и проверить, насколько хорошо вы знакомы с инструментами и фреймворками разработки.
Onsite встреча. Если вы пройдете техническое собеседование, вас могут пригласить на собеседование в офис. Она может включать встречи с различными членами команды, разработчиками, менеджерами по продуктам и дизайнерами. У вас появится возможность узнать больше о культуре компании и динамике команды, а команда сможет оценить вашу квалификацию при помощи дополнительных сессий. Например, решение алгоритмический задач на whiteboard или system design интервью.
Поведенческое интервью: Вас также могут попросить принять участие в поведенческом интервью. Больше свойственно для западных компаний особенно
FAANGMAANG. Это возможность для интервьюера узнать больше о вашем стиле работы, навыках общения, и о том, как вы справляетесь с различными ситуациями. Вас могут попросить привести примеры того, как вы справлялись с трудными или конфликтными ситуациями в прошлом.
Live coding
Найти хорошо собранный список вопросов для теоретической части интервью не составит труда. Выделить для себя топ 50-150 довольно просто
https://intellipaat.com/blog/interview-question/ios-interview-questions/
https://www.hackingwithswift.com/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
}
}
Данная задача позволяет интервьюерам проверить ваши знания по работе с многопоточностью, потокобезопасностью и инструментами синхронизации.
Детальней познакомиться с отличиями и скоростью выполнения этих реализаций можно по ссылке https://swiftrocks.com/thread-safety-in-swift
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()
}
}
}
}
}
Создаем объект типа DispatchGroup и кастомную параллельную очередь;
Копируем исходный массив в локальную переменную - чтобы в последствии использовать для сохранения порядка;
Устанавливаем
notify
для группы. Когда все операции выйдут из группы, будет вызвано замыканиеcomplete
;В цикле выполняем операции. Код внутри цикла исполняется асинхронно на параллельной очереди т.к. по условию метод
performWork
требует много ресурсов;group.enter()
вызывается до вызова методаperformWork
;Чтобы обезопасить доступ к результирующему массиву, следует записывать в него значения используя флаг
.barrier
;После сохранения результата выполнения замыкания выходим из группы с помощью
group.leave()
.Навыки работы с
DispatchGroup
необходимы для работы с набором ассинхронных операций. Также вы можете попробовать решить эту задачу используя фреймворкCombine
и операторcollect.
High-order functions
В Swift функции высшего порядка — это функции, которые принимают одну или несколько функций в качестве аргументов или возвращают функцию в качестве результата. Это мощный инструмент для работы с коллекциями, поскольку они позволяют писать лаконичный и выразительный код, который можно легко настроить для различных вариантов использования.
Вот некоторые из них и их назначение:
map
- преобразует каждый элемент коллекции с помощью замыкания и возвращает новую коллекцию того же размера;filter
- возвращает новую коллекцию, содержащую только те элементы, которые удовлетворяют заданному предикату;reduce
- объединяет элементы коллекции в одно значение, используя замыкание, которое принимает два аргумента: накопленное значение и следующий элемент коллекции;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()
}
}
Замыкание
typeErased
будет захватывать как запрос (request), так и его обработчик (handler), не раскрывая никакой информации об этом типе за его пределами, обеспечивая полное стирание типа (type erasure);Замыкания сохраняются в массив с типом
[() -> Void]
;Метод
performNextIfNeeded
проверяет, нет ли выполнения в данный момент и есть ли в массиве еще запросы на очереди;Если запрос есть, выставляем флаг
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 без подсказок. Будьте снисходительны, принимая во внимание стресс и волнение на интервью, а также тот факт, что решение кандидата может отличаться стилистически от вашего.