Data Science fwdays сonference — few-shot learning, snorkel, black box and more! Kyiv, Sep 7

Готовимся к Oracle Certified Java 8 Programmer

Всем привет! В этом году Oracle зарезилил свои экзамены по Java 8 — для сдачи стали доступны Associate (1Z0-808) и Professional (1Z0-809). В этой статье я хочу поделиться опытом прохождения новых экзаменов и подробнее раскрыть встречающиеся темы из восьмой версии. Большая часть будет посвящена Professional, так как он наиболее интересен. Также я не буду поднимать философские вопросы о том, надо ли это вообще — поговорим о технической стороне и о том, как подготовиться к сертификации.

О процедуре заказа уже написано много статей, подробно останавливаться на этом месте смысла не вижу. Регистрируемся на PearsonVUE и Oracle CertView, связываем аккаунты, заказываем, оплачиваем и идем сдавать. Сертификационных центров в Киеве хватает (около десятка), и расписание очень гибкое.

Есть приятный бонус. В этом году Java празднует свое 20-летие, и поэтому во всем мире до конца 2015 года действует скидка 20% на все Java-экзамены. Просто введите промокод «Java20» при оплате на PearsonVUE. Судя по всему, есть возможность заказывать со скидкой на январь 2016.

Oracle Certified Associate (1Z0-808)

Associate — это начальный уровень. Здесь проверяют базовые знания языка. На странице экзамена доступен список тем.

Также можно ознакомиться со списком отличий 7-й и 8-й версий. Если сказать в целом, экзамен по 8 — это такой экзамен по 7, где вас дополнительно спросят о лямбдах и new Date and Time API.

Что есть для подготовки:
— Первая часть книги OCA/OCP Java SE 7 Programmer I & II Study Guide by Kathy Sierra & Bert Bates;
— OCA: Java SE 8 Programmer I Study Guide by Jeanne Boyarsky & Scott Selikoff;

— Очень полезный материал Maurice Naftalin’s Lambda FAQ;
— Java 8 Date and Time.

В практических тестах можно потренироваться на Quizful или выбрать что-нибудь отсюда.

Date and Time

Oracle обожает новый API. Вас ожидают вопросы по основным сущностям пакета java.time.

По факту нужно помнить, что все основные классы являются immutable, и не попадаться на глупых вопросах:

LocalDate localDate = LocalDate.now(); localDate.plus(1, ChronoUnit.DAYS);

В данном случае c оригинальным объектом ничего не произойдет.

То же самое касается Period, ZonedDateTime и других. Никаких родственных связей у этих классов нет (но методы преобразования присутствуют).

LocalDateTime localDateTime = LocalDate.now(); //ошибка компиляции

Ничего суперсложного не будет, просто хорошо почитайте материал и потренируйтесь в IDE.

Лямбды

Здесь также будут базовые вопросы: что это такое, в чём фишка, замените лямбду анонимным классом, перепишите кусок кода с использованием лямбд и так далее. Стоит иметь представление о базовых интерфейсах пакета java.util.function (Consumer, Supplier, Function, Predicate, UnaryOperator).

Также будут вопросы о видимости переменных — в Java 8 появился термин effectively final variable (local variables referenced from a lambda expression must be final or effectively final). Пример:

List<String> list = new ArrayList<>();
list.add("Hi");
list.add("Toast");
list.add("Beer");

int count = 2;
list.removeIf((String s) -> s.length() <= count); //1
list.forEach((s) -> System.out.println(s)); //2
        
if (list.size() == 2) {
    count = 5; //3
} else {
    System.out.println("Hello!");
}

В данном случае мы получаем ошибку компиляции в строке 1, так как переменная count изменяется в блоке if и перестает быть effectively final. Если убрать строку 3, всё будет окей. Обратите внимание, что изменение происходит после обращения, но компилятор отслеживает такие вещи. String s в строке 1 не имеет никакого отношения к s в строке 2 — это локальные имена аргументов и разные способы объявления.

Сам экзамен довольно прост. Просто будьте внимательны.

Oracle Certified Professional (1Z0-809)

Он же бывший SCJP. Список тем лежит на странице экзамена. Есть и список отличий 7-й и 8-й версий.

