Thymeleafで独自実装したIterableでth:eachを回したらlastでNullPointerExceptionになる

# 概要
Thymeleafで独自実装したIterableでth:eachを回したらlastでNullPointerExceptionになりました。
公式ドキュメントを読んでも、検索してもそのようになるケースについては書かれていなかったので詳しく調べてみました。

# 結論
長くなるので先に結論を書いておきます。
th:each の last は、繰り返すオブジェクトが

- Collection
- Map
- 配列

の場合でしか使えません。

独自実装したIterableでは、th:eachの繰り返しステータスでlastやsizeを使えません。

# バージョンとソースコード
### 対象バージョン
Thymeleaf 3.0
（少なくとも 2016 時点からどう現象が発生するようです）

### コード
全コードは下記を参照してください。
https://github.com/wb773/thymeleaf-each-last-nullPointerException

主要なコードとして、

- 最小単位のName
- Nameのリストを管理するNames
- リストを作成し、Thymeleafに渡しているMainController
- そのリストを表示するindex.html

を掲載します。



``` java:Name.java
package com.example.demo.entity;

import lombok.AllArgsConstructor;
import lombok.Data;

/**
 * Name
 */
@Data
@AllArgsConstructor
public class Name {
    private String name;
}
```

``` java:Names.java
package com.example.demo.entity;

import java.util.Iterator;
import java.util.List;

/**
 * Names
 */
public class Names implements Iterable<Name> {

    private List<Name> list = null;

    public Names(List<Name> nameList){
        this.list = nameList;
    }

    @Override
    public Iterator<Name> iterator() {
        return new NameIterator(list);
    }
}

class NameIterator implements Iterator<Name> {

    private int currentIndex = 0;
    private List<Name> list;

    public NameIterator(List<Name> nameList){
        this.list = nameList;
    }

    @Override
    public boolean hasNext() {
        return currentIndex < list.size();
    }

    @Override
    public Name next() {
        return list.get(currentIndex++);
    }
}
```

``` java:MainController.java
package com.example.demo.controller;

import java.util.ArrayList;

import com.example.demo.entity.Name;
import com.example.demo.entity.Names;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/")
public class MainController {

    @GetMapping
    public String index(Model model) {
        
        ArrayList<Name> nameList = new ArrayList<Name>();
        nameList.add(new Name("AAA"));
        nameList.add(new Name("BBB"));
        nameList.add(new Name("CCC"));
        nameList.add(new Name("DDD"));
        nameList.add(new Name("EEE"));

        model.addAttribute("nameList", nameList);

        Names names = new Names(nameList);
        model.addAttribute("names", names);

        return "index";
    }
    
}
```

``` html:index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    index.html

    <hr>
    ArrayList
    <div th:each="name , idx: ${nameList}">
        <div>            
            <div th:text="${idx}"></div>
            <span th:text="${name.getName()}"></span>
            <span th:if="${idx.first}">【先頭】</span>
            <span th:if="${idx.last}">【最後】</span>
        </div>
    </div>

    <hr>
    iterable
    <div th:each="namesName , namesIdx: ${names}">
        <div th:text="${namesIdx}"></div>
        <div>
            <span th:text="${namesName.getName()}"></span>
            <span th:if="${namesIdx.first}">【先頭】</span>
            <!-- <span th:if="${namesIdx.last}">【最後】</span> -->
        </div>
    </div>
</body>
</html>
```

# 実行結果とエラー

実行結果は以下のようになります。
index.htmlでArrayListに入れた場合と、Iterableを独自実装したクラスを使用した場合で、オブジェクトの内容を比較しています。
（一旦エラーが起こる箇所はコメントアウトしています）

``` text:結果
index.html

ArrayList
{index = 0, count = 1, size = 5, current = Name(name=AAA)}
AAA 【先頭】
{index = 1, count = 2, size = 5, current = Name(name=BBB)}
BBB
{index = 2, count = 3, size = 5, current = Name(name=CCC)}
CCC
{index = 3, count = 4, size = 5, current = Name(name=DDD)}
DDD
{index = 4, count = 5, size = 5, current = Name(name=EEE)}
EEE 【最後】

iterable
{index = 0, count = 1, size = null, current = Name(name=AAA)}
AAA 【先頭】
{index = 1, count = 2, size = null, current = Name(name=BBB)}
BBB
{index = 2, count = 3, size = null, current = Name(name=CCC)}
CCC
{index = 3, count = 4, size = null, current = Name(name=DDD)}
DDD
{index = 4, count = 5, size = null, current = Name(name=EEE)}
EEE
```

