Отложенное выполнение и неизменность, система объясняет обработку данных JavaStream

Java задняя часть
Отложенное выполнение и неизменность, система объясняет обработку данных JavaStream

Эта статья является первой подписанной статьей сообщества Nuggets, и ее перепечатка без разрешения запрещена.

Когда я недавно писал о бизнесе в компании, я вдруг не смог вспомнитьStreamКак следует накопления в записи?

Беспомощный, я могу только программировать для Google. Потратив свои драгоценные три минуты, я научился этому. Это очень просто.

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

Может быть, все одинаковы,对最常用到的东西,也最容易将其忽略, даже если вы готовитесь к интервью, вы, вероятно, даже не вспомните посмотреть что-то вроде Stream.

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

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

В этой статье я разделю содержимое Stream на следующие части:

При первом взгляде на эту карту вы можете немного запутаться в терминах операция преобразования потока и операция завершения потока.На самом деле, я разделил все API в потоке на две категории, и каждая категория имеет соответствующее имя (см. Книги, см. в конце текста):

  • Преобразование потоковых операций: Например, методы filter и map преобразуют Stream в другой Stream, а возвращаемые значения — это все Streams.

  • Завершить потоковые операции: Например, методы count и collect агрегируют Stream в нужные нам результаты, а возвращаемые значения не являются Streams.

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

  1. нет статуса: то есть выполнение этого метода не должно полагаться на набор результатов выполнения предыдущего метода.

  2. состояние: то есть выполнение этого метода должно зависеть от набора результатов выполнения предыдущего метода.

Поскольку контента в Stream слишком много, я разделил Stream на две части: первая часть с подробным контентом и простыми и богатыми вариантами использования.

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


Примечание: Поскольку мой локальный компьютер - JDK11, и я забыл переключиться на JDK8, когда писал его, в случае использования появляется большое количество вариантов.List.of()Он недоступен в JDK8, он эквивалентен JDK8.Arrays.asList().

Примечание: В процессе написания я прочитал много исходников Stream и книг по Java8 (в конце статьи).Создать было непросто.Мне понравилось более 100 раз.

1. Зачем использовать поток?

Все тоже связано с выходом JDK8.В ту эпоху функциональных языков программирования, Java критиковали за ее раздутость (сильная объектно-ориентированность), и сообществу срочно нужна Java, чтобы добавить возможности функционального языка для улучшения этой ситуации, и, наконец, в 2014 Java выпустила JDK8.

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

Добавление этих двух функций делает Java проще и элегантнее.Использование функционального и функционального для закрепления статуса старшего брата Java — это просто вопрос обучения навыкам других.

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

1.1 Более четкая структура кода

Stream имеет более четкую структуру кода. Чтобы лучше объяснить, как Stream делает код более понятным, давайте предположим, что у нас есть очень простое требование:Найти все элементы больше 2 в наборе.

Давайте посмотрим перед использованием Stream:

        List<Integer> list = List.of(1, 2, 3);
        
        List<Integer> filterList = new ArrayList<>();
        
        for (Integer i : list) {
            if (i > 2) {
                filterList.add(i);
            }
        }
        
        System.out.println(filterList);

Приведенный выше код прост для понимания, поэтому я не буду его слишком много объяснять, на самом деле это нормально, потому что наши потребности относительно просты, а если их больше?

Каждый раз, когда есть еще одно требование, то в if нужно добавить еще одно условие, а у нас часто бывает много полей на объекте в нашей разработке, поэтому может быть четыре или пять условий, и в итоге это может стать таким:

        List<Integer> list = List.of(1, 2, 3);

        List<Integer> filterList = new ArrayList<>();

        for (Integer i : list) {
            if (i > 2 && i < 10 && (i % 2 == 0)) {
                filterList.add(i);
            }
        }

        System.out.println(filterList);

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

Если стримить, то все становится ясно и понятно:

        List<Integer> list = List.of(1, 2, 3).stream()
                .filter(i -> i > 2)
                .filter(i -> i < 10)
                .filter(i -> i % 2 == 0)
                .collect(toList());

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

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