Экзамен появился лишь в августе. В связи с этим есть нюансы — study guide’ы на момент написания статьи отсутствуют. Ближайший релиз ожидается 21-го декабря: OCP: Java SE 8 Programmer II Study Guide by Jeanne Boyarsky & Scott Selikoff.

Как готовиться:
— Материалы по OCA;
— Вторая часть OCA/OCP Java SE 7 Programmer I & II Study Guide by Kathy Sierra & Bert Bates;
— Java SE 8 for the Really Impatient by Cay Horstmann;
— Летом был замечательный курс Oracle JDK 8 Lambdas and Streams. Материалы доступны на YouTube;

— А если и этого мало — Java 8 Stream tutorial;
— Любые mock-тесты. Например, Enthuware 1Z0-809.

Что будет на экзамене? Для начала все классические темы, вопросы по которым «проапгрейджены» с использованием нового синтаксиса и приёмов. Ну и, конечно же, Java 8 (особенно Stream API). О новых темах, которые мне встретились, я и хочу написать ниже.

Optional

Одно из нововведений. Помимо собственной функциональности, активно используется Stream API (reduce, max, min, findAny, etc.). Где искать подвоха и что важно понимать? Optional призван избавить нас от NullPointerException. Так ли это на самом деле?

Optional<String> opt = Optional.of(null); System.out.println(opt); //и получаем NPE :)

Для избавления существует метод ofNullable. Но и тут есть свой нюанс:

Optional<String> opt = Optional.ofNullable(null); //вернёт Optional.empty System.out.println(opt.get()); //NoSuchElementException

Почему так было сделано и почему существует of и ofNullable — я нагуглил здесь.

Нельзя изменить контент Optional после создания. Любые манипуляции возвращают новый объект.

Optional<String> optional = Optional.empty();

if (optional.isPresent()) {
    System.out.println("Yes");
    } 
else {
    optional.of("Java");
    }

System.out.println(optional.orElse("Unknown"));

Здесь будет выведено «Unknown» — optional.of("Java") вернул новый объект, но никуда не присвоил.

Interfaces / Functional Interfaces

Начнем с default/static методов. Есть хороший раздел из Maurice Naftalin’s Lambda FAQ. Со static следует помнить, что в отличии от статических методов класса, статические методы интерфейса не могут быть вызваны через ссылку на объект:

    interface One {
        static void foo() {}
    }

    class Alpha implements One {
    }

    public static void main(String[] args) {
        One.foo(); //ок
        One obj = new Alpha();
        obj.foo(); //ошибка компиляции
    }

Дефолтные методы можно переопределять, но нельзя переопределять статические методы дефолтными и обратно:

    interface One {
        default void foo() {
            System.out.println("One");
        }
    }

    interface Two extends One {
        default void foo() {
            System.out.println("Two");
        }
        static void foo() {     //ошибка компиляции
             System.out.println("Static Two");
         }
    }

Не стоит пугаться количества функциональных интерфейсов в java.util.function. Основных там не много, и они достаточно хорошо описаны в литературе или документации. Все остальные — это специализации (Bi, Int, Double, Long, etc.). Если почитаете и потренируетесь в IDE, проблем возникнуть не должно.

Если быть невнимательным, можно попасться на чем-то таком:

Function<String> f = name::toUpperCase;

Данный код не скомпилируется, потому что Function<T, R> принимает аргумент и возвращаемый тип. Один аргумент может принимать специализация, например, IntFunction<R> (принимает int, возвращает R).

Еще один пример:

Stream<Double> stream = DoubleStream.of(1, 2, 3).boxed();
UnaryOperator<Integer> unaryOperator = x -> x * 2;
stream.map(unaryOperator).forEach(System.out::println);

Получаем ошибку компиляции, потому что map ожидает UnaryOperator<Double>. Но помимо нормального решения, её можно обойти некоторыми извращенными способами. Например, заменив вызов map(unaryOperator) на map(x -> unaryOperator.apply(Integer.valueOf(x.intValue()))) (не делайте так никогда).

Method / Constructor References

По ссылкам на методы в дополнение к основным материалам также хороший материал есть на Oracle docs.

Гораздо больше можно запутаться в ссылках на конструкторы. Базовое объяснение довольно простое:

Supplier<String> supplier = () -> new String();