結果を見ると、
独自実装した Iterable を使用した方では size が null になっています。
htmlのコメントアウトしている箇所

``` html:コメントアウトした箇所
<!-- <span th:if="${namesIdx.last}">【最後】</span> --> 
```

このコメントアウトを外すと、Whitelabel Error Page になります。
スタックトレースを見ると、一番最後のログから、 NullPointerException が発生していることがわかります。

``` text:エラー内容
Whitelabel Error Page
This application has no explicit mapping for /error, so you are seeing this as a fallback.

Sun Mar 01 22:20:08 JST 2020
There was an unexpected error (type=Internal Server Error, status=500).
An error happened during template parsing (template: "class path resource [templates/index.html]")
org.thymeleaf.exceptions.TemplateInputException: An error happened during template parsing (template: "class path resource [templates/index.html]")
	at org.thymeleaf.templateparser.markup.AbstractMarkupTemplateParser.parse(AbstractMarkupTemplateParser.java:241)
	at org.thymeleaf.templateparser.markup.AbstractMarkupTemplateParser.parseStandalone(AbstractMarkupTemplateParser.java:100)
	at org.thymeleaf.engine.TemplateManager.parseAndProcess(TemplateManager.java:666)
	at org.thymeleaf.TemplateEngine.process(TemplateEngine.java:1098)
	at org.thymeleaf.TemplateEngine.process(TemplateEngine.java:1072)
	at org.thymeleaf.spring5.view.ThymeleafView.renderFragment(ThymeleafView.java:362)
	at org.thymeleaf.spring5.view.ThymeleafView.render(ThymeleafView.java:189)
	at org.springframework.web.servlet.DispatcherServlet.render(DispatcherServlet.java:1373)
	at org.springframework.web.servlet.DispatcherServlet.processDispatchResult(DispatcherServlet.java:1118)
	at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1057)
	at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:943)
	at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)
	at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898)
	at javax.servlet.http.HttpServlet.service(HttpServlet.java:634)
	at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)
	at javax.servlet.http.HttpServlet.service(HttpServlet.java:741)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:231)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
	at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
	at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
	at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
	at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
	at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:202)
	at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:96)
	at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:541)
	at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:139)
	at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92)
	at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74)
	at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:343)
	at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:367)
	at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65)
	at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:868)
	at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1639)
	at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
	at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
	at java.lang.Thread.run(Thread.java:745)
Caused by: org.attoparser.ParseException: Exception evaluating SpringEL expression: "namesIdx.last" (template: "index" - line 29, col 19)
	at org.attoparser.MarkupParser.parseDocument(MarkupParser.java:393)
	at org.attoparser.MarkupParser.parse(MarkupParser.java:257)
	at org.thymeleaf.templateparser.markup.AbstractMarkupTemplateParser.parse(AbstractMarkupTemplateParser.java:230)
	... 48 more
Caused by: org.thymeleaf.exceptions.TemplateProcessingException: Exception evaluating SpringEL expression: "namesIdx.last" (template: "index" - line 29, col 19)
	at org.thymeleaf.spring5.expression.SPELVariableExpressionEvaluator.evaluate(SPELVariableExpressionEvaluator.java:290)
	at org.thymeleaf.standard.expression.VariableExpression.executeVariableExpression(VariableExpression.java:166)
	at org.thymeleaf.standard.expression.SimpleExpression.executeSimple(SimpleExpression.java:66)
	at org.thymeleaf.standard.expression.Expression.execute(Expression.java:109)
	at org.thymeleaf.standard.expression.Expression.execute(Expression.java:138)
	at org.thymeleaf.standard.expression.Expression.execute(Expression.java:125)
	at org.thymeleaf.standard.processor.StandardIfTagProcessor.isVisible(StandardIfTagProcessor.java:59)
	at org.thymeleaf.standard.processor.AbstractStandardConditionalVisibilityTagProcessor.doProcess(AbstractStandardConditionalVisibilityTagProcessor.java:61)
	at org.thymeleaf.processor.element.AbstractAttributeTagProcessor.doProcess(AbstractAttributeTagProcessor.java:74)
	at org.thymeleaf.processor.element.AbstractElementTagProcessor.process(AbstractElementTagProcessor.java:95)
	at org.thymeleaf.util.ProcessorConfigurationUtils$ElementTagProcessorWrapper.process(ProcessorConfigurationUtils.java:633)
	at org.thymeleaf.engine.ProcessorTemplateHandler.handleOpenElement(ProcessorTemplateHandler.java:1314)
	at org.thymeleaf.engine.OpenElementTag.beHandled(OpenElementTag.java:205)
	at org.thymeleaf.engine.Model.process(Model.java:282)
	at org.thymeleaf.engine.Model.process(Model.java:290)
	at org.thymeleaf.engine.IteratedGatheringModelProcessable.processIterationModel(IteratedGatheringModelProcessable.java:367)
	at org.thymeleaf.engine.IteratedGatheringModelProcessable.process(IteratedGatheringModelProcessable.java:221)
	at org.thymeleaf.engine.ProcessorTemplateHandler.handleCloseElement(ProcessorTemplateHandler.java:1640)
	at org.thymeleaf.engine.TemplateHandlerAdapterMarkupHandler.handleCloseElementEnd(TemplateHandlerAdapterMarkupHandler.java:388)
	at org.thymeleaf.templateparser.markup.InlinedOutputExpressionMarkupHandler$InlineMarkupAdapterPreProcessorHandler.handleCloseElementEnd(InlinedOutputExpressionMarkupHandler.java:322)
	at org.thymeleaf.standard.inline.OutputExpressionInlinePreProcessorHandler.handleCloseElementEnd(OutputExpressionInlinePreProcessorHandler.java:220)
	at org.thymeleaf.templateparser.markup.InlinedOutputExpressionMarkupHandler.handleCloseElementEnd(InlinedOutputExpressionMarkupHandler.java:164)
	at org.attoparser.HtmlElement.handleCloseElementEnd(HtmlElement.java:169)
	at org.attoparser.HtmlMarkupHandler.handleCloseElementEnd(HtmlMarkupHandler.java:412)
	at org.attoparser.MarkupEventProcessorHandler.handleCloseElementEnd(MarkupEventProcessorHandler.java:473)
	at org.attoparser.ParsingElementMarkupUtil.parseCloseElement(ParsingElementMarkupUtil.java:201)
	at org.attoparser.MarkupParser.parseBuffer(MarkupParser.java:725)
	at org.attoparser.MarkupParser.parseDocument(MarkupParser.java:301)
	... 50 more
Caused by: org.springframework.expression.spel.SpelEvaluationException: EL1021E: A problem occurred whilst attempting to access the property 'last': 'Unable to access property 'last' through getter method'
	at org.springframework.expression.spel.ast.PropertyOrFieldReference.readProperty(PropertyOrFieldReference.java:209)
	at org.springframework.expression.spel.ast.PropertyOrFieldReference.getValueInternal(PropertyOrFieldReference.java:104)
	at org.springframework.expression.spel.ast.PropertyOrFieldReference.access$000(PropertyOrFieldReference.java:51)
	at org.springframework.expression.spel.ast.PropertyOrFieldReference$AccessorLValue.getValue(PropertyOrFieldReference.java:406)
	at org.springframework.expression.spel.ast.CompoundExpression.getValueInternal(CompoundExpression.java:92)
	at org.springframework.expression.spel.ast.SpelNodeImpl.getValue(SpelNodeImpl.java:112)
	at org.springframework.expression.spel.standard.SpelExpression.getValue(SpelExpression.java:337)
	at org.thymeleaf.spring5.expression.SPELVariableExpressionEvaluator.evaluate(SPELVariableExpressionEvaluator.java:263)
	... 77 more
Caused by: org.springframework.expression.AccessException: Unable to access property 'last' through getter method
	at org.springframework.expression.spel.support.ReflectivePropertyAccessor$OptimalPropertyAccessor.read(ReflectivePropertyAccessor.java:704)
	at org.springframework.expression.spel.ast.PropertyOrFieldReference.readProperty(PropertyOrFieldReference.java:204)
	... 84 more
Caused by: java.lang.reflect.InvocationTargetException
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:483)
	at org.springframework.expression.spel.support.ReflectivePropertyAccessor$OptimalPropertyAccessor.read(ReflectivePropertyAccessor.java:700)
	... 85 more
Caused by: java.lang.NullPointerException
	at org.thymeleaf.engine.IterationStatusVar.isLast(IterationStatusVar.java:73)
	... 90 more
```