Поскольку Stream поможет нам с неявным циклом, это называется:内部迭代, что соответствует нашей общей внешней итерации.

Поэтому, даже если вы не напишите цикл, он будет выполнять цикл.

1.2 Не заботьтесь о состоянии переменных

Stream был разработан с самого начала, чтобы быть不可变的, его неизменность имеет два значения:

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

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

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

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

Таким образом, в Stream нам не нужно заботиться о различных побочных эффектах работы с исходной коллекцией объектов, и все кончено.

Читать о функциональном программированииПредварительное исследование функционального программирования Жуань Ифэна.

1.3 Отложенное выполнение и оптимизация

Транслировать только встречи终结操作будет выполняться, когда, например:

        List.of(1, 2, 3).stream()
                .filter(i -> i > 2)
                .peek(System.out::println);

Такой кусок кода не будет выполняться, метод peek можно увидеть как forEach, здесь я использую его для печати элементов в Stream.

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

Если мы добавим метод count позже, он может выполняться нормально:

        List.of(1, 2, 3).stream()
                .filter(i -> i > 2)
                .peek(System.out::println)
                .count();

Метод count — это последняя операция, используемая для подсчета количества элементов в потоке, и его возвращаемое значение имеет тип long.

Эта функция Stream, которая не выполняется без финализации, называется延迟执行.

В то же время Stream также будет называть методы без сохранения состояния в API.循环合并оптимизация, конкретные примеры см. в Разделе III.

2. Создать поток

Для полноты статьи я подумал об этом и добавил раздел о создании Stream.Этот раздел в основном знакомит с некоторыми распространенными способами создания Stream.Создание Stream в целом можно разделить на две ситуации:

  1. Создано с использованием интерфейса Steam

  2. Создано библиотекой классов коллекции

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

2.1 Создано через интерфейс Stream

В качестве интерфейса Stream определяет несколько статических методов в интерфейсе, чтобы предоставить нам API для создания Stream:

    public static<T> Stream<T> of(T... values) {
        return Arrays.stream(values);
    }

Первый — это метод of, который предоставляет универсальный переменный параметр, создает поток Stream с универсальными для нас и автоматически оборачивает базовый тип, если ваш параметр является базовым типом:

        Stream<Integer> integerStream = Stream.of(1, 2, 3);

        Stream<Double> doubleStream = Stream.of(1.1d, 2.2d, 3.3d);

        Stream<String> stringStream = Stream.of("1", "2", "3");

Конечно, вы также можете напрямую создать пустой Stream, просто вызвав другой статический метод — empty(), чей общий тип — Object:

        Stream<Object> empty = Stream.empty();

Все вышеперечисленное — это методы создания, которые мы упростили для понимания, и есть еще один способ создать Stream с неограниченным количеством элементов — generate():

    public static<T> Stream<T> generate(Supplier<? extends T> s) {
        Objects.requireNonNull(s);
        return StreamSupport.stream(
                new StreamSpliterators.InfiniteSupplyingSpliterator.OfRef<>(Long.MAX_VALUE, s), false);
    }

С точки зрения параметров метода, он принимает в качестве параметра функциональный интерфейс - Поставщик.Этот функциональный интерфейс представляет собой интерфейс, используемый для создания объектов.Вы можете сравнить его с фабрикой создания объектов.Поток будет помещать объекты, созданные из этой фабрики, в в поток:

        Stream<String> generate = Stream.generate(() -> "Supplier");

        Stream<Integer> generateInteger = Stream.generate(() -> 123);

Я здесь, чтобы создать объект Supplier непосредственно с помощью Lamdba для удобства.Вы также можете напрямую передать объект Supplier, который создаст объект через метод get() интерфейса Supplier.

2.2 Создание через библиотеку классов коллекции

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

        Stream<Integer> integerStreamList = List.of(1, 2, 3).stream();
        
        Stream<String> stringStreamList = List.of("1", "2", "3").stream(); 

