該文章主要介紹JDK中各類常見的函數式接口,並會附上一些我的對函數式編程的一些擴展思考與實際用法。java
jdk1.8的函數式接口都在rt.jar中java.util.function
包下,如下以jdk集合類與我的經常使用的接口進行介紹:數據庫
Function<T,R>
:傳入類型爲T的對象並執行含返回值(返回值爲R-return類型)的指定方法,方法可臨時實現。常見於類Optional{map();flatMap();}
、Stream{map();flatMap();}
、Comparator{thenComparing();}
等,MybatisPlus 3.0版本以後的SFunction
接口與該接口做用相同,區別在於添加了序列化,使開發者可經過傳入getter Function匹配對應字段而無需再寫字段名進行匹配,免除字段名寫錯的問題。編程
BiFunction<T,U,R>
:傳入類型爲T、U類型(T、U能夠相同)的兩個對象並執行含返回值的指定方法,方法可臨時實現。常見於類Stream{reduce();}
、Map{replaceAll();computeIfPresent();compute();merge();}
等。緩存
Consumer<T>
:傳入單個對象並執行對象中無返回值的指定方法,方法可臨時實現。常見於類List{foreach();}
、Stream{foreach();}
、Optional{ifPresent();}
等。服務器
BiConsumer<T, U>
:傳入兩個對象並執行對象中無返回值的指定方法,方法可臨時實現。常見於類Stream{collect();}
、Map{foreach();}
等。session
Supplier<T>
:供應商接口,可理解爲對象的無參構造函數代理接口,每次調用其get()方法都會產生一個新的對象。常見於類Stream{generate();collect();}
Objects{requireNonNull();}
、ThreadLocal{withInitial();}
app
Predicate<T>
:傳入一個對象返回其指定行爲方法執行結果布爾值,方法可臨時實現。常見於類Optional{filter();}
、Stream{filter();anyMatch();allMatch();noneMatch();}
、ArrayList{removeIf();}
等框架
BiPredicate<T, U>
:可根據前面的Bi接口與Predicate
推斷,再也不多做闡述ssh
Stream中的函數式編程ide
如下先以一段代碼簡單的介紹jdk中的函數式用法:
List<String> list = Lists.newArrayList("a", "b", "c", "d", "e");
String result = list.stream()
.filter(str -> !StringUtils.equals(str, "c")) // ① 參數爲Predicate<? super String>,返回值爲Stream<String>
.map(str -> str + ",") // ② 參數爲Function<? super String, ? extends String>,返回值爲Stream<String>
.reduce((current, next) -> current + next) // ③ 參數爲BinaryOperator<String>,返回值爲Optional<String>
.orElse("");
複製代碼
List轉爲Stream後Stream中的泛型都會對應爲List元素的類型,如下爲上面幾個stream對象方法的簡單講解: ①:實現了一個Predicate<String>
接口,並讓Stream對象調用該接口的實現操做去過濾獲取列表中元素值不爲"c"
的元素 ②: 實現了一個Function<String,String>
接口,在每一個元素末尾添加字符串",",並返回添加後的結果 ③: 實現了一個BinaryOperator<String>
接口,將stream的當前元素與下一個元素進行拼接並返回拼接結果。BinaryOperator<T>
是BiFunction<T,U,R>
的子接口,在2個參數類型與返回類型都相同的狀況下可以使用BinaryOperator接口替代BiFunction
接口,但兩個接口實質上都須要實現apply()方法進行操做並返回結果,並沒有太大區別,可把BinaryOperator
當成BiFunction
的一個子集,其定義以下:
@FunctionalInterface
public interface BinaryOperator<T> extends BiFunction<T,T,T> {
......
}
複製代碼
單看以上代碼可能還沒法體現出爲何叫函數式編程的緣由,如今把以上代碼還原爲爲函數實現顯示樣式:
String result = list.stream()
.filter(new Predicate<String>() {
@Override
public boolean test(String s) {
return !StringUtils.equals(s, "c");
}
})
.map(new Function<String, String>() {
@Override
public String apply(String s) {
return s + ",";
}
})
.reduce(new BinaryOperator<String>() {
@Override
public String apply(String current, String next) {
return current + next;
}
})
.orElse("");
複製代碼
兩段的執行代碼均可編譯執行,對比可知第一段代碼只是對第二段代碼的簡化,第二段代碼中詳細的顯示了對列表轉stream後的操做實現了哪些接口與實現的函數操做,顯得十分臃腫,而第一段代碼只顯示了實現的函數操做,故我的認爲將重點放在函數實現操做即是函數式編程的核心。 相信各個讀者都發現了全部函數式接口所需實現的函數都有且僅有一個,我的認爲目的除了更優雅的顯示之外,還可讓程序知道即便我傳入的是一個函數式接口實現類,程序依然會清楚它還要再去執行該類型的指定函數。
List中的函數式編程 List中含函數式接口參數的方法主要爲foreach(Consumer),遍歷元素時將元素做爲參數傳入Consumer執行,最簡單的例子爲list.forEach(System.out::println);
,調用System.out對象的println方法打印遍歷的當前元素。
Map中的函數式編程 Map中我的經常使用的含函數式接口參數的方法主要爲foreach(BiConsumer<? super K, ? super V>)
和compute(K, BiFunction<? super K, ? super V, ? extends V>)
,其他的相信你們能夠觸類旁及。foreach爲遍歷當前map中的元素,前面介紹BiConsumer須要傳入兩個參數,而map.foreach()執行時每一個key、value則做爲參數傳入到BiConsumer。雖說須要傳兩個參數給BiConsumer,但不表明每一個參數都必須用到,以下例中的BiConsumer只對每一個val參數列表添加「z」
字符串而沒有用到key參數:
Map<String, List<String>> map = new HashMap<>();
map.put("0", Lists.newArrayList(""));
map.put("1", Lists.newArrayList("a"));
map.put("2", Lists.newArrayList("a", "b"));
map.put("3", Lists.newArrayList("a", "b", "c"));
map.forEach((k,v) -> v.add("z")); // ① 每一個val列表末尾添加z字符串
複製代碼
若是以爲有點難理解的可看如下函數還原代碼:
map.forEach(new BiConsumer<String, List<String>>() {
@Override
public void accept(String key, List<String> list) {
list.add("z");
}
});
複製代碼
Map的compute()方法根據名稱你們也能夠估到該方法是進行某些計算後再去設計key的值,可用於Map中指定key的值計算,在實際開發中我的經常使用於該狀況:map的val爲列表,map須要爲指定key的val添加元素,添加前需判斷val列表是否爲空,爲空則初始化後再添加,不爲空則直接添加。
map.compute("4",(key, list) -> list == null ? Lists.newArrayList("a", "b", "c", "d") : ListUtils.add(list,"z"));
複製代碼
以上代碼判斷map中key爲4的列表是否爲空,若爲空則將map中key爲4的val設爲元素爲"a", "b", "c", "d"
的列表,不爲空則在原val列表中添加字符串"z"
。其中ListUtils爲自定義工具類,其add方法返回參數列表,便於一行代碼實現目的,實現以下:
public static <T> List<T> add(List<T> list, T t) {
list.add(t);
return list;
}
複製代碼
看了map.compute()的都知道該函數能夠替代在操做map一些狀況下的if判斷,若把上面的compute()
方法使用if
執行,則將變成如下代碼塊:
if(map.containsKey("4")){
map.get("4").add("z");
}else {
map.put("4",Lists.newArrayList("a", "b", "c", "d"));
}
複製代碼
能夠看出適當的使用函數式編程能夠爲咱們減小代碼行。
Optional簡化if JDK1.8新增了Optional類使開發者能夠減小if的語句塊,類也含很多參數爲函數式接口的方法,如下以一個簡單的代碼塊進行介紹:
Classify classify = new Classify();
Optional.ofNullable(classify)
.map(Classify::getName)
.orElse("null");
複製代碼
上例中把classify對象交給Optional代理,若是classify對象爲空或classify對象中的name屬性爲空則返回字符串「null」,其中map的參數爲Function。
看到這相信你們都瞭解到JDK中的函數式方法都是殊途同歸,區別只在於在實際使用時泛型對應的實際類型。
前面基本都是談我的對函數式的認知與JDK原生類函數式參數方法的用法,而此處開始,是時候展示真正的技術了[doge]。函數式接口運用得當能夠省略很多,下文將以幾個我的實際開發中思考或使用過的例子進行函數式使用的思惟拓展。
分類例子實體Classify:
@Data
@Accessors(chain = true)
public class Classify {
private Long id;
private String name;
private Integer level;
private Long parentId;
private transient List<Classify> sonClassifies;
}
複製代碼
現有一個List<Classify>
的列表對象,如今須要將列表中全部分類的名字從新提取爲一個列表,瞭解Stream會這樣寫:
List<String> names = list.stream()
.map(Classify::getName)
.collect(Collectors.toList());
複製代碼
又有一個需求須要將列表元素轉化成key爲id,value爲name的映射,這時會寫成以下:
Map<Long,String> idNameMap = list.stream()
.collect(Collectors.toMap(Classify::getId, Classify::getName));
複製代碼
又又有一個需求須要將全部分類轉換成key爲parentId,value爲子分類元素列表的映射,這時會寫成以下:
Map<Long, List<Classify>> parentSonsMap = list.stream()
.collect(Collectors.groupingBy(Classify::getParentId));
複製代碼
以上寫法都是比較普通的寫法,應該任何人均可以接受,但我想這麼簡單的操做可不能夠一行解決呢?也有部分開發者認爲把全部stream方法調用放到同一行就能夠了,但對我而言這會影響代碼的可讀性(雖然影響可能不大)。在開發者以上List轉換的情況雖然很少,但也不算少,爲了可一行代碼取代Stream的簡單操做,我的擼了一個List工具類放到了本身的通用框架中,經過Function做爲參數取代Stream的簡單操做,完整以下:
public class ListUtils {
private ListUtils() {
}
public static <T> List<T> add(List<T> list, T t) {
list.add(t);
return list;
}
public static boolean isEmpty(Collection collection) {
return collection == null || collection.isEmpty();
}
public static boolean isNotEmpty(Collection collection) {
return !isEmpty(collection);
}
public static <T> ArrayList<T> newArrayList(T... elements) {
ArrayList<T> list = new ArrayList<>(elements.length + elements.length >> 1 + 5);
Collections.addAll(list, elements);
return list;
}
/**
* 條件爲true時才添加元素
*
* @param condition 條件
* @param collection 集合
* @param val
* @return 添加結果
*/
public static <T> boolean addIf(boolean condition, Collection<T> collection, T val) {
return condition && collection.add(val);
}
/**
* 從對象列表中提取對象屬性
*
* @param list 對象列表
* @param valGetter 對象屬性get方法
* @param <T> 對象
* @param <V> 對象屬性
* @return 對象屬性列表
*/
public static <T, V> List<V> collectToList(Collection<T> list, Function<T, V> valGetter) {
List<V> properties = new ArrayList<>(list.size());
list.forEach(e -> properties.add(valGetter.apply(e)));
return properties;
}
/**
* 從對象列表中提取指定屬性爲key,當前對象爲value轉爲map
*
* @param list
* @param keyGetter
* @param <T>
* @param <K>
* @return
*/
public static <T, K> Map<K, T> collectToMap(Collection<T> list, Function<T, K> keyGetter) {
Map<K, T> propertiesMap = new HashMap<>(list.size());
list.forEach(e -> propertiesMap.put(keyGetter.apply(e), e));
return propertiesMap;
}
/**
* 從對象列表中提取指定屬性T爲key,屬性V爲value轉爲map
*
* @param list 對象列表
* @param keyGetter
* @param valGetter
* @param <T>
* @param <K>
* @param <V>
* @return
*/
public static <T, K, V> Map<K, V> collectToMap(Collection<T> list, Function<T, K> keyGetter, Function<T, V> valGetter) {
Map<K, V> propertiesMap = new HashMap<>(list.size());
list.forEach(e -> propertiesMap.put(keyGetter.apply(e), valGetter.apply(e)));
return propertiesMap;
}
/**
* 根據列表對象中的某屬性值爲key劃分列表,value爲key的屬性值相同的對象列表,
* 功能同stream().collect(Collectors.groupingBy())
*
* @param list
* @param keyGetter
* @param <T>
* @param <K>
* @return
*/
public static <T, K> Map<K, List<T>> groupToMap(Collection<T> list, Function<T, K> keyGetter) {
Map<K, List<T>> propertiesMap = new HashMap<>(list.size());
for (T each : list) {
propertiesMap.compute(keyGetter.apply(each),
(key, valueList) -> isEmpty(valueList) ? add(new ArrayList<>(list.size()), each) : add(valueList, each));
}
return propertiesMap;
}
/**
* 根據列表對象中的某屬性值爲key劃分列表,value爲key的屬性值相同的對象列表,value爲key的屬性值相同的對象中指定屬性的值列表,
* 功能同stream().collect(Collectors.groupingBy())
*
* @param list
* @param keyGetter
* @param valGetter
* @param <T>
* @param <K>
* @param <V>
* @return
*/
public static <T, K, V> Map<K, List<V>> groupToMap(Collection<T> list, Function<T, K> keyGetter, Function<T, V> valGetter) {
Map<K, List<V>> propertiesMap = new HashMap<>(list.size());
for (T each : list) {
K key = keyGetter.apply(each);
List<V> values = Optional.ofNullable(propertiesMap.get(key)).orElse(new ArrayList<>());
values.add(valGetter.apply(each));
propertiesMap.put(key, values);
}
return propertiesMap;
}
/**
* 獲取列表中重複的值
*
* @param list
* @param <T>
* @return
*/
public static <T> Set<T> collectRepeats(Collection<T> list) {
Set<T> set = new HashSet<>(list.size());
return list.stream()
.filter(e -> !set.add(e))
.collect(Collectors.toSet());
}
/**
* 按指定大小,分隔集合,將集合按規定個數分爲n個部分
*
* @param <T>
* @param list
* @param len
* @return
*/
public static <T> List<List<T>> splitList(List<T> list, int len) {
if (list == null || list.isEmpty() || len < 1) {
return Collections.emptyList();
}
List<List<T>> result = new ArrayList<>();
int size = list.size();
int count = (size + len - 1) / len;
for (int i = 0; i < count; i++) {
List<T> subList = list.subList(i * len, ((i + 1) * len > size ? size : len * (i + 1)));
result.add(subList);
}
return result;
}
}
複製代碼
看看使用該工具類替代Stream簡單操做後的效果吧:
List<String> namess = ListUtils.collectToList(list,Classify::getName);
Map<Long, String> idMap = ListUtils.collectToMap(list,Classify::getId,Classify::getName);
Map<Long, List<Classify>> parentSonsMap = ListUtils.groupToMap(list,Classify::getParentId);
// 將List轉化成key爲parentId,value爲子分類name列表的映射
Map<Long, List<String>> parentSonNamesMap = ListUtils.groupToMap(list,Classify::getId,Classify::getName);
複製代碼
能夠看出經過函數式接口做爲參數傳遞,不只能夠增長程序的可讀性,還能夠爲咱們的編碼開發添加很多擴展性。
局部不一樣多出相同的代碼塊重複出現的情況總會遇到,如一些業務代碼先後都相同惟獨中間不一樣,如DB鏈接-操做-釋放、Ssh鏈接-操做-釋放,如下將以一個ssh鏈接-操做-釋放的代碼來擴展函數式編程簡化代碼的用法。 可能會有人疑問ssh鏈接-操做-釋放這樣的實際操做業務很少吧?就在一段時間以前,上級讓我去Zabbix查看各服務器的CPU、內存、磁盤使用率而後寫入文檔。看到機器數的我心裏是拒接的,因而想出了使用java ssh鏈接到服務器執行相應的查看指令而後提取佔用率打印到控制檯上,再copy到文檔中(反正獲得了默許了)。如下是未優化前的兩個查詢方法:
/**
* 查詢cpu佔用率
*/
public static String cpuPercent(String ip, String username, String passw
JSch jsch = new JSch();
Session session = null;
Channel channel = null;
String cpuPercent = null;
try {
session = jsch.getSession(username, ip, 22);
Properties config = new Properties();
config.put("StrictHostKeyChecking", "no");
session.setConfig(config);
session.setPassword(password);
session.connect();
String cmd = "sar -u 3 1|awk '{print $8}'|tail -1";
channel = session.openChannel("exec");
((ChannelExec) channel).setCommand(cmd);
((ChannelExec) channel).setErrStream(System.err);
((ChannelExec) channel).setPty(true);
channel.connect();
InputStream in = channel.getInputStream();
String output = IOUtils.toString(in, StandardCharsets.UTF_8);
cpuPercent = HUNDRED.subtract(BigDecimal.valueOf(Double.valueOf(
.setScale(2, RoundingMode.HALF_UP)
.toString() + "%";
} catch (Exception e) {
e.printStackTrace();
} finally {
if (channel != null) {
channel.disconnect();
}
if (session != null) {
session.disconnect();
}
}
return cpuPercent;
}
/**
* 磁盤佔用率查詢
*/
public static String diskPercent(String ip, String username, String pass
JSch jsch = new JSch();
Session session = null;
Channel channel = null;
String diskPercent = null;
try {
session = jsch.getSession(username, ip, 22);
Properties config = new Properties();
config.put("StrictHostKeyChecking", "no");
session.setConfig(config);
session.setPassword(password);
session.connect();
String cmd = "df -hl | grep apps|tail -1|awk '{print $4}'";
String cmd = "df -hl | grep apps|tail -1|awk '{print $5}'";
channel = session.openChannel("exec");
((ChannelExec) channel).setCommand(cmd);
((ChannelExec) channel).setErrStream(System.err);
((ChannelExec) channel).setPty(true);
channel.connect();
InputStream in = channel.getInputStream();
diskPercent = IOUtils.toString(in, StandardCharsets.UTF_8);
} catch (Exception e) {
e.printStackTrace();
} finally {
if (channel != null) {
channel.disconnect();
}
if (session != null) {
session.disconnect();
}
}
return diskPercent;
}
複製代碼
相信你們能夠看出Ssh鏈接與釋放的代碼塊是相同的,惟獨操做是不一樣的,因而我把相同的代碼塊寫入了一個方法中,操做的代碼塊做爲參數,優化後的完整代碼以下:
public class SshClientUtils {
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
private static final String RESULT_FORMAT = "%s\t\t%s\t\t%s\t%s";
/**
* 執行查詢cpu、mem、disk命令並打印各佔用率
*/
public static void exec(SshConfig sshConfig) {
System.err.println("cpu%\t\tmem%\t\tdisk\tip");
String username = sshConfig.getUsername();
String password = sshConfig.getPassword();
List<String> ipList = sshConfig.getIpList();
ipList.forEach(ip -> {
String cpuPercent = cpuPercent(ip, username, password);
String memoryPercent = memoryPercent(ip, username, password);
String diskPercent = diskPercent(ip, username, password);
System.out.println(String.format(RESULT_FORMAT, cpuPercent, memoryPercent, diskPercent, ip)
.replaceAll("\n|\r\n", ""));
});
}
/**
* 查詢cpu佔用率
*/
public static String cpuPercent(String ip, String username, String password) {
String cmd = "sar -u 3 1|awk '{print $8}'|tail -1";
return exec(ip, username, password, cmd, output -> HUNDRED.subtract(BigDecimal.valueOf(Double.valueOf(output)))
.setScale(2, RoundingMode.HALF_UP)
.toString() + "%");
}
/**
* 內存佔用率查詢
*/
public static String memoryPercent(String ip, String username, String password) {
String cmd = "free|grep Mem";
return exec(ip, username, password, cmd, output -> {
String[] memories = output.replaceAll("\\s+", ",")
.substring(5)
.split(",");
double total = Integer.parseInt(memories[0]);
double free = Integer.parseInt(memories[2]);
double buffers = Integer.parseInt(memories[4]);
double cache = Integer.parseInt(memories[5]);
BigDecimal freePercent = BigDecimal.valueOf((free + buffers + cache) / total)
.setScale(6, RoundingMode.HALF_UP);
return BigDecimal.ONE.subtract(freePercent)
.multiply(HUNDRED)
.setScale(2, RoundingMode.HALF_UP)
.toString() + "%";
});
}
/**
* 磁盤佔用率查詢
*/
public static String diskPercent(String ip, String username, String password) {
String cmd = "df -hl | grep apps|tail -1|awk '{print $5}'";
return exec(ip, username, password, cmd, output -> output);
}
/**
* 直接執行命令
*/
public static String exec(String ip, String username, String password, String command, Function<String, String> execFunc) {
JSch jsch = new JSch();
Session session = null;
Channel channel = null;
try {
session = jsch.getSession(username, ip, 22);
Properties config = new Properties();
config.put("StrictHostKeyChecking", "no");
session.setConfig(config);
session.setPassword(password);
session.connect();
channel = session.openChannel("exec");
((ChannelExec) channel).setCommand(command);
((ChannelExec) channel).setErrStream(System.err);
((ChannelExec) channel).setPty(true);
channel.connect();
InputStream in = channel.getInputStream();
String output = IOUtils.toString(in, StandardCharsets.UTF_8);
return func.apply(execFunc);
} catch (Exception e) {
e.printStackTrace();
} finally {
if (channel != null) {
channel.disconnect();
}
if (session != null) {
session.disconnect();
}
}
return null;
}
}
複製代碼
能夠看出優化的代碼將Ssh的鏈接與操做都抽象到exec()方法中了,而實際操做則是由入參的Function實現決定,以上即是一個經過Function優化代碼部分不一樣的例子。
將if-set對象屬性經過函數式接口放到對象內部執行
話多不如實例,相信你們都遇到過相似如下這樣的狀況:
if(condition1){ classify.setName("Wilson"); } if(condition2){ classify.setLevel(5); }
好麻煩,能不能再簡單一點(個人簡單永遠沒有上限),如今先對以上代碼塊分析一下(簡化的核心在於抽離),相同的部分主要有if、classify,不一樣的部分爲condition的值、set方法、set的值,既然有相同的就做爲方法,不一樣的就做爲參數吧(是否是跟ssh的例子想法差異不大吧),因而我在Classify類中添加了如下方法:
public <V> Classify set(boolean isSet, V value, BiFunction<Classify, V, Classify> setFunction) {
return isSet ? setFunction.apply(this, value) : this;
}
複製代碼
??? 唔,這裏可能有一些門檻,若是暫時不理解或以爲沒法靈活運動的也不用着急,代碼都是慢慢磨出來的,調用一下吧:
Classify classify = new Classify();
classify.set(true, "Wilson", Classify::setName)
.set(false, 5, Classify::setLevel);
System.out.println(classify);
// 打印出Classify(id=null, name=Wilson, level=null, parentId=null, sonClassifies=null)
複製代碼
因爲Classify在類上添加了Lombok的註解@Accessors(chain = true)
,因此每一個set方法結果都會返回當前對象方便鏈式調用(我很喜歡鏈式),因此上面的set方法能夠直接返回apply(this,setFunction)的結果。BiFunction前面有提過是須要兩個參數並返回一個結果的,在該例子中,因爲Classify的setProperty()是返回當前對象的,因此不能用Function<T,R>做爲set()的函數式參數(不然T與R都是Classify,沒法設置屬性),Classify對象做爲BiFunction的第一個參數,set()方法的value做爲第二個參數,當前classify對象做爲返回值,這樣就能夠保持個人對象能夠繼續鏈式調用各set方法。 也有會有人疑問set方法設置返回值不會影響程序的正常運行(如框架的調用)嗎?這裏我的是從反射與Java關鍵字void的角度思考事後就一直習慣使對象set方法返回當前對象了,這裏但願你們也思考一下便很少做講解了。
相信接觸過Spring的都會使用過其中BeanUtils的copyProperties()
方法,我的常用該方法進行VO屬性到Model屬性的設置,Model通常都是現場new因此內部屬性都是的,反正都是空的何再也不經過Supplier函數式接口擴展一下工具類提升一下逼格呢?因而便有了如下代碼:
@NoArgsConstructor
public class ObjectUtils {
public static <S, T> T copyProperties(S source, T target) {
BeanUtils.copyProperties(source, target);
return target;
}
public static <S, T> T copyProperties(S source, Supplier<T> targetSupplier) {
T target = targetSupplier.get();
BeanUtils.copyProperties(source, target);
return target;
}
}
複製代碼
再以一段Controller的僞代碼演示一下: Long id = classifyService.insert(ObjectUtils.copyProperties(classifyVO,Classify::new));
實際開發中還有不少能夠經過函數式接口簡化代碼的地方,如從數據庫中查出全部分類後而後將分類樹形排列再進行緩存預熱,樹形排列也可經過函數式接口抽象爲一個工具方法,但因爲通用性不強把該方法tree
從ListUtils
中移除了(當時實現是經過Map避免遞歸,只需遍歷2次全部分類列表完成排序完成),更多的用法能夠從實際開發中思考實現,具體想過多少種本身的記不清了。
該文章是我的最耗時的少有的基礎用法文章,以上的全部例子並不必定適合於全部人,但相信能夠擴展一下思惟,若是是初接觸者但願能夠爲你雪中送炭,若是有必定熟練度了但願能爲你錦上添花。這篇鬼文花了我好多多多多多多時間~難受。