isLast というメソッドで NullPointerException が発生していることは読み取れるのですが、 refrection を使用しアクセスしているので、それ以上の情報がつかめません。

# 公式ドキュメントを確認
<img width="971" alt="スクリーンショット 2020-03-01 21.36.47.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18220/0879beef-7df0-a9b0-a0ef-99d2abf98f8d.png">

公式ドキュメントを見ると、
繰り返し処理が利用可能な値として、 java.util.Iterable が挙げられています。
実際繰り返し処理自体は出来ています。

繰り返しステータスの保持について確認してみるも、独自実装したIterableについての特記事項は何も書かれていませんでした。

# 原因
英語で検索してもこの件に対する有効な回答を見つけることは出来ず、一時調査を断念していたのですが、
別件でThymeleafについて調べているときに、ThymeleafのソースコードがGithub上で確認出来ることを知りました。

https://github.com/thymeleaf/thymeleaf

ソースコードを追っていくと、 IterationStatusVar というクラスがありました。
https://github.com/thymeleaf/thymeleaf/blob/3.0-master/src/main/java/org/thymeleaf/engine/IterationStatusVar.java
どうやらこれが th:each の繰り返しステータス（iteration status）の実態クラスのようです。
このコードを見ていくと、 isLast の実装を発見することが出来ました。