В Java 8 интерфейс верхнего уровня для коллекцийCollectionДобавлен новый метод интерфейса по умолчанию -stream(), с помощью этого метода мы можем легко создавать потоковые операции для всех подклассов коллекций:

        Stream<Integer> listStream = List.of(1, 2, 3).stream();
        
        Stream<Integer> setStream = Set.of(1, 2, 3).stream();

Посмотрев исходный код, вы можете отправитьstream()Метод по существу создает Stream, вызывая служебный класс Stream:

    default Stream<E> stream() {
        return StreamSupport.stream(spliterator(), false);
    }

2.3 Создание параллельных потоков

Все потоки в приведенных выше примерах являются последовательными потоками. В некоторых сценариях, чтобы максимизировать производительность многоядерных процессоров, мы можем использовать параллельные потоки, которые выполняют параллельные операции через структуру fork/join, представленную в JDK7. Мы можем создавать параллельные потоки следующим образом:

        Stream<Integer> integerParallelStream = Stream.of(1, 2, 3).parallel();

        Stream<String> stringParallelStream = Stream.of("1", "2", "3").parallel();

        Stream<Integer> integerParallelStreamList = List.of(1, 2, 3).parallelStream();

        Stream<String> stringParallelStreamList = List.of("1", "2", "3").parallelStream();

Да, нет возможности напрямую создать параллельный поток в статическом методе Stream.Нам нужно вызвать метод parallel() после построения Stream для создания параллельного потока, потому что вызов метода parallel() не воссоздает параллельный поток. stream object. , но задайте параллельный параметр для исходного объекта Stream.

Конечно, мы также можем видеть, что параллельные потоки могут быть созданы непосредственно в интерфейсе Коллекции, просто вызовите иstream()соответствующийparallelStream()методы, как я только что упомянул, единственная разница между ними — это параметры:

    default Stream<E> stream() {
        return StreamSupport.stream(spliterator(), false);
    }

    default Stream<E> parallelStream() {
        return StreamSupport.stream(spliterator(), true);
    }

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

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

Таким образом, только когда количество элементов в потоке превышает 10 000 или даже больше, использование параллельных потоков может обеспечить более очевидное улучшение производительности.

Наконец, когда у вас есть параллельный поток, вы также можете передатьsequential()Его удобно преобразовать в последовательный поток:

        Stream.of(1, 2, 3).parallel().sequential();

2.4 Подключиться к потоку

Если вы создаете два потока в двух местах и ​​хотите объединить их при их использовании, вы можете использовать concat():

        Stream<Integer> concat = Stream
                .concat(Stream.of(1, 2, 3), Stream.of(4, 5, 6));

Если два разных универсальных потока объединены, автоматический вывод автоматически выведет два родительских класса одного и того же типа:

        Stream<Integer> integerStream = Stream.of(1, 2, 3);

        Stream<String> stringStream = Stream.of("1", "2", "3");

        Stream<? extends Serializable> stream = Stream.concat(integerStream, stringStream);

3. Метод без сохранения состояния операции преобразования потока

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

Мы обычно используем следующие три API без сохранения состояния:

  1. map()Метод: параметр этого метода — объект Function, который позволяет выполнять пользовательские операции над элементами в коллекции и сохранять элементы после операции.

  2. filter()Метод: параметр этого метода является объектом Predicate, а результат выполнения Predicate является логическим типом, поэтому этот метод сохраняет только те элементы, возвращаемое значение которых истинно. Как его имя, мы можем использовать этот метод для выполнения некоторых операций фильтрации .

  3. flatMap()Метод: этот метод имеет тот же параметр, что и метод map(), который является объектом Function, но возвращаемое значение этой функции должно быть потоком.Этот метод может объединять элементы в несколько потоков и возвращать их.

Давайте рассмотрим пример метода map():

        Stream<Integer> integerStreamList = List.of(1, 2, 3).stream();

        Stream<Integer> mapStream = integerStreamList.map(i -> i * 10);

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

Здесь, чтобы помочь вам лучше понять, я нарисовал простую схему:


