引言
普通的工程師堆砌程式碼,優秀的工程師優雅程式碼,卓越的工程師簡化程式碼。如何寫出優雅整潔易懂的程式碼是一門學問,也是軟體工程實踐裡重要的一環。--來自網路
背景
軟體質量,不但依賴於架構及專案管理,更與程式碼質量緊密相關。簡潔高效的程式碼不但易於閱讀,更能避免潛在BUG與風險,提高程式碼質量。近期,一位Oracle程式設計師在Hacker News上吐槽自己的工作,引起了熱議。
這個工程師的核心痛點是,Oracle經歷長期的產品線迭代,程式碼異常龐大、邏輯複雜,整個程式碼中充斥著神祕的巨集命令。每新增一個特性或者修復BUG,該工程師都需要大量的調研,小心謹慎的進行著日常的工作。而Oracle每次的版本釋出都經歷數百萬次的測試,腦補一下,如噩夢一般。那麼我們應該如何編寫簡潔高效的程式碼呢?其實業內有過很多相關書籍,比如經典的書籍有《程式碼整潔之道》、《編寫可讀程式碼的藝術》、《重構:改善既有程式碼的設計》,可用於修煉內功。以及我們有嚴格的程式碼規範以及方便的靜態程式碼掃描工具,可用於加強研發程式碼質量能力。
簡潔之術
其實程式碼規範和靜態程式碼掃描工具能夠幫助我們完成很多程式碼簡潔的工作。諸如:註釋、命名、方法、異常、單元測試等多個方面。但卻無法總結了一些程式碼簡潔最佳實踐,其實Java是物件導向語音,而物件導向的特徵是封裝、繼承、多型,巧妙的運用這三大特性、理解Java的一些關鍵字特性、語音特性、閱讀JDK原始碼,就可以寫出相對簡潔的程式碼了。
簡化邏輯
// 修改前
if(list.size()>0) {
return true;
} else {
return false;
}
// 修改後
return list.size()>0;
複製程式碼
- if/else 語法:if語句包含一個布林表示式,當if語句的布林表示式值為false時,else語句塊會被執行;
- return 關鍵字:返回一個任意型別的值;
- list.size()>0 表示式:list.size()方法本身是一個返回int型別數值的函式,而與>0組成了一個布林表示式;
省略無意義賦值
// 修改前
public List<Map<String, Object>> queryList(Map<String, Object> params) {
List<Map<String, Object>> list = null;
try {
list = mapper.queryList(params);
} catch (Throwable e) {
throw new RuntimeException("ERROR", e);
}
return list;
}
// 修改後
public List<Map<String, Object>> queryList(Map<String, Object> params) {
try {
return mapper.queryList(params);
} catch (Throwable e) {
throw new RuntimeException("ERROR", e);
}
}
複製程式碼
- 區域性變數list的資料型別與該方法的返回值型別一致,而多餘的變數也將會增加JVM垃圾回收的消耗;
- 區域性變數list只是負責接收了mapper.queryList(params)的返回值,而並沒有其他邏輯處理;
- 此程式碼存在於service層和mapper層之間,可以在框架層面進一步抽象,利用註解、java8 default方法等進一步改進;
最小化判斷
// 修改前
if (0 == retCode) {
sendMessage("A001", "Process Success", outResult);
} else {
sendMessage("A001", "Process Failure", outResult);
}
// 修改後 1
String message = (0 == retCode ? "Process Success" : "Process Failure");
sendMessage("A001", message, outResult);
// 修改後 2
sendMessage("A001", messageFromRetCode(retCode), outResult);
複製程式碼
- 程式碼中if else的存在只是因為sendMessage函式的第二個引數會有兩種情況(成功/失敗),儘量讓判斷最小化;
set方法治理
// 修改前
String uuid = UUIDUtils.getUUID();
String date = DateTimeUtils.getCurrDt();
String time = DateTimeUtils.getCurrTm();
Order order = new Order();
order.setSrUsrId(map.get("srcUsrId"));
// 省略幾十個set
...
order.setTmsDate(date);
order.setTmsCte(time);
order.setUuid(uuid);
list.add(order);
// 修改後
list.add(buildOrder(map));
public Order buildOrder(Map<String,String> map){
Order order = new Order();
order.setSrUsrId(map.get("srcUsrId"));
// 省略幾十個set
...
String date = DateTimeUtils.getCurrDt();
order.setTmsDate(date);
order.setTmsCte(DateTimeUtils.getCurrTm());
String uuid = UUIDUtils.getUUID();
order.setUuid(uuid);
return order;
}
複製程式碼
- 大坨的set方法很影響程式碼可讀性,可封裝成特定方法或者使用lombok工具簡化程式碼;
- 區域性變數就近宣告,增加可讀性,區域性變數宣告和使用地方距離遙遠,會導致的讀者頻繁滑動;
- 可不宣告變數儘量不要宣告多餘的變數,冗餘程式碼;(如date、time兩段程式碼);
巧用JAVA8特性-函數語言程式設計簡化程式碼
JAVA8特性“函數語言程式設計”,使用Lambdas我們能做到什麼?
- 遍歷集合(List、Map等)、Sum、Max、Min、Avg、Sort、Distinct等等
- 函式介面
- 謂詞(Predicate)使用
- 實現Map和Reduce
- 實現事件處理/簡化多執行緒
內、外部迴圈
// 修改前
public static void test1() {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
for (int number : numbers) {
System.out.println(number);
}
}
// 修改後1
// 使用lambda表示式以及函式操作(functional operation)
public static void test2(){
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
numbers.forEach((Integer value)-> System.out.println(value));
}
// 修改後2
//在Java8中使用雙冒號操作符(double colon operator)
public static void test3(){
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
numbers.forEach(System.out::println);
}
複製程式碼
上述程式碼是傳統方式的遍歷一個List的寫法,簡單來說主要有3個不足:
- 只能順序處理list中的資料(process one by one)
- 不能充分利用多核cpu
- 不利於編譯器優化(jit)
而使用函數語言程式設計能規避上面的三個問題:
- 不一定需要順序處理List中的元素,順序可以不確定
- 可以並行處理,充分利用多核CPU的優勢
- 有利於JIT編譯器對程式碼進行優化
- 程式碼看起來更簡潔,完全交給編譯器內部迴圈
default方法
在Java8中,介面中的方法可以被實現,用關鍵字 default 作為修飾符來標識,介面中被實現的方法叫做 default 方法。使用default方法,當介面發生改變的時候,實現類不需要做改動,所有的子類都會繼承 default 方法。
public class Test1 {
public static void main(String[] args) {
Formula formula = new Formula() {
@Override
public double calculate(int a) {
return sqrt(a * 100);
}
};
System.out.println(formula.calculate(100)); // 100.0
System.out.println(formula.sqrt(16)); // 4.0
}
}
interface Formula {
double calculate(int a);
default double sqrt(int a) {
return Math.sqrt(a);
}
}
複製程式碼
當一個介面擴充套件另外一個包含預設方法的介面的時候,有以下3種處理方式。
- 完全無視預設方法(直接繼承上級介面的預設方法)
- 重新申明預設方法為抽象方法(無實現,具體子類必需再次實現該方法)
- 重新實現預設方法(重寫了預設方法的實現,依然是一個預設方法)
日期處理
Java8中新增了LocalDate和LocalTime介面,為什麼要搞一套全新的處理日期和時間的API?因為舊的java.util.Date實在是太難用了。
- java.util.Date月份從0開始,一月是0,十二月是11,變態吧!java.time.LocalDate月份和星期都改成了enum,就不可能再用錯了。
- java.util.Date和SimpleDateFormatter都不是執行緒安全的,而LocalDate和LocalTime和最基本的String一樣,是不變型別,不但執行緒安全,而且不能修改。
- java.util.Date是一個“萬能介面”,它包含日期、時間,還有毫秒數,如果你只想用java.util.Date儲存日期,或者只儲存時間,那麼,只有你知道哪些部分的資料是有用的,哪些部分的資料是不能用的。在新的Java8中,日期和時間被明確劃分為LocalDate和LocalTime,LocalDate無法包含時間,LocalTime無法包含日期。
當然,LocalDateTime才能同時包含日期和時間。
新介面更好用的原因是考慮到了日期時間的操作,經常發生往前推或往後推幾天的情況。用java.util.Date配合Calendar要寫好多程式碼,而且一般的開發人員還不一定能寫對。
- 1、Clock時鐘。Clock類提供了訪問當前日期和時間的方法,Clock是時區敏感的,可以用來取代System.currentTimeMillis(),來獲取當前的微秒數。某一個特定的時間點也可以使用Instant類(為Final類)來表示,Instant類也可以用來建立老的java.util.Date物件。
Clock c = Clock.systemDefaultZone();
System.out.println(System.currentTimeMillis());
System.out.println(c.millis());
Date date = Date.from(c.instant());
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
System.out.println(sdf.format(date));
// instant精確到納秒,比原來的date的毫秒要更精確
// 獲取當前時間
Instant in = Instant.now();
System.out.println(in);
// 將現在的時間增加3小時2分,將產生新的例項
Instant in1 = in.plus(Duration.ofHours(3).plusMinutes(2));
System.out.println(in1);
System.out.println(in1 == in);
// 關於計算的例子
in.minus(5, ChronoUnit.DAYS);// 計算5天前
in.minus(Duration.ofDays(5));// 計算5天前
// 計算兩個Instant之間的分鐘數
long diffAsMinutes1 = ChronoUnit.MINUTES.between(in, in1); // 方法2
System.out.println(diffAsMinutes1);
// instant是可比較的,有isAfter和isBefore
System.out.println(in.isAfter(in1));
System.out.println(in.isBefore(in1));
複製程式碼
- 2、LocalDate和LocalTime、LocalDateTime(均為Final類,不帶時區)的一系列計算。LocalDateTime和Instant兩者很像都是不帶時區的日期和時間,Instant中是不帶時區的即時時間點。比如:兩個人都是2018年4月14日出生的,一個出生在北京,一個出生在紐約;看上去他們是一起出生的(LocalDateTime的語義),其實他們是有時間差的(Instant的語義)
// 取當前日期
LocalDate today = LocalDate.now();
System.out.println(today);
// 獲得2005年的第86天 (27-Mar-2005)
LocalDate localDate = LocalDate.ofYearDay(2005, 86);
System.out.println(localDate);
// 根據年月日取日期 2013年8月10日
localDate = LocalDate.of(2013, Month.AUGUST, 10);
localDate = LocalDate.of(2013, 8, 10);
// 根據字串取
LocalDate.parse("2014-02-28"); // 嚴格按照ISO yyyy-MM-dd驗證,02寫成2都不行
LocalDate.parse("2014-02-29"); // 無效日期無法通過:DateTimeParseException: Invalid date
// 取本月第1天:
LocalDate firstDayOfThisMonth = today.with(TemporalAdjusters.firstDayOfMonth());
// 取本月第2天:
LocalDate secondDayOfThisMonth = today.withDayOfMonth(2);
// 取本月最後一天,再也不用計算是28,29,30還是31:
LocalDate lastDayOfThisMonth = today.with(TemporalAdjusters.lastDayOfMonth());
// 取下一天:
LocalDate firstDayOf2015 = lastDayOfThisMonth.plusDays(1); // 變成了2015-01-01
// 取2015年1月第一個週一,這個計算用Calendar要死掉很多腦細胞:
LocalDate firstMondayOf2015 = LocalDate.parse("2015-01-01")
.with(TemporalAdjusters.firstInMonth(DayOfWeek.MONDAY));
// LocalTime
LocalTime now = LocalTime.now(); // 帶納秒
LocalTime now1 = LocalTime.now().withNano(0); // 清除納秒
System.out.println(now);
System.out.println(now1);
LocalTime localTime = LocalTime.of(22, 33);
System.out.println(localTime);
// 返回一天中的第4503秒
localTime = LocalTime.ofSecondOfDay(4503);
System.out.println(localTime);
LocalDateTime localDateTime0 = LocalDateTime.now();
System.out.println(localDateTime0);
// 當前時間加上25小時3分鐘
LocalDateTime inTheFuture = localDateTime0.plusHours(25).plusMinutes(3);
System.out.println(inTheFuture);
// 同樣也可以用在localTime和localDate中
System.out.println(localDateTime0.toLocalTime().plusHours(25).plusMinutes(3));
System.out.println(localDateTime0.toLocalDate().plusMonths(2));
複製程式碼
Streams與集合
Stream是對集合的包裝,通常和lambda一起使用。使用lambdas可以支援許多操作。如 map,filter,limit,sorted,count,min,max,sum,collect等等。 同樣,Stream使用懶運算,他們並不會真正地讀取所有資料。遇到像getFirst()這樣的方法就會結束鏈式語法,通過下面一系列例子介紹:比如我有個Person類,就是一個簡單的pojo, 針對這個物件,我們可能有這樣一系列的運算需求。
class Person{
private String name, job, gender;
private int salary, age;
// 省略若干get/set方法及構造方法
...
}
複製程式碼
- 先進行資料初始化,為後面運算演示提供基礎
List<Person> persons = new ArrayList<Person>() {
private static final long serialVersionUID = 1L;
{
add(new Person("張三", "Java", "female", 25, 1000));
add(new Person("李四", "Java", "male", 29, 1200));
add(new Person("王五", "測試", "female", 25, 1400));
add(new Person("趙六", "Java", "male", 31, 1800));
add(new Person("張三三", "設計", "male", 33, 1900));
add(new Person("李四四", "需求", "female", 30, 2000));
add(new Person("王五五", "Java", "female", 29, 2100));
add(new Person("趙六六", "Java", "male", 43, 2800));
}
};
複製程式碼
- 1.使用foreach輸出上述列表
Consumer<Person> print = e -> System.out.println(e.toString());
persons.forEach(print);
複製程式碼
- 2.將所有員工工資漲10%(使用foreach)
Consumer<Person> raise = e -> e.setSalary(e.getSalary()/100*10+e.getSalary());
persons.forEach(raise);
persons.forEach(print);
複製程式碼
- 3.顯示工資低於1500的員工(使用stream().filter())
persons.stream().filter((p) -> (p.getSalary()< 1500)).forEach(print);
複製程式碼
- 4.顯示工資大於2000 job=java 年齡>29 的女生
Predicate<Person> salaryPredicate = e -> e.getSalary() > 2000;
Predicate<Person> jobPredicate = e -> "Java".equals(e.getJob());
Predicate<Person> agePredicate = e -> e.getAge() >= 29;
Predicate<Person> genderPredicate = e -> "female".equals(e.getGender());
persons.stream().filter(salaryPredicate)
.filter(jobPredicate)
.filter(agePredicate)
.filter(genderPredicate)
.forEach(print);
複製程式碼
- 5.限制結果條數limit
persons.stream().filter(genderPredicate).limit(2).forEach(print);
複製程式碼
- 6.按照年齡排序
persons.stream().sorted((p1,p2)-> (p1.getAge() - p2.getAge()))
//.sorted((p1,p2)->(p1.getName().compareTo(p2.getName())))
.forEach(print);
複製程式碼
- 7.找出工資最高max(),年齡最小的min()
System.out.println(persons.stream().min((p1,p2)->(p1.getSalary()-p2.getSalary())).get().toString());
System.out.println(persons.stream().max((p1,p2)->(p1.getSalary()-p2.getSalary())).get().toString());
複製程式碼
- 8.計算所有人的工資parallel()並行的計算
System.out.println("所有人的工資總和:"+ persons.stream().parallel().mapToInt(p - > p.getSalary()).sum());
複製程式碼
- 9.將人員姓名存放到TreeSet\set\String中
String str = persons.stream().map(Person::getName).collect(Collectors.joining(";"));
System.out.println(str);
TreeSet<String> ts = persons.stream().map(Person::getName).collect(Collectors.toCollection(TreeSet::new));
System.out.println("ts.toString():"+ts.toString());
Set<String> set = persons.stream().map(Person::getName).collect(Collectors.toSet());
System.out.println("set.toString():"+set.toString());
複製程式碼
- 10.統計結果summaryStatistics()
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
IntSummaryStatistics stats = numbers
.stream()
.mapToInt((x) -> x)
.summaryStatistics();
System.out.println("List中最大的數字 : " + stats.getMax());
System.out.println("List中最小的數字 : " + stats.getMin());
System.out.println("所有數字的總和 : " + stats.getSum());
System.out.println("所有數字的平均值 : " + stats.getAverage());
複製程式碼
- 11.去除重複元素,建立新陣列
List<Integer> numbers1 = Arrays.asList(9, 10, 3, 4, 7, 3, 4);
List<Integer> distinct = numbers1.stream().distinct().collect(Collectors.toList());
System.out.println(distinct);
複製程式碼
傳遞行為,而不僅僅是傳值
//sumAll演算法很簡單,完成的是將List中所有元素相加。
public static int sumAll(List<Integer> numbers) {
int total = 0;
for (int number : numbers) {
total += number;
}
return total;
}
複製程式碼
sumAll演算法很簡單,完成的是將List中所有元素相加。某一天如果我們需要增加一個對List中所有偶數求和的方法sumAllEven,那麼就產生了sumAll2,如下:
public static int sumAll2(List<Integer> numbers) {
int total = 0;
for (int number : numbers) {
if (number % 2 == 0) {
total += number;
}
}
return total;
}
複製程式碼
又有一天,我們需要增加第三個方法:對List中所有大於3的元素求和,那是不是繼續加下面的方法呢?sumAll3
public static int sumAll3(List<Integer> numbers) {
int total = 0;
for (int number : numbers) {
if (number > 3) {
total += number;
}
}
return total;
}
複製程式碼
觀察這三個方法我們發現,有很多重複內容,唯一不同的是方法中的if條件不一樣(第一個可以看成if(true)),如果讓我們優化,可能想到的第一種重構就是策略模式吧,程式碼如下:
public interface Strategy {
public boolean test(int num);
}
public class SumAllStrategy implements Strategy {
@Override
public boolean test(int num) {
return true;
}
}
public class SumAllEvenStrategy implements Strategy {
@Override
public boolean test(int num) {
return num % 2 == 0;
}
}
public class SumAllGTThreeStrategy implements Strategy {
@Override
public boolean test(int num) {
return num > 3;
}
}
public class BodyClass {
private Strategy stragegy = null;
private final static Strategy DEFAULT_STRATEGY = new SumAllStrategy();
public BodyClass() {
this(null);
}
public BodyClass(Strategy arg) {
if (arg != null) {
this.stragegy = arg;
} else {
this.stragegy = DEFAULT_STRATEGY;
}
}
public int sumAll(List<Integer> numbers) {
int total = 0;
for (int number : numbers) {
if (stragegy.test(number)) {
total += number;
}
}
return total;
}
}
//呼叫
BodyClass bc = new BodyClass();
bc.sumAll(numbers);
複製程式碼
這無疑使用設計模式的方式優化了冗餘程式碼,但是可能要額外增加幾個類,以後擴充套件也要新增,下面看看使用lambda如何實現,宣告方法:第一個引數還是我們之前傳遞的List陣列,第二個看起來可能有點陌生,通過檢視jdk可以知道,這個類是一個謂詞(布林值的函式)
public static int sumAllByPredicate(List<Integer> numbers, Predicate<Integer> p) {
int total = 0;
for (int number : numbers) {
if (p.test(number)) {
total += number;
}
}
return total;
}
//呼叫:
sumAllByPredicate(numbers, n -> true);
sumAllByPredicate(numbers, n -> n % 2 == 0);
sumAllByPredicate(numbers, n -> n > 3);
複製程式碼
程式碼是不是比上面簡潔了很多?語義也很明確,重要的是不管以後怎麼變,都可以一行程式碼就修改了。。。萬金油啊。
其他
JAVA8 還推出了很多特性,來簡化程式碼。比如String.join函式、Objects類、Base64編碼類。
字串拼接
String joined = String.join("/", "usr","local","bin");
String joided1="usr/"+"local/"+"bin/";
System.out.println(joined);
String ids = String.join(", ", ZoneId.getAvailableZoneIds());
System.out.println(ids);
複製程式碼
Objects類
String aa = null;
Objects.requireNonNull(aa," aa must be not null");
Object a = null;
Object b = new Object();
if(a.equals(b)){
}
if(Objects.equals(a, b)){
}
複製程式碼
Base64編碼
Base64.Encoder encoder = Base64.getEncoder();
Base64.Decoder decoder = Base64.getDecoder();
String str = encoder.encodeToString("你好".getBytes(StandardCharsets.UTF_8));
System.out.println(str);
System.out.println(new String(decoder.decode(str),StandardCharsets.UTF_8));
複製程式碼
總結
好的程式碼需要不停的打磨,作為一個優秀的工程師,我們應該嚴格遵守,每次提交的程式碼要比遷出的時候更好。經常有人說,作為工程師一定要有團隊精神,但這種精神並不是說說而已的,需要實際的行動來體現的。設計模式、JDK的新特性都是我們可以藉助的經驗,編碼完成後思考一下,還可不可以在簡化、優化,不要成為一個“作惡”的工程師。
作者簡介
馬鐵利,隨行付架構部負責人 & TGO鯤鵬會北京分會會員,10年全棧工程師,擅長微服務分散式架構設計。主要負責隨行付架構部日常管理;參與構建微服務平臺周邊基礎設施及中介軟體;負責隨行付對外開源等事宜。