``` java:IterationStatusVar.java
public boolean isLast() {
    return (this.index == this.size - 1);
}
```

先程の、独自実装したIterableを使用した繰り返しステータスでは size が nullとなっていました。
確かにこのコードでは null - 1 となるため、NullPointerException が発生します。
なぜこのような実装になっているのでしょうか。
同ファイルを見ていると、 size についてコメントが書かれていました。

``` java:IterationStatusVar.java
Integer size; // it can be null if we don't know the size of the iterated object beforehand!
```
「我々が予め知っているオブジェクト以外の繰り返し要素は null となる」

**どういうことなのか。**

実際、この IterationStatusVar を生成しているクラス IteratedGatheringModelProcessable では、
https://github.com/thymeleaf/thymeleaf/blob/3.0-master/src/main/java/org/thymeleaf/engine/IteratedGatheringModelProcessable.java

``` java:IteratedGatheringModelProcessable.java
this.iterStatusVariable = new IterationStatusVar();
this.iterStatusVariable.index = 0;
this.iterStatusVariable.size = computeIteratedObjectSize(iteratedObject);
```
size は computeIteratedObjectSize というメソッド内で計算されているようです。
同クラス内、

``` java:computeIteratedObjectSizeメソッド
/*
* Whenever possible, compute the total size of the iterated object. Note sometimes we will not be able
* to compute this size without traversing the entire collection/iterator (which we want to avoid), so
* null will be returned.
*/
private static Integer computeIteratedObjectSize(final Object iteratedObject) {
    if (iteratedObject == null) {
        return Integer.valueOf(0);
    }
    if (iteratedObject instanceof Collection<?>) {
        return Integer.valueOf(((Collection<?>) iteratedObject).size());
    }
    if (iteratedObject instanceof Map<?,?>) {
        return Integer.valueOf(((Map<?, ?>) iteratedObject).size());
    }
    if (iteratedObject.getClass().isArray()) {
        return Integer.valueOf(Array.getLength(iteratedObject));
    }
    if (iteratedObject instanceof Iterable<?>) {
        return null; // Cannot determine before actually iterating
    }
    if (iteratedObject instanceof Iterator<?>) {
        return null; // Cannot determine before actually iterating
    }
    return Integer.valueOf(1); // In this case, we will iterate the object as a collection of size 1
}
```
「我々は、時々 collection/iterator について、サイズを計算出来ないことがあるので、nullを返すようにします」

なるほど。

# 結論

- instanceof Collection<?>
- instanceof Map<?,?>
- getClass().isArray()

の時は有効な size が返され、last も判定出来る。

Iterable、Iterator の時は、null が返り、last を判定しようとすると NullPointerException となる。

独自実装したIterableではth:eachの繰り返しステータスでlastやsizeを使ってはいけない、ということですね。
困る。