Далее приведен пример метода filter():

        Stream<Integer> integerStreamList = List.of(1, 2, 3).stream();

        Stream<Integer> filterStream = integerStreamList.filter(i -> i >= 20);

В этом коде будет выполнятьсяi >= 20Затем эта логика сохраняет результат, возвращающий true, в новом потоке и возвращает его.

Здесь у меня также есть простая схема:


flatMap()Описание метода было описано выше, но оно слишком абстрактно, я также перерыл множество примеров в изучении этого метода, чтобы лучше понять.

Согласно официальной документации, этот метод предназначен для выравнивания элементов «один ко многим»:

        List<Order> orders = List.of(new Order(), new Order());

        Stream<Item> itemStream = orders.stream()
                .flatMap(order -> order.getItemList().stream());

Здесь я использую пример заказа, чтобы проиллюстрировать этот метод. Каждый из наших заказов содержит список продуктов. Если я хочу сформировать новый список продуктов из всех списков продуктов в двух заказах, мне нужно использовать метод flatMap().

В приведенном выше примере кода вы можете видеть, что каждый заказ возвращает поток списков продуктов.В этом примере у нас есть только два заказа, поэтому в конечном итоге будут возвращены потоки из двух списков продуктов.Роль flatMap() Метод заключается в извлечении элементов из этих двух потоков и помещении их в новый поток.

Старые правила, поставьте простую схему для иллюстрации:

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


Существует также очень редкий метод без сохранения состояния.peek():

    Stream<T> peek(Consumer<? super T> action);

Метод peek принимает объект Consumer в качестве параметра, который является параметром без возвращаемого значения.Мы можем выполнять некоторые операции, такие как печать элементов, с помощью метода peek:

        Stream<Integer> peekStream = integerStreamList.peek(i -> System.out.println(i));

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

        List.of(1, 2, 3).stream()
                .map(i -> i * 10)
                .peek(System.out::println)
                .count();

В документации по API также указано, что этот метод используется для отладки.По моему опыту, peek будет выполняться только тогда, когда Stream, наконец, понадобится воспроизвести элемент.

В приведенном выше примере count нужно вернуть только количество элементов, поэтому peek не выполняется, и если его заменить на метод collect, он будет выполнен.

Или, если в Stream есть методы фильтрации, такие как метод фильтра и сопоставление связанных методов, он также будет выполняться.

3.1 Основной тип потока

В предыдущем разделе упоминались три наиболее часто используемых метода без сохранения состояния в трех потоках. Также существует несколько методов, соответствующих map() и flatMap() в методах без сохранения состояния потока. А именно:

  1. mapToInt

  2. mapToLong

  3. mapToDouble

  4. flatMapToInt

  5. flatMapToLong

  6. flatMapToDouble

Эти шесть методов можно увидеть, во-первых, из названия метода.Они только преобразуют возвращаемое значение на основе map() или flatMap().Разумеется, что нет необходимости делать один метод.На самом деле, их ключ находится в возвращаемом значении:

  1. Возвращаемое значение mapToInt равноIntStream

  2. Возвращаемое значение mapToLong равноLongStream

  3. Возвращаемое значение mapToDouble равноDoubleStream

  4. Возвращаемое значение flatMapToInt равноIntStream

  5. Возвращаемое значение flatMapToLong равноLongStream

  6. Возвращаемое значение flatMapToDouble равноDoubleStream

В JDK5, чтобы сделать Java более объектно-ориентированным, вводится понятие класса-оболочки.Все восемь базовых типов данных соответствуют классу-оболочке, который позволяет автоматически распаковывать/упаковывать без какого-либо смысла при использовании базовых типов. заключается в автоматическом использовании метода преобразования класса-оболочки.

Например, в предыдущем примере я использовал этот пример:

        Stream<Integer> integerStream = Stream.of(1, 2, 3);