превращается в

Supplier<String> supplier = String::new;

Ну и вызываем:

String str = supplier.get();

Другого синтаксиса, например, String():new, String::new("test«>) быть не может. Это вызовет ошибку компиляции. Но что, если в конструктор требуется передать аргументы? Supplier нам уже не подойдет, его метод T get() ничего не принимает.

Создаем свой (также можно воспользоваться Function):

    interface SupplierWithArg<T, U> {
        T get(U arg);
    }

SupplierWithArg<String, String> supplier = String::new;
String str = supplier.get(“Java 8");

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

А если аргумент параметризован? Например, у нас есть класс:

    class Student {
        List<String> grades;
        public Student(List<String> grades) {
            this.grades = grades;
        }
    }

И функциональный интерфейс:

    interface SupplierWithParamArg<T, U> {
        T get(List<U> arg);
    }

В данном случае Student::new не прокатит, компилятору нужно указать тип. Это можно сделать так:

List<String> grades = Arrays.asList("A", "B", “C");
SupplierWithParamArg<Student, String> supplier = Student::<String>new;
Student student = supplier.get(grades);

Stream API

There are 95 methods in 23 classes that return a Stream
Many of them, though are intermediate operations in the Stream interface
71 methods in 15 classes can be used as practical Stream sources
(JDK 8 MOOC Lambdas and Streams)

Самое важное нововведение. Более половины вопросов будет именно об операциях со стримами. И также они будут фигурировать в вопросах на общие темы. Ключевым интерфейсом является Stream<T> — он содержит практически все методы, которые будут упомянуты ниже.

Теперь о частых ошибках:
— Операции со стримами делятся на intermediate и terminal (в документации всегда можно увидеть, к какому типу относится метод);
— Для отработки стрима необходимы две вещи — source и terminal operation;
— Intermediate-операции «are lazy when possible». Они не выполняются, пока не потребуется результат.

Эти три пункта ведут к следующему:

List<StringBuilder> list = Arrays.asList(new StringBuilder("Java"), new StringBuilder(“Hello"));
list.stream().map((x) -> x.append(" World”));
list.forEach(System.out::println);

Выведет:

Java
Hello

Не произошло абсолютно ничего, потому что map является intermediate-операцией, которая добавила преобразование и вернула новый стрим. Но без вызова terminal-операции мы просто «вяжем» свои вычисления до финального результата.

Стоит добавить любую terminal-операцию:

list.stream().map((x) -> x.append(" World”)).count(); // count возвращает кол-во элементов стрима.

и стрим отработает. Объекты листа будут изменены в:

Java World
Hello World

Стрим нельзя использовать повторно, если на нем отработала terminal-операция:

List<StringBuilder> list = Arrays.asList(new StringBuilder("Java"), new StringBuilder("Hello"));
Stream<StringBuilder> stream = list.stream().map((x) -> x.append(" World"));
long count = stream.count();

Object[] array = stream.toArray(); // java.lang.IllegalStateException

Обратите внимание, что метод close для интерфейса Stream не является terminal-операцией. Он идет от интерфейса AutoCloseable, который наследует BaseStream.

Специализированные версии стримов (DoubleStream, IntStream, LongStream) позволяют уйти от создания лишних объектов и autoboxing/unboxing. Мы работаем напрямую с примитивами. У интерфейса Stream есть соответствующие методы для преобразования — mapToXXX / flatMapToXXX. У специализированных версий метод boxed делает обратное — возвращает Stream. Ещё у IntStream и LongStream есть интересные методы range и rangeClosed, генерирующие последовательность значений с шагом 1.

Интересную подборку частых ошибок при работе со стримами можно увидеть здесь.

Например, порядок операций и методы skip, limit:

IntStream.iterate(0, i -> i + 1)
                    .limit(10)
                    .skip(5)
                    .forEach((x) -> System.out.print(x + " "));

Выведет: 5 6 7 8 9.

Меняем местами:

IntStream.iterate(0, i -> i + 1)
                    .skip(5)
                    .limit(10)
                    .forEach((x) -> System.out.print(x + " "));

Получаем: 5 6 7 8 9 10 11 12 13 14.

Short-circuit operations

О чем стоит помнить на экзамене? Методы anyMatch, allMatch, noneMatch принимают Predicate и возвращают boolean. Также в названиях методов заложена механика их работы.

Stream<Integer> values = IntStream.rangeClosed(0, 10).boxed();
values.peek(System.out::println).anyMatch(x -> x == 5);

Будет выведена последовательность от 0 до 5. При x = 5 предикат вернет true, и стрим закончит работу.

Методы findFirst, findAny не принимают аргументов и возвращают Optional (потому что результата может и не быть).

C findAny не всё так просто:

      Optional<Integer> result = IntStream.rangeClosed(10, 15)
                .boxed()
                .filter(x -> x > 12)
                .findAny();
        System.out.println(result); 

Казалось бы, данный код всегда выведет Optional с 13 внутри. Однако, findAny не гарантирует последовательности, он может выбрать любой элемент. Особенно это касается parallel-стримов (для производительности которых он был и создан). Для стабильного результата существует findFirst.

Reduction / Mutable Reduction

Самое простое по этой теме: методы min и max принимают Comparator и возвращают Optional.

За агрегирование результата отвечает reduce. Простейшая его версия принимает BinaryOperator (два аргумента — полученное на предыдущем шаге значение и текущее значение). Возвращает всегда одно значение для стрима, завернутое в Optional.

Например, max может быть заменен на:

.reduce((x, y) -> x > y ? x : y)

Или еще проще

.reduce(Integer::max)

Версия с корнем (identity) аккумулирует вычисление на основе типа корня:

T reduce(T identity, BinaryOperator<T> accumulator))
int sum = IntStream.range(0, 9).reduce(0, (x, y) -> x + y);

Mutable reduction позволяет не просто выдать результат, но и завернуть его в какой-нибудь контейнер (например, коллекцию). Для этого у стрима есть методы collect.

Стоит помнить, что collect в простейшей его версии принимает три аргумента:

<R> R collect(Supplier<R> supplier,
              BiConsumer<R,? super T> accumulator,
              BiConsumer<R,R> combiner)

Supplier — возвращает новые инстансы целевого контейнера на текущем шаге.
Accumulator — собирает элементы в него.
Combiner — сливает контейнеры воедино.

Пример collect для ArrayList в роли контейнера:

List<String> asList = stringStream.collect(ArrayList::new, ArrayList::add, ArrayList::addAll);

Также был добавлен класс Collectors с множеством уже реализованных удобных операций. Рассмотрите его методы и хорошенько поиграйтесь в IDE. Например, по groupingBy вопросов будет достаточно. Пример:

        List<String> list = Arrays.asList("Dave", "Kathy", "Ed", "John", "Fred");
        Map<Integer, Long> data = list.stream()
                .collect(Collectors.groupingBy(String::length, Collectors.counting()));
        System.out.println(data);

Напоминает GROUP BY из SQL. Метод группирует значения по длине строки и данная его версия возвращает Map. Получаем вывод: {2=1, 4=3, 5=1}.

Ещё есть интересный метод partitioningBy. Он организует элементы согласно предикату в Map<Boolean, List<T>>:

Stream<Integer> values = IntStream.rangeClosed(0, 10).boxed();
Object obj = values.collect(Collectors.partitioningBy(x -> x % 2 == 0));
System.out.println(obj);

Вывод:
{false=[1, 3, 5, 7, 9], true=[0, 2, 4, 6, 8, 10]}

Parallel streams

По этой теме вопросов откровенно мало. Не волнуйтесь, вас и так будут спрашивать по многопоточности и Fork/Join Framework. По последнему может начаться настоящий трeш и угар. От простых вопросов: «Что это такое? Преимущества?» и «RecursiveTask vs RecursiveAction» до огромных полотен кода с сортировками массивов.

Под капотом параллельных стримов как раз и работает ForkJoinPool.

Для начала — методы parallel / sequential являются intermediate-операциями. С их помощью можно определять тип операций. Стрим может переходить из sequential в parallel и обратно. По-дефолту Collection.stream возвращает sequential-стрим.

        List<Integer> list = IntStream.range(0, 256).boxed().collect(Collectors.toList());

        int sum = list.stream()
                .filter(x -> x > 253)
                .parallel()
                .map(x -> x + 1)
                .sequential()
                .reduce(Integer::sum).orElse(0);

forEachOrdered наряду с forEach существует не просто так.

Например:

IntStream.range(0, 9).parallel().forEach(System.out::println)

Будут выведены числа от 0 до 8 в непредсказуемом порядке. Метод forEachOrdered заставит вывести их в натуральном порядке. Но он не сортирует данные (if the stream has a defined encounter order © JavaDoc).

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

List<String> values = Arrays.asList("a", "b");
		String join = values.parallelStream()
				.reduce("-", (x, y) -> x.concat(y));
		System.out.println(join);

Если тут случится распараллеливание, и результат будет обрабатываться в двух разных потоках, на выходе получим —a-b. Каждый элемент по отдельности сольется с корнем, а затем всё сольется воедино. Но этого может и не произойти, тогда на первом шаге получим -a, а финальным результатом будет -ab.

Collections

Наиболее видимые изменения: Iterable и Map получили forEach. Обратите внимание, что для Map он принимает BiConsumer (два аргумента для ключа и значения):

Map<Integer, String> map = new HashMap<>();
map.put(1, "Joe");
map.put(2, "Bill");
map.put(3, "Kathy");
map.forEach((x, y) -> System.out.println(x + " " + y));

Кстати, можно вполне себе выводить, например, только значения. Не обязательно использовать все аргументы в выражении — map.forEach((x, y) -> System.out.println(y));

Далее, Collection получил stream() и parallelStream().

Могут попасться теоретические вопросы на тему работы HashMap. HashMap, LinkedHashMap, ConcurrentHashMap ведут себя иначе в Java 8. Грубо говоря, когда с хэшами беда и количество элементов связного списка в корзине переваливает за определенное значение, то список превращается в сбалансированное дерево. Об этом можно почитать, например, здесь или здесь.

Date and Time

Здесь придется углубиться в функционал новых классов. Duration манипулирует датами в разрезе часов/минут/секунд. Period использует дни/месяцы/годы. Где будет видна эта разница больше всего? При смещении временных диапазонов и переходом на летнее/зимнее время.

Например, в этом году в Украине переход на зимнее время состоялся 25 октября в 4 часа ночи (на час назад):

LocalDateTime ldt = LocalDateTime.of(2015, Month.OCTOBER, 25, 3, 0);

ZonedDateTime zdt = ZonedDateTime.of(ldt, ZoneId.of("EET"));
zdt = zdt.plus(Duration.ofDays(1));
System.out.println(zdt);
        
zdt = ZonedDateTime.of(ldt, ZoneId.of("EET"));
zdt = zdt.plus(Period.ofDays(1));
System.out.println(zdt);

Данный код выведет:

2015-10-26T02:00+02:00[EET]
2015-10-26T03:00+02:00[EET]

Что произошло? Duration добавил к дате конкретные 24 часа (потому что длительность одного дня всегда 24 часа). При переходе получаем на час меньше. Period же добавил конкретный день, и локальное время сохранилось.

I/O, NIO 2

Касаемо общих вопросов, стоит хорошо ориентироваться в операциях Path — normalize, relativize, resolve. А также StandardOpenOption. Но об этом будет достаточно в рекомендуемой литературе.

Теперь о Java 8. Вас могут ожидать не очень сложные вопросы про чтение и обработку текстового файла. В Files и BufferedReader появился метод lines(), возвращающий стрим, который состоит из строк выбранного файла.

Например, вывести строки:

Files.lines(Paths.get(“file.txt”)).forEach(System.out::println);

Или — разбить на слова, убрать дубликаты, перевести в нижний регистр и отсортировать по длине:

    try (BufferedReader reader = Files.newBufferedReader(
        Paths.get("file.txt"), StandardCharsets.UTF_8)) {

      List<String> list = reader.lines()
              .flatMap(line -> Stream.of(line.split("[- .:,]+”)))
              .map(String::toLowerCase)
              .distinct()
              .sorted((w1, w2) -> w1.length() - w2.length())
              .collect(Collectors.toList());
    } 

Кроме этого, в классе Files стримы также используют методы list, find и walk.

Заключение

Надеюсь, я смог немного раскрыть встречающиеся темы. К сожалению, исключительно из личного экзаменационного опыта нельзя описать всё возможное.

Еще несколько общих советов:
— Не нервничайте:). Вопросы в целом адекватные и без извращений. У Oracle нет цели завалить вас кривыми тестами;
— Также стоит следить за временем. На оба экзамена дается 2,5 часа. Лично у меня на OCA еще оставалось минут 40 на перепроверку, а вот в случае с OCP — около 15-ти;
— При подготовке посвящайте больше времени практике — это даст куда больше зазубривания книг. Открывайте IDE и пробуйте любые непонятные вещи.

Удачи!

LinkedIn

29 комментариев

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

спасибо за ваш труд )

А если не сдал с первого раза, то опять платить за новую сдачу ?

Одна пересдача бесплатно

У кого есть опыт сдачи, подскажите, пожалуйста, на экзамене можно пользоваться словарем? Допустим, если в вопросе нужно ответить, правильное ли выражение «Package private access is more lenient than protected access.» а ты не знаешь перевода слова «lenient» — ответить на вопрос не можешь не потому, то не знаешь, и даже не потому, что не знаешь специфической терминологии, а просто вот такое одно слово не знаешь. Заранее спасибо за ответ!

Ничем нельзя пользоваться. Все вещи предварительно складываются в ящик, на экзамен выдается только маркер и поверхность для заметок.

Спасибо за ответ! Так и думала :( И, наверно, спросить ни у кого из организаторов тоже нельзя, да?

Наверное зависит от организаторов.

А никто случайно не сдавал Oracle Web Services Developer Certified Expert? Есть пара вопросов.

Щоб отримати знижку 20%, здати теж треба до Нового Року.
«This discount cannot be used with appointments scheduled after 31 Dec 2015.»
Гарний challenge, якщо зараз тільки почав готуватися ;)

Блин, жаль. Впрочем, с хорошей базой знаний к OCA 8 за неделю подготовиться очень даже реально.

Enthuware прекрасный Exam simulator, всего за $10. В нем есть тематический progress report, можно увидеть, где провалы в знании.

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

У вас такой колоссальный опыт, что не вас должны сертифицировать, а вы можете проверять чужие знания. Но вы за $10 не будете тренировать меня по C++ в течении полугода.

Действительно, очень хороший симулятор, правда вопросы значительно сложнее, чем на настоящем экзамене.

В этой статье я хочу поделиться опытом прохождения новых экзаменов

Зачем? Какая польза от этого? За 10 лет мои сертификаты ни разу не были востребованы на Украине. мне мучительно больно за потраченное время и деньги на эти бумажки.
ИМХО, сертификаты полезны тем, кто работает с людьми (надо же пыль в глаза пускать), а для разработчика абсолютно бесполезная вещь

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

а о чем?
Хорошо, дам некоторый совет, как подготовится к экзамену за один день без всяких знаний - в гугл забиваем номер экзамена и добавляем волшебное слово дамп. Если повезет вы получите полный список вопросов и ответов от тех, кто прошел экзамен. Конечно ответы могут неправильные и перечень вопросов неполный, да и эти сайты закрывают, но появляются новые. Но тем не менее, кто ищет, тот всегда находит.
Интересно может, кто знает способ, как вооще не платить за экзамен?

Интересно может, кто знает способ, как вооще не платить за экзамен?
Подготовиться и не идти сдавать :)

Есть польза от самой подготовки к такому экзамену для систематизации знаний начинающих программистов или менеджеров.

Польза — это рабочий проект на гите.

Жоден проект не охопить всього об«єму матеріалу. «Практика без теорії небезпечна, теорія без практики безплідна». Потрібен баланс.

Так в цьому і є основний сенс здачі екзаменів і не тільки цих: в ВУЗі, на права, тощо.

Вы знаете, по моей статистике, одна из 5 компаний обращает внимание на сертификаты ( по крайней мере с java). Для разработчика с большим опытом работы, возможно, эти сертификаты и не нужны, но вот для начинающих — я думаю, что очень полезны: изучение материала + подтверждение навыков.

но вот для начинающих

Ищем лохов. Вы случайно в сертификационном центре не работаете?

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

Ищите и обрящете

*на том же гите масса бесполезных поделок, и масса г***о-кода...
польза?

Подписаться на комментарии