我是風箏,公眾號「古時的風箏」。一個兼具深度與廣度的程式設計師鼓勵師,一個本打算寫詩卻寫起了程式碼的田園碼農!
文章會收錄在 JavaNewBee 中,更有 Java 後端知識圖譜,從小白到大牛要走的路都在裡面。公眾號回覆『666』獲取高清大圖。
就在今年 Java 25週歲了,可能比在座的各位中的一些少年年齡還大,但令人遺憾的是,竟然沒有我大,不禁感嘆,Java 還是太小了。(難道我會說是因為我老了?)
而就在上個月,Java 15 的試驗版悄悄釋出了,但是在 Java 界一直有個神祕現象,那就是「你發你發任你發,我的最愛 Java 8」.
據 Snyk 和 The Java Magazine 聯合推出釋出的 2020 JVM 生態調查報告顯示,在所有的 Java 版本中,仍然有 64% 的開發者使用 Java 8。另外一些開發者可能已經開始用 Java 9、Java 11、Java 13 了,當然還有一些神仙開發者還在堅持使用 JDK 1.6 和 1.7。
儘管 Java 8 釋出多年,使用者眾多,可神奇的是竟然有很多同學沒有用過 Java 8 的新特性,比如 Lambda表示式、比如方法引用,再比如今天要說的 Stream。其實 Stream 就是以 Lambda 和方法引用為基礎,封裝的簡單易用、函式式風格的 API。
Java 8 是在 2014 年釋出的,實話說,風箏我也是在 Java 8 釋出後很長一段時間才用的 Stream,因為 Java 8 釋出的時候我還在 C# 的世界中掙扎,而使用 Lambda 表示式卻很早了,因為 Python 中用 Lambda 很方便,沒錯,我寫 Python 的時間要比 Java 的時間還長。
要講 Stream ,那就不得不先說一下它的左膀右臂 Lambda 和方法引用,你用的 Stream API 其實就是函式式的程式設計風格,其中的「函式」就是方法引用,「式」就是 Lambda 表示式。
Lambda 表示式
Lambda 表示式是一個匿名函式,Lambda表示式基於數學中的λ演算得名,直接對應於其中的lambda抽象,是一個匿名函式,即沒有函式名的函式。Lambda表示式可以表示閉包。
在 Java 中,Lambda 表示式的格式是像下面這樣
// 無引數,無返回值
() -> log.info("Lambda")
// 有引數,有返回值
(int a, int b) -> { a+b }
其等價於
log.info("Lambda");
private int plus(int a, int b){
return a+b;
}
最常見的一個例子就是新建執行緒,有時候為了省事,會用下面的方法建立並啟動一個執行緒,這是匿名內部類的寫法,new Thread
需要一個 implements 自Runnable
型別的物件例項作為引數,比較好的方式是建立一個新類,這個類 implements Runnable
,然後 new 出這個新類的例項作為引數傳給 Thread。而匿名內部類不用找物件接收,直接當做引數。
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("快速新建並啟動一個執行緒");
}
}).run();
但是這樣寫是不是感覺看上去很亂、很土,而這時候,換上 Lambda 表示式就是另外一種感覺了。
new Thread(()->{
System.out.println("快速新建並啟動一個執行緒");
}).run();
怎麼樣,這樣一改,瞬間感覺清新脫俗了不少,簡潔優雅了不少。
Lambda 表示式簡化了匿名內部類的形式,可以達到同樣的效果,但是 Lambda 要優雅的多。雖然最終達到的目的是一樣的,但其實內部的實現原理卻不相同。
匿名內部類在編譯之後會建立一個新的匿名內部類出來,而 Lambda 是呼叫 JVM invokedynamic
指令實現的,並不會產生新類。
方法引用
方法引用的出現,使得我們可以將一個方法賦給一個變數或者作為引數傳遞給另外一個方法。::
雙冒號作為方法引用的符號,比如下面這兩行語句,引用 Integer
類的 parseInt
方法。
Function<String, Integer> s = Integer::parseInt;
Integer i = s.apply("10");
或者下面這兩行,引用 Integer
類的 compare
方法。
Comparator<Integer> comparator = Integer::compare;
int result = comparator.compare(100,10);
再比如,下面這兩行程式碼,同樣是引用 Integer
類的 compare
方法,但是返回型別卻不一樣,但卻都能正常執行,並正確返回。
IntBinaryOperator intBinaryOperator = Integer::compare;
int result = intBinaryOperator.applyAsInt(10,100);
相信有的同學看到這裡恐怕是下面這個狀態,完全不可理喻嗎,也太隨便了吧,返回給誰都能接盤。
先別激動,來來來,現在我們們就來解惑,解除蒙圈臉。
Q:什麼樣的方法可以被引用?
A:這麼說吧,任何你有辦法訪問到的方法都可以被引用。
Q:返回值到底是什麼型別?
A:這就問到點兒上了,上面又是 Function
、又是Comparator
、又是 IntBinaryOperator
的,看上去好像沒有規律,其實不然。
返回的型別是 Java 8 專門定義的函式式介面,這類介面用 @FunctionalInterface
註解。
比如 Function
這個函式式介面的定義如下:
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
}
還有很關鍵的一點,你的引用方法的引數個數、型別,返回值型別要和函式式介面中的方法宣告一一對應才行。
比如 Integer.parseInt
方法定義如下:
public static int parseInt(String s) throws NumberFormatException {
return parseInt(s,10);
}
首先parseInt
方法的引數個數是 1 個,而 Function
中的 apply
方法引數個數也是 1 個,引數個數對應上了,再來,apply
方法的引數型別和返回型別是泛型型別,所以肯定能和 parseInt
方法對應上。
這樣一來,就可以正確的接收Integer::parseInt
的方法引用,並可以呼叫Funciton
的apply
方法,這時候,呼叫到的其實就是對應的 Integer.parseInt
方法了。
用這套標準套到 Integer::compare
方法上,就不難理解為什麼即可以用 Comparator<Integer>
接收,又可以用 IntBinaryOperator
接收了,而且呼叫它們各自的方法都能正確的返回結果。
Integer.compare
方法定義如下:
public static int compare(int x, int y) {
return (x < y) ? -1 : ((x == y) ? 0 : 1);
}
返回值型別 int
,兩個引數,並且引數型別都是 int
。
然後來看Comparator
和IntBinaryOperator
它們兩個的函式式介面定義和其中對應的方法:
@FunctionalInterface
public interface Comparator<T> {
int compare(T o1, T o2);
}
@FunctionalInterface
public interface IntBinaryOperator {
int applyAsInt(int left, int right);
}
對不對,都能正確的匹配上,所以前面示例中用這兩個函式式介面都能正常接收。其實不止這兩個,只要是在某個函式式介面中宣告瞭這樣的方法:兩個引數,引數型別是 int
或者泛型,並且返回值是 int
或者泛型的,都可以完美接收。
JDK 中定義了很多函式式介面,主要在 java.util.function
包下,還有 java.util.Comparator
專門用作定製比較器。另外,前面說的 Runnable
也是一個函式式介面。
自己動手實現一個例子
1. 定義一個函式式介面,並新增一個方法
定義了名稱為 KiteFunction 的函式式介面,使用 @FunctionalInterface
註解,然後宣告瞭具有兩個引數的方法 run
,都是泛型型別,返回結果也是泛型。
還有一點很重要,函式式介面中只能宣告一個可被實現的方法,你不能宣告瞭一個 run
方法,又宣告一個 start
方法,到時候編譯器就不知道用哪個接收了。而用default
關鍵字修飾的方法則沒有影響。
@FunctionalInterface
public interface KiteFunction<T, R, S> {
/**
* 定義一個雙引數的方法
* @param t
* @param s
* @return
*/
R run(T t,S s);
}
2. 定義一個與 KiteFunction 中 run 方法對應的方法
在 FunctionTest 類中定義了方法 DateFormat
,一個將 LocalDateTime
型別格式化為字串型別的方法。
public class FunctionTest {
public static String DateFormat(LocalDateTime dateTime, String partten) {
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(partten);
return dateTime.format(dateTimeFormatter);
}
}
3.用方法引用的方式呼叫
正常情況下我們直接使用 FunctionTest.DateFormat()
就可以了。
而用函式式方式,是這樣的。
KiteFunction<LocalDateTime,String,String> functionDateFormat = FunctionTest::DateFormat;
String dateString = functionDateFormat.run(LocalDateTime.now(),"yyyy-MM-dd HH:mm:ss");
而其實我可以不專門在外面定義 DateFormat
這個方法,而是像下面這樣,使用匿名內部類。
public static void main(String[] args) throws Exception {
String dateString = new KiteFunction<LocalDateTime, String, String>() {
@Override
public String run(LocalDateTime localDateTime, String s) {
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(s);
return localDateTime.format(dateTimeFormatter);
}
}.run(LocalDateTime.now(), "yyyy-MM-dd HH:mm:ss");
System.out.println(dateString);
}
前面第一個 Runnable
的例子也提到了,這樣的匿名內部類可以用 Lambda 表示式的形式簡寫,簡寫後的程式碼如下:
public static void main(String[] args) throws Exception {
KiteFunction<LocalDateTime, String, String> functionDateFormat = (LocalDateTime dateTime, String partten) -> {
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(partten);
return dateTime.format(dateTimeFormatter);
};
String dateString = functionDateFormat.run(LocalDateTime.now(), "yyyy-MM-dd HH:mm:ss");
System.out.println(dateString);
}
使用(LocalDateTime dateTime, String partten) -> { } 這樣的 Lambda 表示式直接返回方法引用。
Stream API
為了說一下 Stream API 的使用,可以說是大費周章啊,知其然,也要知其所以然嗎,追求技術的態度和姿勢要正確。
當然 Stream 也不只是 Lambda 表示式就厲害了,真正厲害的還是它的功能,Stream 是 Java 8 中集合資料處理的利器,很多本來複雜、需要寫很多程式碼的方法,比如過濾、分組等操作,往往使用 Stream 就可以在一行程式碼搞定,當然也因為 Stream 都是鏈式操作,一行程式碼可能會呼叫好幾個方法。
Collection
介面提供了 stream()
方法,讓我們可以在一個集合方便的使用 Stream API 來進行各種操作。值得注意的是,我們執行的任何操作都不會對源集合造成影響,你可以同時在一個集合上提取出多個 stream 進行操作。
我們看 Stream 介面的定義,繼承自 BaseStream
,機會所有的介面宣告都是接收方法引用型別的引數,比如 filter
方法,接收了一個 Predicate
型別的引數,它就是一個函式式介面,常用來作為條件比較、篩選、過濾用,JPA
中也使用了這個函式式介面用來做查詢條件拼接。
public interface Stream<T> extends BaseStream<T, Stream<T>> {
Stream<T> filter(Predicate<? super T> predicate);
// 其他介面
}
下面就來看看 Stream 常用 API。
of
可接收一個泛型物件或可變成泛型集合,構造一個 Stream 物件。
private static void createStream(){
Stream<String> stringStream = Stream.of("a","b","c");
}
empty
建立一個空的 Stream 物件。
concat
連線兩個 Stream ,不改變其中任何一個 Steam 物件,返回一個新的 Stream 物件。
private static void concatStream(){
Stream<String> a = Stream.of("a","b","c");
Stream<String> b = Stream.of("d","e");
Stream<String> c = Stream.concat(a,b);
}
max
一般用於求數字集合中的最大值,或者按實體中數字型別的屬性比較,擁有最大值的那個實體。它接收一個 Comparator<T>
,上面也舉到這個例子了,它是一個函式式介面型別,專門用作定義兩個物件之間的比較,例如下面這個方法使用了 Integer::compareTo
這個方法引用。
private static void max(){
Stream<Integer> integerStream = Stream.of(2, 2, 100, 5);
Integer max = integerStream.max(Integer::compareTo).get();
System.out.println(max);
}
當然,我們也可以自己定製一個 Comparator
,順便複習一下 Lambda 表示式形式的方法引用。
private static void max(){
Stream<Integer> integerStream = Stream.of(2, 2, 100, 5);
Comparator<Integer> comparator = (x, y) -> (x.intValue() < y.intValue()) ? -1 : ((x.equals(y)) ? 0 : 1);
Integer max = integerStream.max(comparator).get();
System.out.println(max);
}
min
與 max 用法一樣,只不過是求最小值。
findFirst
獲取 Stream 中的第一個元素。
findAny
獲取 Stream 中的某個元素,如果是序列情況下,一般都會返回第一個元素,並行情況下就不一定了。
count
返回元素個數。
Stream<String> a = Stream.of("a", "b", "c");
long x = a.count();
peek
建立一個通道,在這個通道中對 Stream 的每個元素執行對應的操作,對應 Consumer<T>
的函式式介面,這是一個消費者函式式介面,顧名思義,它是用來消費 Stream 元素的,比如下面這個方法,把每個元素轉換成對應的大寫字母並輸出。
private static void peek() {
Stream<String> a = Stream.of("a", "b", "c");
List<String> list = a.peek(e->System.out.println(e.toUpperCase())).collect(Collectors.toList());
}
forEach
和 peek 方法類似,都接收一個消費者函式式介面,可以對每個元素進行對應的操作,但是和 peek 不同的是,forEach
執行之後,這個 Stream 就真的被消費掉了,之後這個 Stream 流就沒有了,不可以再對它進行後續操作了,而 peek
操作完之後,還是一個可操作的 Stream 物件。
正好藉著這個說一下,我們在使用 Stream API 的時候,都是一串鏈式操作,這是因為很多方法,比如接下來要說到的 filter
方法等,返回值還是這個 Stream 型別的,也就是被當前方法處理過的 Stream 物件,所以 Stream API 仍然可以使用。
private static void forEach() {
Stream<String> a = Stream.of("a", "b", "c");
a.forEach(e->System.out.println(e.toUpperCase()));
}
forEachOrdered
功能與 forEach
是一樣的,不同的是,forEachOrdered
是有順序保證的,也就是對 Stream 中元素按插入時的順序進行消費。為什麼這麼說呢,當開啟並行的時候,forEach
和 forEachOrdered
的效果就不一樣了。
Stream<String> a = Stream.of("a", "b", "c");
a.parallel().forEach(e->System.out.println(e.toUpperCase()));
當使用上面的程式碼時,輸出的結果可能是 B、A、C 或者 A、C、B或者A、B、C,而使用下面的程式碼,則每次都是 A、 B、C
Stream<String> a = Stream.of("a", "b", "c");
a.parallel().forEachOrdered(e->System.out.println(e.toUpperCase()));
limit
獲取前 n 條資料,類似於 MySQL 的limit,只不過只能接收一個引數,就是資料條數。
private static void limit() {
Stream<String> a = Stream.of("a", "b", "c");
a.limit(2).forEach(e->System.out.println(e));
}
上述程式碼列印的結果是 a、b。
skip
跳過前 n 條資料,例如下面程式碼,返回結果是 c。
private static void skip() {
Stream<String> a = Stream.of("a", "b", "c");
a.skip(2).forEach(e->System.out.println(e));
}
distinct
元素去重,例如下面方法返回元素是 a、b、c,將重複的 b 只保留了一個。
private static void distinct() {
Stream<String> a = Stream.of("a", "b", "c","b");
a.distinct().forEach(e->System.out.println(e));
}
sorted
有兩個過載,一個無引數,另外一個有個 Comparator
型別的引數。
無參型別的按照自然順序進行排序,只適合比較單純的元素,比如數字、字母等。
private static void sorted() {
Stream<String> a = Stream.of("a", "c", "b");
a.sorted().forEach(e->System.out.println(e));
}
有引數的需要自定義排序規則,例如下面這個方法,按照第二個字母的大小順序排序,最後輸出的結果是 a1、b3、c6。
private static void sortedWithComparator() {
Stream<String> a = Stream.of("a1", "c6", "b3");
a.sorted((x,y)->Integer.parseInt(x.substring(1))>Integer.parseInt(y.substring(1))?1:-1).forEach(e->System.out.println(e));
}
為了更好的說明接下來的幾個 API ,我模擬了幾條專案中經常用到的類似資料,10條使用者資訊。
private static List<User> getUserData() {
Random random = new Random();
List<User> users = new ArrayList<>();
for (int i = 1; i <= 10; i++) {
User user = new User();
user.setUserId(i);
user.setUserName(String.format("古時的風箏 %s 號", i));
user.setAge(random.nextInt(100));
user.setGender(i % 2);
user.setPhone("18812021111");
user.setAddress("無");
users.add(user);
}
return users;
}
filter
用於條件篩選過濾,篩選出符合條件的資料。例如下面這個方法,篩選出性別為 0,年齡大於 50 的記錄。
private static void filter(){
List<User> users = getUserData();
Stream<User> stream = users.stream();
stream.filter(user -> user.getGender().equals(0) && user.getAge()>50).forEach(e->System.out.println(e));
/**
*等同於下面這種形式 匿名內部類
*/
// stream.filter(new Predicate<User>() {
// @Override
// public boolean test(User user) {
// return user.getGender().equals(0) && user.getAge()>50;
// }
// }).forEach(e->System.out.println(e));
}
map
map
方法的介面方法宣告如下,接受一個 Function
函式式介面,把它翻譯成對映最合適了,通過原始資料元素,對映出新的型別。
<R> Stream<R> map(Function<? super T, ? extends R> mapper);
而 Function
的宣告是這樣的,觀察 apply
方法,接受一個 T 型引數,返回一個 R 型引數。用於將一個型別轉換成另外一個型別正合適,這也是 map
的初衷所在,用於改變當前元素的型別,例如將 Integer
轉為 String
型別,將 DAO 實體型別,轉換為 DTO 例項型別。
當然了,T 和 R 的型別也可以一樣,這樣的話,就和 peek
方法沒什麼不同了。
@FunctionalInterface
public interface Function<T, R> {
/**
* Applies this function to the given argument.
*
* @param t the function argument
* @return the function result
*/
R apply(T t);
}
例如下面這個方法,應該是業務系統的常用需求,將 User 轉換為 API 輸出的資料格式。
private static void map(){
List<User> users = getUserData();
Stream<User> stream = users.stream();
List<UserDto> userDtos = stream.map(user -> dao2Dto(user)).collect(Collectors.toList());
}
private static UserDto dao2Dto(User user){
UserDto dto = new UserDto();
BeanUtils.copyProperties(user, dto);
//其他額外處理
return dto;
}
mapToInt
將元素轉換成 int 型別,在 map
方法的基礎上進行封裝。
mapToLong
將元素轉換成 Long 型別,在 map
方法的基礎上進行封裝。
mapToDouble
將元素轉換成 Double 型別,在 map
方法的基礎上進行封裝。
flatMap
這是用在一些比較特別的場景下,當你的 Stream 是以下這幾種結構的時候,需要用到 flatMap
方法,用於將原有二維結構扁平化。
Stream<String[]>
Stream<Set<String>>
Stream<List<String>>
以上這三類結構,通過 flatMap
方法,可以將結果轉化為 Stream<String>
這種形式,方便之後的其他操作。
比如下面這個方法,將List<List<User>>
扁平處理,然後再使用 map
或其他方法進行操作。
private static void flatMap(){
List<User> users = getUserData();
List<User> users1 = getUserData();
List<List<User>> userList = new ArrayList<>();
userList.add(users);
userList.add(users1);
Stream<List<User>> stream = userList.stream();
List<UserDto> userDtos = stream.flatMap(subUserList->subUserList.stream()).map(user -> dao2Dto(user)).collect(Collectors.toList());
}
flatMapToInt
用法參考 flatMap
,將元素扁平為 int 型別,在 flatMap
方法的基礎上進行封裝。
flatMapToLong
用法參考 flatMap
,將元素扁平為 Long 型別,在 flatMap
方法的基礎上進行封裝。
flatMapToDouble
用法參考 flatMap
,將元素扁平為 Double 型別,在 flatMap
方法的基礎上進行封裝。
collection
在進行了一系列操作之後,我們最終的結果大多數時候並不是為了獲取 Stream 型別的資料,而是要把結果變為 List、Map 這樣的常用資料結構,而 collection
就是為了實現這個目的。
就拿 map 方法的那個例子說明,將物件型別進行轉換後,最終我們需要的結果集是一個 List<UserDto >
型別的,使用 collect
方法將 Stream 轉換為我們需要的型別。
下面是 collect
介面方法的定義:
<R, A> R collect(Collector<? super T, A, R> collector);
下面這個例子演示了將一個簡單的 Integer Stream 過濾出大於 7 的值,然後轉換成 List<Integer>
集合,用的是 Collectors.toList()
這個收集器。
private static void collect(){
Stream<Integer> integerStream = Stream.of(1,2,5,7,8,12,33);
List<Integer> list = integerStream.filter(s -> s.intValue()>7).collect(Collectors.toList());
}
很多同學表示看不太懂這個 Collector
是怎麼一個意思,來,我們看下面這段程式碼,這是 collect
的另一個過載方法,你可以理解為它的引數是按順序執行的,這樣就清楚了,這就是個 ArrayList 從建立到呼叫 addAll
方法的一個過程。
private static void collect(){
Stream<Integer> integerStream = Stream.of(1,2,5,7,8,12,33);
List<Integer> list = integerStream.filter(s -> s.intValue()>7).collect(ArrayList::new, ArrayList::add,
ArrayList::addAll);
}
我們在自定義 Collector
的時候其實也是這個邏輯,不過我們根本不用自定義, Collectors
已經為我們提供了很多拿來即用的收集器。比如我們經常用到Collectors.toList()
、Collectors.toSet()
、Collectors.toMap()
。另外還有比如Collectors.groupingBy()
用來分組,比如下面這個例子,按照 userId 欄位分組,返回以 userId 為key,List
// 返回 userId:List<User>
Map<String,List<User>> map = user.stream().collect(Collectors.groupingBy(User::getUserId));
// 返回 userId:每組個數
Map<String,Long> map = user.stream().collect(Collectors.groupingBy(User::getUserId,Collectors.counting()));
toArray
collection
是返回列表、map 等,toArray
是返回陣列,有兩個過載,一個空引數,返回的是 Object[]
。
另一個接收一個 IntFunction<R>
型別引數。
@FunctionalInterface
public interface IntFunction<R> {
/**
* Applies this function to the given argument.
*
* @param value the function argument
* @return the function result
*/
R apply(int value);
}
比如像下面這樣使用,引數是 User[]::new
也就是new 一個 User 陣列,長度為最後的 Stream 長度。
private static void toArray() {
List<User> users = getUserData();
Stream<User> stream = users.stream();
User[] userArray = stream.filter(user -> user.getGender().equals(0) && user.getAge() > 50).toArray(User[]::new);
}
reduce
它的作用是每次計算的時候都用到上一次的計算結果,比如求和操作,前兩個數的和加上第三個數的和,再加上第四個數,一直加到最後一個數位置,最後返回結果,就是 reduce
的工作過程。
private static void reduce(){
Stream<Integer> integerStream = Stream.of(1,2,5,7,8,12,33);
Integer sum = integerStream.reduce(0,(x,y)->x+y);
System.out.println(sum);
}
另外 Collectors
好多方法都用到了 reduce
,比如 groupingBy
、minBy
、maxBy
等等。
並行 Stream
Stream 本質上來說就是用來做資料處理的,為了加快處理速度,Stream API 提供了並行處理 Stream 的方式。通過 users.parallelStream()
或者users.stream().parallel()
的方式來建立並行 Stream 物件,支援的 API 和普通 Stream 幾乎是一致的。
並行 Stream 預設使用 ForkJoinPool
執行緒池,當然也支援自定義,不過一般情況下沒有必要。ForkJoin 框架的分治策略與並行流處理正好契合。
雖然並行這個詞聽上去很厲害,但並不是所有情況使用並行流都是正確的,很多時候完全沒這個必要。
什麼情況下使用或不應使用並行流操作呢?
- 必須在多核 CPU 下才使用並行 Stream,聽上去好像是廢話。
- 在資料量不大的情況下使用普通序列 Stream 就可以了,使用並行 Stream 對效能影響不大。
- CPU 密集型計算適合使用並行 Stream,而 IO 密集型使用並行 Stream 反而會更慢。
- 雖然計算是並行的可能很快,但最後大多數時候還是要使用
collect
合併的,如果合併代價很大,也不適合用並行 Stream。 - 有些操作,比如 limit、 findFirst、forEachOrdered 等依賴於元素順序的操作,都不適合用並行 Stream。
最後
Java 25 週歲了,有多少同學跟我一樣在用 Java 8,還有多少同學再用更早的版本,請說出你的故事。
壯士且慢,先給點個贊吧,總是被白嫖,身體吃不消!
我是風箏,公眾號「古時的風箏」。一個兼具深度與廣度的程式設計師鼓勵師,一個本打算寫詩卻寫起了程式碼的田園碼農!你可選擇現在就關注我,或者看看歷史文章再關注也不遲。