Я использовал базовые параметры типа данных при создании Stream, и его универсальный тип был автоматически преобразован в Integer, но иногда мы можем игнорировать стоимость автоматической распаковки.Если мы хотим игнорировать эту стоимость при использовании Stream, мы можем использовать Streams converted to Streams, предназначенные для базовых типов данных:

  1. IntStream: соответствующийint, short, char, boolean в базовых типах данных

  2. LongStream: соответствует long в базовом типе данных.

  3. DoubleStream: соответствует double и float в базовом типе данных.

В этих интерфейсах Stream можно создать с помощью метода of, как в приведенном выше примере, и он не будет автоматически распаковываться.

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

Поток базового типа имеет тот же API, что и Stream с точки зрения API, поэтому, если вы понимаете Stream, поток базового типа тот же.

Примечание: IntStream, LongStream и DoubleStream — все это интерфейсы, но они не унаследованы от интерфейса Stream.

3.2 Объединение циклов для методов без сохранения состояния

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

        List<Integer> list = List.of(1, 2, 3).stream()
                .filter(i -> i > 2)
                .filter(i -> i < 10)
                .filter(i -> i % 2 == 0)
                .collect(toList());

В этом примере я использовал метод filter три раза, поэтому вы думаете, что Stream будет трижды зацикливаться для фильтрации?

Если вы замените один из фильтров картой, сколько раз, по вашему мнению, он будет повторяться?

        List<Integer> list = List.of(1, 2, 3).stream()
                .map(i -> i * 10)
                .filter(i -> i < 10)
                .filter(i -> i % 2 == 0)
                .collect(toList());

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

Но, оглядываясь назад на определение метода без сохранения состояния, вы можете обнаружить, что остальные три условия можно выполнять в цикле, потому что фильтр зависит только от результата вычисления карты, а не от результата, установленного после выполнения карты, поэтому просто убедитесь, что сначала работаете с картой. Затем используйте фильтр, они могут быть завершены в одном цикле, эта оптимизация называется循环合并.

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

4. Stateful метод операции преобразования Stream

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

имя метода результат метода
distinct() Дедупликация элементов.
sorted() Сортировка элементов, два перегруженных метода, при необходимости вы можете передать объект сортировки.
limit(long maxSize) Передайте число, что означает, что берутся только первые X элементов.
skip(long n) Передать число, что означает пропустить X элементов и взять следующие элементы.
takeWhile(Predicate predicate) Новое в JDK9: передача параметра утверждения останавливается, когда первое утверждение ложно, и возвращает элемент, который ранее был утвержден как истина.
dropWhile(Predicate predicate) Новое в JDK9: передача параметра утверждения останавливается, когда первое утверждение ложно, и удаляет элемент, который ранее был утвержден как истина.

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

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

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

5. Резюме

В этой статье в основном дается обзор Stream и описываются две основные характеристики Stream:

  1. 不可变: не влияет на исходную коллекцию и возвращает новый поток при каждом вызове.

  2. 延迟执行: поток не будет выполняться до тех пор, пока не встретится операция финализации.

В то же время API Stream делится на две категории: операции преобразования и операции завершения и объясняет все часто используемые операции преобразования.Основным содержанием следующей главы будет операция завершения.

В процессе просмотра исходного кода Stream я обнаружил интересную вещь, вReferencePipelineВ классе (классе реализации Stream) его последовательность методов сверху вниз точно такая: метод без состояния → метод с состоянием → метод агрегации.

Ну, после прочтения этой статьи, я думаю, у всех появилось четкое представление о Stream в целом, и в то же время вы должны были освоить API операций конвертации, ведь их не так много 😂, у Java8 много мощных фич , мы в следующий раз давай поговорим~


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

Эти три книги очень хороши.Первая написана автором основных технологий Java.Если вы хотите полностью понять обновление JDK8, вы можете прочитать эту книгу.

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

Если вы можете прочитать только одну книгу, то я рекомендую третью книгу здесь, балл Douban достигает 9,2, а содержание и качество превосходны.


Наконец, создание не простое. Если оно будет полезно всем, я надеюсь, что оно всем понравится и поддержит его. Если у вас есть какие-либо вопросы, вы также можете обсудить это в области комментариев 😄~