我刚读完“ JDK 8收集器的强大功能的一种例外” ,我不得不说我很失望。 Java冠军 Simon Ritter是Oracle的前Java推广者,现在是Oracle的Java传播者,现在是Azul Systems的副CTO(使用JVM的人 )写了它,因此我希望对流有一些有趣的见解。 相反,帖子归结为:
- 使用流减少行数
- 你可以和收藏家一起做花哨的东西
- 流中的异常很烂
这不仅是肤浅的,而且文章还采用了一些不合标准的开发实践。 现在,西蒙(Simon)写道,这只是一个小型演示项目,所以我想他并没有将所有的专业知识投入其中。 尽管如此,它还是很草率的,而且,更糟的是,那里的许多人犯了同样的错误并重复了相同的比喻。
看到它们被引用在许多不同的地方(即使各自的作者在按下时可能无法捍卫这些观点),也肯定不会帮助开发人员对如何使用流获得良好的印象。 因此,我决定借此机会写一篇反驳的文章-不仅是针对这篇文章,而且是对所有重复的文章的反驳。
(总是指出我的观点是多余的(毕竟这是我的博客)并且很累人,所以我不会这样做。但是请记住这一点,因为我说的某些东西即使它们是事实也是如此。仅是我的观点。)
问题
关于发生了什么事情以及为什么发生的原因有很多解释,但最终归结为:我们有一个来自HTTP POST请求的查询字符串,并且想要将参数解析为更方便的数据结构。 例如,给定字符串a = foo&b = bar&a = fu,我们希望得到类似a〜> {foo,fu} b〜> {bar}的名称。
我们也有一些在网上找到的已经执行此操作的代码:
private void parseQuery(String query, Map parameters)
throws UnsupportedEncodingException {
if (query != null) {
String pairs[] = query.split("[&]");
for (String pair : pairs) {
String param[] = pair.split("[=]");
String key = null;
String value = null;
if (param.length > 0) {
key = URLDecoder.decode(param[0],
System.getProperty("file.encoding"));
}
if (param.length > 1) {
value = URLDecoder.decode(param[1],
System.getProperty("file.encoding"));
}
if (parameters.containsKey(key)) {
Object obj = parameters.get(key);
if(obj instanceof List) {
List values = (List)obj;
values.add(value);
} else if(obj instanceof String) {
List values = new ArrayList();
values.add((String)obj);
values.add(value);
parameters.put(key, values);
}
} else {
parameters.put(key, value);
}
}
}
}
我认为没有提及作者的名字是一种好意,因为此代码段在很多层面上都是错误的,因此我们甚至都不会讨论。
我的牛肉
从这里开始,本文将说明如何向流重构。 这就是我开始不同意的地方。
简洁流
这就是重构的动机:
看完这个之后,我认为我可以[...]使用流来使其更加简洁。
当人们把它作为使用流的第一个动机时,我讨厌它! 认真地说,我们是Java开发人员,如果可以提高可读性,我们习惯于编写一些额外的代码。
信息流与简洁无关
因此,信息流与简洁无关。 相反,我们习惯于循环,因此我们经常将大量操作塞入循环的主体行中。 当向流重构时,我经常将操作分开,从而导致更多的行。
相反,流的神奇之处在于它们如何支持思维模式匹配。 因为他们只使用了少数几个概念(主要是map / flatMap,filter,reduce / collect / find),所以我可以快速了解正在发生的事情并集中精力进行操作,最好是一个接一个地进行。
for (Customer customer : customers) {
if (customer.getAccount().isOverdrawn()) {
WarningMail mail = WarningMail.createFor(customer.getAccount());
// do something with mail
}
}
customers.stream()
.map(Customer::getAccount)
.filter(Account::isOverdrawn)
.map(WarningMail::createFor)
.forEach(/* do something with mail */ );
在代码中,遵循通用的“客户映射到帐户,过滤透支的帐户映射到警告邮件”,然后费时费力地“为从客户那里获得的帐户创建警告邮件,但前提是透支,才容易得多”。
但是,为什么这是抱怨的理由? 每个人都有自己的喜好,对吗? 是的,但是专注于简洁性会导致错误的设计决策。
例如,我经常决定通过为其创建一个方法并使用一个方法引用来总结一个或多个操作(如连续映射)。 这样可以带来不同的好处,例如将流管道中的所有操作都保持在相同的抽象级别上,或者简单地命名本来就很难理解的操作(您知道,意图显示名称和内容)。 如果我专注于简洁性,我可能不会这样做。
减少代码行数也可能导致将多个操作组合到一个lambda中,从而节省了两个映射或过滤器。 再次,这打败了背后的目的!
因此,当您看到一些代码并考虑将其重构为流时,不要数行来确定您的成功!
使用丑陋的力学
循环要做的第一件事也是启动流的方法:我们将查询字符串与&符分开,然后对结果键值对进行操作。 该文章如下
Arrays.stream(query.split("[&]"))
看起来不错? 老实说,没有。 我知道,这是创建流的最佳方式,但只是因为我们必须这样做 ,这样并不意味着我们来看看它。 而且我们在这里所做的(沿着正则表达式分割字符串)似乎也很普通。 那么为什么不将其推入实用程序功能呢?
public static Stream<String> splitIntoStream(String s, String regex) {
return Arrays.stream(s.split(regex));
}
然后,我们使用splitIntoStream(query,“ [&]”)启动流。 一种简单的“提取方法”重构,但效果更好。
次优数据结构
还记得我们想做什么? 将类似a = foo&b = bar&a = fu的内容解析为a〜> {foo,fu} b〜> {bar}。 现在,我们怎么可能代表结果呢? 看起来我们正在将单个字符串映射到许多字符串,所以也许我们应该尝试Map <String,List <String >>?
那绝对是个不错的初衷……但这绝不是我们能做的最好的! 首先,为什么要列出清单? 订单真的很重要吗? 我们需要重复的值吗? 我猜这两项都不对,所以也许我们应该尝试一套?
无论如何,如果您曾经创建过一个以值为集合的地图,那么您就会知道这有些不愉快。 总是存在这样的极端情况:“这是第一个要素吗?” 考虑。 尽管Java 8减轻了麻烦……
public void addPair(String key, String value) {
// `map` is a `Map<String, Set<String>>`
map.computeIfAbsent(key, k -> new HashSet<>())
.add(value);
}
…从API的角度来看,还远远不够完美。 例如,迭代或流式传输所有值是一个两步过程:
private <T> Stream<T> streamValues() {
// `map` could be a `Map<?, Collection<T>>`
return map
.values().stream()
.flatMap(Collection::stream);
}
!
长话短说,我们正在将需要的东西(从键到多个值的映射)变成我们想到的第一件事(从键到单个值的映射)。 那不是一个好的设计!
尤其是因为我们的需求非常匹配: Guava的Multimap 。 也许有充分的理由不使用它,但在这种情况下,至少应该提及它。 毕竟,本文的目的是找到一种处理和表示输入的好方法,因此它应该在为输出选择数据结构方面做得很好。
(虽然一般来说,这是一个反复出现的主题,但它并不是非常特定于流的。我没有将其归类为5个常见的对立部分,但仍然想提及它,因为这样可以使最终结果更好。)
康妮插图
说到常见的比喻...一种是使用溪流的老照片为帖子添加一些颜色。 有了这个,我很乐意承担!
贫血管道
您是否曾经看到几乎什么都不做但突然将所有功能塞入单个操作的管道? 这篇文章对我们的小解析问题的解决方案是一个完美的例子(我删除了一些空处理以提高可读性):
private Map<String, List<String>> parseQuery(String query) {
return Arrays.stream(query.split("[&]"))
.collect(groupingBy(s -> (s.split("[=]"))[0],
mapping(s -> (s.split("[=]"))[1], toList())));
}
这是我在阅读本文时的思考过程:“好吧,所以我们用&符分隔查询字符串,然后,耶稣在他妈的棍子上,那是什么?!” 然后我冷静下来,意识到这里隐藏着一个抽象-通常不追求它,而让我们大胆地做到这一点。
在这种情况下,我们将请求参数a = foo拆分为[a,foo],然后分别处理这两个部分。 因此,在流中包含该对的流水线中不应该走一步吗?
但这是一种罕见的情况。 流的元素通常是某种类型,我想用其他信息来丰富它。 也许我有大量的客户,并希望将其与他们居住的城市配对。请注意,我不想用城市代替客户-这是一个简单的地图-但需要同时使用这两个功能,例如将城市映射到居住的客户在其中。
正确表示中间结果是可读性的福音。
两种情况有什么共同点? 他们需要代表一对。 他们为什么不呢? 因为Java没有惯用的方法。 当然,您可以使用数组(适用于我们的请求参数), Map.Entry ,某些库的元组类甚至特定于域的东西。 但很少有人做,这使得代码做到的是通过一个有点令人惊讶脱颖而出。
不过,我还是喜欢这种方式。 正确表示中间结果是可读性的福音。 使用Entry看起来像这样:
private Map<String, List<String>> parseQuery(String query) {
return splitIntoStream(query, "[&]")
.map(this::parseParameter)
.collect(groupingBy(Entry::getKey,
mapping(Entry::getValue, toList())));
}
private Entry<String, String> parseParameter(String parameterString) {
String[] split = parameterString.split("[=]");
// add all kinds of verifications here
return new SimpleImmutableEntry<>(split[0], split[1]);
}
我们仍然有魔术收藏家要处理,但那里发生的事情最少。
收藏魔术
Java 8附带了一些疯狂的收集器 (尤其是那些转发给下游收集器的收集器),我们已经看到如何滥用它们来创建不可读的代码。 如我所见,它们之所以存在是因为没有元组,就没有办法准备复杂的约简。 所以这是我的工作:
- 我尝试通过正确准备流的元素来使收集器尽可能简单(如有必要,我为此使用元组或特定于域的数据类型)。
- 如果仍然需要做一些复杂的事情,可以将其放入实用程序方法中。
吃我自己的狗粮,这怎么办?
private Map<String, List<String>> parseQuery(String query) {
return splitIntoStream(query, "[&]")
.map(this::parseParameter)
.collect(toListMap(Entry::getKey, Entry::getValue));
}
/** Beautiful JavaDoc comment explaining what the collector does. */
public static <T, K, V> Collector<T, ?, Map<K, List<V>>> toListMap(
Function<T, K> keyMapper, Function<T, V> valueMapper) {
return groupingBy(keyMapper, mapping(valueMapper, toList()));
}
它仍然很丑陋-尽管不是那么可怕-但至少我不必一直都在看它。 如果我愿意,返回类型和合同注释将使您更容易了解发生的情况。
或者,如果我们决定使用Multimap,我们会四处寻找匹配的收集器 :
private Multimap<String, String> parseQuery(String query) {
return splitIntoStream(query, "[&]")
.map(this::parseParameter)
.collect(toMultimap(Entry::getKey, Entry::getValue));
}
在这两种情况下,我们甚至可以更进一步,对条目流进行特殊处理。 我将其留给您练习。 :)
异常处理
本文在处理流时面临的最大挑战是异常处理。 它说:
不幸的是,如果您回头查看原始代码,将会发现我方便地省略了一个步骤:使用URLDecoder将参数字符串转换为其原始格式。
问题是URLDecoder :: decode会引发检查的UnsupportedEncodingException,因此无法将其简单地添加到代码中。 那么本文采用哪种方法解决这一相关问题? 鸵鸟之一 :
最后,我决定保留我的第一个超薄方法。 由于在这种情况下我的Web前端未进行任何编码,因此我的代码仍然可以正常工作。
嗯...文章标题没有提到例外吗? 因此,不应该为此多花点时间吗?
无论如何,错误处理总是很困难,流增加了一些约束和复杂性。 讨论不同的方法需要花费时间,而且具有讽刺意味的是,我并不热衷于将其压缩到帖子的最后部分。 因此,让我们详细讨论如何使用运行时异常,欺骗或monad来解决该问题,而不是考虑最简单的解决方案。
一个操作要做的最简单的事情就是筛选出引起麻烦的元素。 因此,该操作不是将每个元素映射到一个新元素,而是将一个元素映射到零或一个元素。 在我们的情况下:
private static Stream<Entry<String, String>> parseParameter(
String parameterString) {
try {
return Stream.of(parseValidParameter(parameterString));
} catch (IllegalArgumentException | UnsupportedEncodingException ex) {
// we should probably log the exception here
return Stream.empty();
}
}
private static Entry<String, String> parseValidParameter(
String parameterString)
throws UnsupportedEncodingException {
String[] split = parameterString.split("[=]");
if (split.length != 2) {
throw new IllegalArgumentException(/* explain what's going on */);
}
return new SimpleImmutableEntry<>(
URLDecoder.decode(split[0], ENCODING),
URLDecoder.decode(split[1], ENCODING));
}
然后,我们在flatMap而不是地图中使用parseParameter,并获取可以拆分和解码的条目流(以及一堆日志消息,告诉我们在什么情况下出现问题)。
摊牌
这是文章的最终版本:
private Map<String, List> parseQuery(String query) {
return (query == null) ? null : Arrays.stream(query.split("[&]"))
.collect(groupingBy(s -> (s.split("[=]"))[0],
mapping(s -> (s.split("[=]"))[1], toList())));
}
摘要说:
由此得出的结论是,使用流和收集器的灵活性,可以大大减少复杂处理所需的代码量。 缺点是,当这些令人讨厌的异常抬起头来时,这种方法就不能很好地工作了。
这是我的:
private Multimap<String, String> parseQuery(String query) {
if (query == null)
return ArrayListMultimap.create();
return splitIntoStream(query, "[&]")
.flatMap(this::parseParameter)
.collect(toMultimap(Entry::getKey, Entry::getValue));
}
// plus `parseParameter` and `parseValidParameter` as above
// plus the reusable methods `splitIntoStream` and `toMultimap
行更多,是的,但是流管道具有更少的技术组合,通过URL解码参数来设置完整的功能集,可接受(或至少存在)异常处理,适当的中间结果,明智的收集器,以及良好的性能结果类型。 它带有两个通用实用程序功能,可以帮助其他开发人员改善其开发流程。 我认为多余的几行值得所有。
因此,我的收获有所不同:使用流以简单可预测的方式使用流的构建块来使代码揭示其意图。 抓住机会寻找可重用的操作(尤其是那些创建或收集流的操作),不要害羞地调用小方法以保持管道可读。 最后但并非最不重要的一点:忽略行数。
圣经后
顺便说一下,借助Java 9对流API的增强 ,我们不必对空查询字符串进行特殊情况处理:
private Multimap<String, String> parseQuery(String query) {
return Stream.ofNullable(query)
.flatMap(q -> splitIntoStream(q, "[&]"))
.flatMap(this::parseParameter)
.collect(toMultimap(Entry::getKey, Entry::getValue));
}
等不及了!
翻译自: https://www.javacodegeeks.com/2016/09/rebutting-5-common-stream-tropes.html