You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@freemarker.apache.org by dd...@apache.org on 2017/06/01 22:13:30 UTC

[5/5] incubator-freemarker git commit: Cleaned up customAttribute related API-s. Most notably, a new getCustomAttribute overload was added, `getCustomAttribute(Serializable key, Object default)`, where the default value is used if the attribute wasn't se

Cleaned up customAttribute related API-s. Most notably, a new getCustomAttribute overload was added, `getCustomAttribute(Serializable key, Object default)`, where the default value is used if the attribute wasn't set (not even to null). (There's a special value, `ProcessingConfiguration.MISSING_VALUE_MARKER`, which can be used as default but not as the value of a custom attribute.) `getCustomAttribute(Serializable key)` now throws `MissingCustomAttributeValue` exception if the attribute isn't set. Also, `getCustomAttributesSnapshot(includeInherited)` was added, which is mostly useful for debugging purposes. Also note that the


Project: http://git-wip-us.apache.org/repos/asf/incubator-freemarker/repo
Commit: http://git-wip-us.apache.org/repos/asf/incubator-freemarker/commit/dc689993
Tree: http://git-wip-us.apache.org/repos/asf/incubator-freemarker/tree/dc689993
Diff: http://git-wip-us.apache.org/repos/asf/incubator-freemarker/diff/dc689993

Branch: refs/heads/3
Commit: dc689993a6407a0307b4710c4a06272d45488a5f
Parents: eae2708
Author: ddekany <dd...@apache.org>
Authored: Fri Jun 2 00:13:10 2017 +0200
Committer: ddekany <dd...@apache.org>
Committed: Fri Jun 2 00:13:10 2017 +0200

----------------------------------------------------------------------
 FM3-CHANGE-LOG.txt                              |   6 +-
 .../freemarker/core/CustomAttributeTest.java    | 240 ++++++++++++++-----
 .../core/TemplateConfigurationTest.java         |  37 ++-
 ...gurationWithDefaultTemplateResolverTest.java |  10 +-
 .../TemplateConfigurationFactoryTest.java       |  11 +-
 .../core/util/CollectionUtilTest.java           |  43 ++++
 .../apache/freemarker/core/Configuration.java   | 182 +++++++++-----
 .../core/CustomAttributeNotSetException.java    |  48 ++++
 .../org/apache/freemarker/core/Environment.java |  16 +-
 .../core/MutableProcessingConfiguration.java    | 221 ++++++++++-------
 .../core/ProcessingConfiguration.java           | 101 ++++++--
 .../core/SettingValueNotSetException.java       |  12 +-
 .../org/apache/freemarker/core/Template.java    | 167 +++++++++----
 .../freemarker/core/TemplateConfiguration.java  |  88 ++++---
 .../freemarker/core/util/_CollectionUtil.java   |  23 ++
 freemarker-core/src/main/javacc/FTL.jj          |   2 +-
 .../freemarker/servlet/FreemarkerServlet.java   |   2 +-
 17 files changed, 837 insertions(+), 372 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/dc689993/FM3-CHANGE-LOG.txt
----------------------------------------------------------------------
diff --git a/FM3-CHANGE-LOG.txt b/FM3-CHANGE-LOG.txt
index f0823ab..1ca0250 100644
--- a/FM3-CHANGE-LOG.txt
+++ b/FM3-CHANGE-LOG.txt
@@ -179,7 +179,11 @@ the FreeMarer 3 changelog here:
     is somewhat similar to the now removed CustomAttribute. CustomStateScope contains one method, Object getCustomState(CustomStateKey), which
     may calls CustomStateKey.create() to lazily create the state object for the key. Configuration, Template and Environment implements
     CustomStateScope.
-  - Added getter/setter to access custom attributes as a Map. (This is to make it less an exceptional setting.)
+  - Cleaned up customAttribute related API-s. Most notably, a new getCustomAttribute overload was added, `getCustomAttribute(Serializable key, Object default)`,
+    where the default value is used if the attribute wasn't set (not even to null). (There's a reserved ProcessingConfiguration.MISSING_VALUE_MARKER that
+    can be used as default but not as the value of a custom attribute). `getCustomAttribute(Serializable key)` now throws MissingCustomAttributeValue exception
+    if the attribute isn't set. Also, getCustomAttributesSnapshot(includeInherited) was added, which is mostly useful for debugging purposes. Also note that the
+    key of custom attributes now must be Serializable.
   - Environment.setCustomState(Object, Object) and getCustomState(Object) was replaced with CustomStateScope.getCustomState(CustomStateKey).
   - Added ProcessingConfiguration interface for the read-only access of template processing settings. This is similar to the
     already existing (in FM2) ParserConfiguration interface.

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/dc689993/freemarker-core-test/src/test/java/org/apache/freemarker/core/CustomAttributeTest.java
----------------------------------------------------------------------
diff --git a/freemarker-core-test/src/test/java/org/apache/freemarker/core/CustomAttributeTest.java b/freemarker-core-test/src/test/java/org/apache/freemarker/core/CustomAttributeTest.java
index 726a20c..d714380 100644
--- a/freemarker-core-test/src/test/java/org/apache/freemarker/core/CustomAttributeTest.java
+++ b/freemarker-core-test/src/test/java/org/apache/freemarker/core/CustomAttributeTest.java
@@ -19,10 +19,14 @@
 
 package org.apache.freemarker.core;
 
+import static org.apache.freemarker.core.ProcessingConfiguration.MISSING_VALUE_MARKER;
+import static org.hamcrest.Matchers.containsString;
 import static org.junit.Assert.*;
 
+import java.io.Serializable;
 import java.math.BigDecimal;
-import java.util.Arrays;
+import java.util.Collections;
+import java.util.Map;
 
 import org.junit.Test;
 
@@ -42,82 +46,121 @@ public class CustomAttributeTest {
     private static final Object VALUE_2 = new Object();
     private static final Object VALUE_3 = new Object();
     private static final Object VALUE_4 = new Object();
-    private static final Object VALUE_LIST = ImmutableList.<Object>of(
-            "s", BigDecimal.valueOf(2), Boolean.TRUE, ImmutableMap.of("a", "A"));
-    private static final Object VALUE_BIGDECIMAL = BigDecimal.valueOf(22);
-
-    private static final Object CUST_ATT_KEY = new Object();
 
     @Test
-    public void testStringKey() throws Exception {
-        // Need some MutableProcessingConfiguration:
-        TemplateConfiguration.Builder mpc = new TemplateConfiguration.Builder();
+    public void testMutableProcessingConfiguration() throws Exception {
+        testMutableProcessingConfiguration(new Configuration.Builder(Configuration.VERSION_3_0_0));
+
+        testMutableProcessingConfiguration(new TemplateConfiguration.Builder());
+
+        Configuration cfg = new Configuration.Builder(Configuration.VERSION_3_0_0).build();
+        Environment env = new Template(null, "", cfg).createProcessingEnvironment(null, null);
+        testMutableProcessingConfiguration(env);
+    }
+
+    private void testMutableProcessingConfiguration(MutableProcessingConfiguration<?> mpc) {
+        assertTrue(mpc.getCustomAttributesSnapshot(true).isEmpty());
+        assertTrue(mpc.getCustomAttributesSnapshot(false).isEmpty());
+        testMissingCustomAttributeAccess(mpc, KEY_1);
 
-        assertEquals(0, mpc.getCustomAttributeNames().length);
-        assertNull(mpc.getCustomAttribute(KEY_1));
-        
         mpc.setCustomAttribute(KEY_1, VALUE_1);
-        assertArrayEquals(new String[] { KEY_1 }, mpc.getCustomAttributeNames());
-        assertSame(VALUE_1, mpc.getCustomAttribute(KEY_1));
-        
         mpc.setCustomAttribute(KEY_2, VALUE_2);
-        assertArrayEquals(new String[] { KEY_1, KEY_2 }, sort(mpc.getCustomAttributeNames()));
+
         assertSame(VALUE_1, mpc.getCustomAttribute(KEY_1));
         assertSame(VALUE_2, mpc.getCustomAttribute(KEY_2));
+        testMissingCustomAttributeAccess(mpc, KEY_3);
+        assertEquals(ImmutableMap.of(KEY_1, VALUE_1, KEY_2, VALUE_2), mpc.getCustomAttributesSnapshot(true));
+        assertEquals(ImmutableMap.of(KEY_1, VALUE_1, KEY_2, VALUE_2), mpc.getCustomAttributesSnapshot(false));
 
-        mpc.setCustomAttribute(KEY_1, VALUE_2);
-        assertArrayEquals(new String[] { KEY_1, KEY_2 }, sort(mpc.getCustomAttributeNames()));
-        assertSame(VALUE_2, mpc.getCustomAttribute(KEY_1));
-        assertSame(VALUE_2, mpc.getCustomAttribute(KEY_2));
+        assertSame(VALUE_2, mpc.getCustomAttribute(KEY_2, "default"));
+        mpc.unsetCustomAttribute(KEY_2);
+        assertEquals("default", mpc.getCustomAttribute(KEY_2, "default"));
+        assertEquals(ImmutableMap.of(KEY_1, VALUE_1), mpc.getCustomAttributesSnapshot(true));
+        assertEquals(ImmutableMap.of(KEY_1, VALUE_1), mpc.getCustomAttributesSnapshot(false));
 
-        mpc.setCustomAttribute(KEY_1, null);
-        assertArrayEquals(new String[] { KEY_1, KEY_2 }, sort(mpc.getCustomAttributeNames()));
-        assertNull(mpc.getCustomAttribute(KEY_1));
-        assertSame(VALUE_2, mpc.getCustomAttribute(KEY_2));
+        mpc.unsetAllCustomAttributes();
+        assertTrue(mpc.getCustomAttributesSnapshot(true).isEmpty());
 
-        mpc.removeCustomAttribute(KEY_1);
-        assertArrayEquals(new String[] { KEY_2 }, mpc.getCustomAttributeNames());
-        assertNull(mpc.getCustomAttribute(KEY_1));
-        assertSame(VALUE_2, mpc.getCustomAttribute(KEY_2));
+        testCustomAttributesSnapshotIsUnmodifiable(mpc);
+        mpc.setCustomAttribute(KEY_1, VALUE_1);
+        testCustomAttributesSnapshotIsUnmodifiable(mpc);
+
+        // Test no aliasing
+        Map<Serializable, Object> attrMap1 = mpc.getCustomAttributesSnapshot(false);
+        mpc.setCustomAttribute(KEY_2, VALUE_2);
+        assertNull(attrMap1.get(KEY_2));
+
+        mpc.unsetAllCustomAttributes();
+        mpc.setCustomAttribute(KEY_1, VALUE_1);
+        mpc.setCustomAttributes(ImmutableMap.of(KEY_2, VALUE_2, KEY_3, VALUE_3));
+        assertEquals(
+                ImmutableMap.of(KEY_1, VALUE_1, KEY_2, VALUE_2, KEY_3, VALUE_3),
+                mpc.getCustomAttributesSnapshot(false));
+
+        try {
+            mpc.setCustomAttribute(KEY_1, MISSING_VALUE_MARKER);
+            fail();
+        } catch (IllegalArgumentException e) {
+            assertThat(e.getMessage(), containsString("MISSING_VALUE_MARKER"));
+        }
+        try {
+            mpc.setCustomAttributes(ImmutableMap.of(KEY_1, MISSING_VALUE_MARKER));
+            fail();
+        } catch (IllegalArgumentException e) {
+            assertThat(e.getMessage(), containsString("MISSING_VALUE_MARKER"));
+        }
     }
 
-    @Test
-    public void testRemoveFromEmptySet() throws Exception {
-        // Need some MutableProcessingConfiguration:
-        TemplateConfiguration.Builder mpc = new TemplateConfiguration.Builder();
+    private void testMissingCustomAttributeAccess(ProcessingConfiguration pc) {
+        testMissingCustomAttributeAccess(pc, "noSuchKey");
+    }
+
+    private void testCustomAttributesSnapshotIsUnmodifiable(ProcessingConfiguration pc) {
+        for (boolean includeInherited : new boolean[] { false,  true }) {
+            Map<Serializable, Object> map = pc.getCustomAttributesSnapshot(includeInherited);
+            try {
+                map.put("aNewKey", 123);
+                fail();
+            } catch (UnsupportedOperationException e) {
+                // Expected
+            }
+        }
+    }
 
-        mpc.removeCustomAttribute(KEY_1);
-        assertEquals(0, mpc.getCustomAttributeNames().length);
-        assertNull(mpc.getCustomAttribute(KEY_1));
+    private void testMissingCustomAttributeAccess(ProcessingConfiguration pc, Serializable key) {
+        try {
+            pc.getCustomAttribute(key);
+            fail();
+        } catch (CustomAttributeNotSetException e) {
+            assertSame(key, e.getKey());
+        }
 
-        mpc.setCustomAttribute(KEY_1, VALUE_1);
-        assertArrayEquals(new String[] { KEY_1 }, mpc.getCustomAttributeNames());
-        assertSame(VALUE_1, mpc.getCustomAttribute(KEY_1));
+        assertNull(pc.getCustomAttribute(key, null));
+        assertEquals("default", pc.getCustomAttribute(key, "default"));
+        assertSame(MISSING_VALUE_MARKER,
+                pc.getCustomAttribute(key, MISSING_VALUE_MARKER));
     }
 
     @Test
-    public void testAttrsFromFtlHeaderOnly() throws Exception {
+    public void testTemplateAttrsFromFtlHeaderOnly() throws Exception {
         Template t = new Template(null, "<#ftl attributes={"
                 + "'" + KEY_1 + "': [ 's', 2, true, {  'a': 'A' } ], "
-                + "'" + KEY_2 + "': " + VALUE_BIGDECIMAL + " "
+                + "'" + KEY_2 + "': 22 "
                 + "}>",
                 new Configuration.Builder(Configuration.VERSION_3_0_0).build());
 
-        assertEquals(ImmutableSet.of(KEY_1, KEY_2), t.getCustomAttributes().keySet());
-        assertEquals(VALUE_LIST, t.getCustomAttribute(KEY_1));
-        assertEquals(VALUE_BIGDECIMAL, t.getCustomAttribute(KEY_2));
+        assertEquals(ImmutableSet.of(KEY_1, KEY_2), t.getCustomAttributesSnapshot(true).keySet());
+        assertEquals(
+                ImmutableList.<Object>of("s", BigDecimal.valueOf(2), Boolean.TRUE, ImmutableMap.of("a", "A")),
+                t.getCustomAttribute(KEY_1));
+        assertEquals(BigDecimal.valueOf(22), t.getCustomAttribute(KEY_2));
 
-        t.setCustomAttribute(KEY_1, VALUE_1);
-        assertEquals(VALUE_1, t.getCustomAttribute(KEY_1));
-        assertEquals(VALUE_BIGDECIMAL, t.getCustomAttribute(KEY_2));
-
-        t.setCustomAttribute(KEY_1, null);
-        assertEquals(ImmutableSet.of(KEY_1, KEY_2), t.getCustomAttributes().keySet());
-        assertNull(t.getCustomAttribute(KEY_1));
+        testMissingCustomAttributeAccess(t);
+        testCustomAttributesSnapshotIsUnmodifiable(t);
     }
 
     @Test
-    public void testAttrsFromFtlHeaderAndFromTemplateConfiguration() throws Exception {
+    public void testTemplateAttrsFromFtlHeaderAndFromTemplateConfiguration() throws Exception {
         TemplateConfiguration.Builder tcb = new TemplateConfiguration.Builder();
         tcb.setCustomAttribute(KEY_3, VALUE_3);
         tcb.setCustomAttribute(KEY_4, VALUE_4);
@@ -129,20 +172,19 @@ public class CustomAttributeTest {
                 new Configuration.Builder(Configuration.VERSION_3_0_0).build(),
                 tcb.build());
 
-        assertEquals(ImmutableSet.of(KEY_1, KEY_2, KEY_3, KEY_4), t.getCustomAttributes().keySet());
+        assertEquals(ImmutableMap.of(KEY_1, "a", KEY_2, "b", KEY_3, "c", KEY_4, VALUE_4),
+                t.getCustomAttributesSnapshot(true));
         assertEquals("a", t.getCustomAttribute(KEY_1));
         assertEquals("b", t.getCustomAttribute(KEY_2));
         assertEquals("c", t.getCustomAttribute(KEY_3)); // Has overridden TC attribute
         assertEquals(VALUE_4, t.getCustomAttribute(KEY_4)); // Inherited TC attribute
 
-        t.setCustomAttribute(KEY_3, null);
-        assertEquals(ImmutableSet.of(KEY_1, KEY_2, KEY_3, KEY_4), t.getCustomAttributes().keySet());
-        assertNull("null value shouldn't cause fallback to TC attribute", t.getCustomAttribute(KEY_3));
+        testMissingCustomAttributeAccess(t);
+        testCustomAttributesSnapshotIsUnmodifiable(t);
     }
 
-
     @Test
-    public void testAttrsFromTemplateConfigurationOnly() throws Exception {
+    public void testTemplateAttrsFromTemplateConfigurationOnly() throws Exception {
         TemplateConfiguration.Builder tcb = new TemplateConfiguration.Builder();
         tcb.setCustomAttribute(KEY_3, VALUE_3);
         tcb.setCustomAttribute(KEY_4, VALUE_4);
@@ -150,14 +192,90 @@ public class CustomAttributeTest {
                 new Configuration.Builder(Configuration.VERSION_3_0_0).build(),
                 tcb.build());
 
-        assertEquals(ImmutableSet.of(KEY_3, KEY_4), t.getCustomAttributes().keySet());
+        assertEquals(ImmutableSet.of(KEY_3, KEY_4), t.getCustomAttributesSnapshot(true).keySet());
         assertEquals(VALUE_3, t.getCustomAttribute(KEY_3));
         assertEquals(VALUE_4, t.getCustomAttribute(KEY_4));
+
+        testMissingCustomAttributeAccess(t);
+        testCustomAttributesSnapshotIsUnmodifiable(t);
     }
 
-    private Object[] sort(String[] customAttributeNames) {
-        Arrays.sort(customAttributeNames);
-        return customAttributeNames;
+    @Test
+    public void testTemplateAttrsFromConfigurationOnly() throws Exception {
+        Configuration cfg = new Configuration.Builder(Configuration.VERSION_3_0_0)
+                .customAttribute(KEY_1, VALUE_1)
+                .build();
+        Template t = new Template(null, "", cfg);
+
+        assertEquals(VALUE_1, t.getCustomAttribute(KEY_1));
+        assertEquals("default", t.getCustomAttribute(KEY_2, "default"));
+
+        assertEquals(ImmutableMap.of(KEY_1, VALUE_1), t.getCustomAttributesSnapshot(true));
+        assertTrue(t.getCustomAttributesSnapshot(false).isEmpty());
+
+        testMissingCustomAttributeAccess(t);
+        testCustomAttributesSnapshotIsUnmodifiable(t);
+    }
+
+    @Test
+    public void testTemplateAttrsFromTemplateAndConfiguration() throws Exception {
+        Configuration cfg = new Configuration.Builder(Configuration.VERSION_3_0_0)
+                .customAttribute(KEY_1, VALUE_1)
+                .build();
+        Template t = new Template(null, "<#ftl attributes={'k2':'v2'}>", cfg);
+
+        assertEquals(VALUE_1, t.getCustomAttribute(KEY_1));
+        assertEquals("v2", t.getCustomAttribute("k2"));
+
+        assertEquals(ImmutableMap.of(KEY_1, VALUE_1, "k2", "v2"), t.getCustomAttributesSnapshot(true));
+        assertEquals(ImmutableMap.of("k2", "v2"), t.getCustomAttributesSnapshot(false));
+
+        testMissingCustomAttributeAccess(t);
+        testCustomAttributesSnapshotIsUnmodifiable(t);
+    }
+
+    @Test
+    public void testAllLayers() throws Exception {
+        Configuration cfg = new Configuration.Builder(Configuration.VERSION_3_0_0)
+                .customAttribute(KEY_1, VALUE_1)
+                .build();
+        Template t = new Template(null, "<#ftl attributes={'k2':'v2'}>", cfg,
+                new TemplateConfiguration.Builder().customAttribute(KEY_3, VALUE_3).build());
+
+        assertEquals(VALUE_1, t.getCustomAttribute(KEY_1));
+        assertEquals("v2", t.getCustomAttribute("k2"));
+        assertEquals(VALUE_3, t.getCustomAttribute(KEY_3));
+
+        assertEquals(ImmutableMap.of(KEY_1, VALUE_1, "k2", "v2", KEY_3, VALUE_3),
+                t.getCustomAttributesSnapshot(true));
+        assertEquals(ImmutableMap.of("k2", "v2", KEY_3, VALUE_3),
+                t.getCustomAttributesSnapshot(false));
+
+        testMissingCustomAttributeAccess(t);
+        testCustomAttributesSnapshotIsUnmodifiable(t);
+
+        Environment env = t.createProcessingEnvironment(null, null);
+
+        assertEquals(VALUE_1, env.getCustomAttribute(KEY_1));
+        assertEquals("v2", env.getCustomAttribute("k2"));
+        assertEquals(VALUE_3, env.getCustomAttribute(KEY_3));
+        assertEquals(ImmutableMap.of(KEY_1, VALUE_1, "k2", "v2", KEY_3, VALUE_3),
+                env.getCustomAttributesSnapshot(true));
+        assertEquals(Collections.emptyMap(),
+                env.getCustomAttributesSnapshot(false));
+
+        env.setCustomAttribute(KEY_4, VALUE_4);
+        assertEquals(VALUE_1, env.getCustomAttribute(KEY_1));
+        assertEquals("v2", env.getCustomAttribute("k2"));
+        assertEquals(VALUE_3, env.getCustomAttribute(KEY_3));
+        assertEquals(VALUE_4, env.getCustomAttribute(KEY_4));
+        assertEquals(ImmutableMap.of(KEY_1, VALUE_1, "k2", "v2", KEY_3, VALUE_3, KEY_4, VALUE_4),
+                env.getCustomAttributesSnapshot(true));
+        assertEquals(ImmutableMap.of(KEY_4, VALUE_4),
+                env.getCustomAttributesSnapshot(false));
+
+        testMissingCustomAttributeAccess(env);
+        testCustomAttributesSnapshotIsUnmodifiable(env);
     }
 
 }

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/dc689993/freemarker-core-test/src/test/java/org/apache/freemarker/core/TemplateConfigurationTest.java
----------------------------------------------------------------------
diff --git a/freemarker-core-test/src/test/java/org/apache/freemarker/core/TemplateConfigurationTest.java b/freemarker-core-test/src/test/java/org/apache/freemarker/core/TemplateConfigurationTest.java
index c229655..92b5bfb 100644
--- a/freemarker-core-test/src/test/java/org/apache/freemarker/core/TemplateConfigurationTest.java
+++ b/freemarker-core-test/src/test/java/org/apache/freemarker/core/TemplateConfigurationTest.java
@@ -18,6 +18,7 @@
  */
 package org.apache.freemarker.core;
 
+import static org.apache.freemarker.core.ProcessingConfiguration.MISSING_VALUE_MARKER;
 import static org.junit.Assert.*;
 
 import java.beans.BeanInfo;
@@ -174,7 +175,6 @@ public class TemplateConfigurationTest {
                 ImmutableMap.of("dummy", HexTemplateNumberFormatFactory.INSTANCE));
         SETTING_ASSIGNMENTS.put("customDateFormats",
                 ImmutableMap.of("dummy", EpochMillisTemplateDateFormatFactory.INSTANCE));
-        SETTING_ASSIGNMENTS.put("customAttributes", ImmutableMap.of("dummy", 123));
 
         // Parser-only settings:
         SETTING_ASSIGNMENTS.put("templateLanguage", TemplateLanguage.STATIC_TEXT);
@@ -237,6 +237,7 @@ public class TemplateConfigurationTest {
         IGNORED_PROP_NAMES.add("strictBeanModels");
         IGNORED_PROP_NAMES.add("parentConfiguration");
         IGNORED_PROP_NAMES.add("settings");
+        IGNORED_PROP_NAMES.add("customAttributes");
     }
 
     private static final Set<String> CONFIGURABLE_PROP_NAMES;
@@ -277,7 +278,7 @@ public class TemplateConfigurationTest {
         }
     }
 
-    private static final Object CA1 = new Object();
+    private static final Integer CA1 = Integer.valueOf(123);
     private static final String CA2 = "ca2";
     private static final String CA3 = "ca3";
     private static final String CA4 = "ca4";
@@ -434,10 +435,10 @@ public class TemplateConfigurationTest {
 
         assertEquals("v1", tcb1.getCustomAttribute("k1"));
         assertEquals("v1", tcb1.getCustomAttribute("k2"));
-        assertNull("v1", tcb1.getCustomAttribute("k3"));
+        assertEquals(MISSING_VALUE_MARKER, tcb1.getCustomAttribute("k3", MISSING_VALUE_MARKER));
         assertEquals("V1", tcb1.getCustomAttribute(CA1));
         assertEquals("V1", tcb1.getCustomAttribute(CA2));
-        assertNull(tcb1.getCustomAttribute(CA3));
+        assertEquals(MISSING_VALUE_MARKER, tcb1.getCustomAttribute(CA3, MISSING_VALUE_MARKER));
 
         TemplateConfiguration.Builder tcb2 = new TemplateConfiguration.Builder();
         tcb2.setCustomAttribute("k1", "v2");
@@ -454,10 +455,10 @@ public class TemplateConfigurationTest {
 
         assertNull(tcb1.getCustomAttribute("k1"));
         assertNull(tcb1.getCustomAttribute("k2"));
-        assertNull(tcb1.getCustomAttribute("k3"));
+        assertEquals(MISSING_VALUE_MARKER, tcb1.getCustomAttribute("k3", MISSING_VALUE_MARKER));
         assertNull(tcb1.getCustomAttribute(CA1));
         assertNull(tcb1.getCustomAttribute(CA2));
-        assertNull(tcb1.getCustomAttribute(CA3));
+        assertEquals(MISSING_VALUE_MARKER, tcb1.getCustomAttribute(CA3, MISSING_VALUE_MARKER));
 
         TemplateConfiguration.Builder tcb4 = new TemplateConfiguration.Builder();
         tcb4.setCustomAttribute("k1", "v4");
@@ -467,10 +468,10 @@ public class TemplateConfigurationTest {
 
         assertEquals("v4", tcb1.getCustomAttribute("k1"));
         assertNull(tcb1.getCustomAttribute("k2"));
-        assertNull(tcb1.getCustomAttribute("k3"));
+        assertEquals(MISSING_VALUE_MARKER, tcb1.getCustomAttribute("k3", MISSING_VALUE_MARKER));
         assertEquals("V4", tcb1.getCustomAttribute(CA1));
         assertNull(tcb1.getCustomAttribute(CA2));
-        assertNull(tcb1.getCustomAttribute(CA3));
+        assertEquals(MISSING_VALUE_MARKER, tcb1.getCustomAttribute(CA3, MISSING_VALUE_MARKER));
     }
 
     @Test
@@ -501,6 +502,7 @@ public class TemplateConfigurationTest {
                 .customAttribute("k1", "c")
                 .customAttribute("k2", "c")
                 .customAttribute("k3", "c")
+                .customAttribute("k8", "c")
                 .build();
 
         TemplateConfiguration.Builder tcb = new TemplateConfiguration.Builder();
@@ -509,30 +511,19 @@ public class TemplateConfigurationTest {
         tcb.setCustomAttribute("k4", "tc");
         tcb.setCustomAttribute("k5", "tc");
         tcb.setCustomAttribute("k6", "tc");
-        tcb.setCustomAttribute(CA1, "tc");
-        tcb.setCustomAttribute(CA2,"tc");
-        tcb.setCustomAttribute(CA3,"tc");
 
         TemplateConfiguration tc = tcb.build();
-        Template t = new Template(null, "", cfg, tc);
-        t.setCustomAttribute("k5", "t");
-        t.setCustomAttribute("k6", null);
-        t.setCustomAttribute("k7", "t");
-        t.setCustomAttribute(CA2, "t");
-        t.setCustomAttribute(CA3, null);
-        t.setCustomAttribute(CA4, "t");
+        Template t = new Template(null, "<#ftl attributes={'k5':'t', 'k7':'t', 'k8':'t'}>", cfg, tc);
 
         assertEquals("c", t.getCustomAttribute("k1"));
         assertEquals("tc", t.getCustomAttribute("k2"));
         assertNull(t.getCustomAttribute("k3"));
         assertEquals("tc", t.getCustomAttribute("k4"));
         assertEquals("t", t.getCustomAttribute("k5"));
-        assertNull(t.getCustomAttribute("k6"));
+        // TODO [FM3] when { ... 'k6': null ... } works in FTL, put this back.
+        // assertNull(t.getCustomAttribute("k6"));
         assertEquals("t", t.getCustomAttribute("k7"));
-        assertEquals("tc", t.getCustomAttribute(CA1));
-        assertEquals("t", t.getCustomAttribute(CA2));
-        assertNull(t.getCustomAttribute(CA3));
-        assertEquals("t", t.getCustomAttribute(CA4));
+        assertEquals("t", t.getCustomAttribute("k8"));
     }
     
     @Test

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/dc689993/freemarker-core-test/src/test/java/org/apache/freemarker/core/TemplateConfigurationWithDefaultTemplateResolverTest.java
----------------------------------------------------------------------
diff --git a/freemarker-core-test/src/test/java/org/apache/freemarker/core/TemplateConfigurationWithDefaultTemplateResolverTest.java b/freemarker-core-test/src/test/java/org/apache/freemarker/core/TemplateConfigurationWithDefaultTemplateResolverTest.java
index 4cd50eb..f242b66 100644
--- a/freemarker-core-test/src/test/java/org/apache/freemarker/core/TemplateConfigurationWithDefaultTemplateResolverTest.java
+++ b/freemarker-core-test/src/test/java/org/apache/freemarker/core/TemplateConfigurationWithDefaultTemplateResolverTest.java
@@ -18,9 +18,11 @@
  */
 package org.apache.freemarker.core;
 
+import static org.apache.freemarker.core.ProcessingConfiguration.MISSING_VALUE_MARKER;
 import static org.junit.Assert.*;
 
 import java.io.IOException;
+import java.io.Serializable;
 import java.io.StringWriter;
 import java.io.UnsupportedEncodingException;
 import java.nio.charset.Charset;
@@ -40,8 +42,8 @@ public class TemplateConfigurationWithDefaultTemplateResolverTest {
 
     private static final String TEXT_WITH_ACCENTS = "pr\u00F3ba";
 
-    private static final Object CUST_ATT_1 = new Object();
-    private static final Object CUST_ATT_2 = new Object();
+    private static final Serializable CUST_ATT_1 = Integer.valueOf(111);
+    private static final Serializable CUST_ATT_2 = Integer.valueOf(222);
 
     private static final Charset ISO_8859_2 = Charset.forName("ISO-8859-2");
 
@@ -212,10 +214,10 @@ public class TemplateConfigurationWithDefaultTemplateResolverTest {
         {
             Template t = cfg.getTemplate("(tc2)");
             assertEquals("a1tc2", t.getCustomAttribute("a1"));
-            assertNull(t.getCustomAttribute("a2"));
+            assertEquals(MISSING_VALUE_MARKER, t.getCustomAttribute("a2", MISSING_VALUE_MARKER));
             assertEquals("a3temp", t.getCustomAttribute("a3"));
             assertEquals("ca1tc2", t.getCustomAttribute(CUST_ATT_1));
-            assertNull(t.getCustomAttribute(CUST_ATT_2));
+            assertEquals(MISSING_VALUE_MARKER, t.getCustomAttribute(CUST_ATT_2, MISSING_VALUE_MARKER));
         }
         {
             Template t = cfg.getTemplate("(tc1)(tc2)");

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/dc689993/freemarker-core-test/src/test/java/org/apache/freemarker/core/templateresolver/TemplateConfigurationFactoryTest.java
----------------------------------------------------------------------
diff --git a/freemarker-core-test/src/test/java/org/apache/freemarker/core/templateresolver/TemplateConfigurationFactoryTest.java b/freemarker-core-test/src/test/java/org/apache/freemarker/core/templateresolver/TemplateConfigurationFactoryTest.java
index a7259d8..78ccf81 100644
--- a/freemarker-core-test/src/test/java/org/apache/freemarker/core/templateresolver/TemplateConfigurationFactoryTest.java
+++ b/freemarker-core-test/src/test/java/org/apache/freemarker/core/templateresolver/TemplateConfigurationFactoryTest.java
@@ -22,9 +22,11 @@ import static org.hamcrest.Matchers.*;
 import static org.junit.Assert.*;
 
 import java.io.IOException;
+import java.io.Serializable;
 import java.util.ArrayList;
 import java.util.List;
 
+import org.apache.freemarker.core.ProcessingConfiguration;
 import org.apache.freemarker.core.TemplateConfiguration;
 import org.junit.Test;
 
@@ -165,7 +167,7 @@ public class TemplateConfigurationFactoryTest {
     private void assertApplicable(TemplateConfigurationFactory tcf, String sourceName, TemplateConfiguration... expectedTCs)
             throws IOException, TemplateConfigurationFactoryException {
         TemplateConfiguration mergedTC = tcf.get(sourceName, DummyTemplateLoadingSource.INSTANCE);
-        List<Object> mergedTCAttNames = new ArrayList<>(mergedTC.getCustomAttributes().keySet());
+        List<Serializable> mergedTCAttNames = new ArrayList<>(mergedTC.getCustomAttributesSnapshot(false).keySet());
 
         for (TemplateConfiguration expectedTC : expectedTCs) {
             Integer tcId = (Integer) expectedTC.getCustomAttribute("id");
@@ -177,7 +179,7 @@ public class TemplateConfigurationFactoryTest {
             }
         }
         
-        for (Object attKey: mergedTCAttNames) {
+        for (Serializable attKey: mergedTCAttNames) {
             if (!containsCustomAttr(attKey, expectedTCs)) {
                 fail("The asserted TemplateConfiguration contains an unexpected custom attribute: " + attKey);
             }
@@ -186,9 +188,10 @@ public class TemplateConfigurationFactoryTest {
         assertEquals(expectedTCs[expectedTCs.length - 1].getCustomAttribute("id"), mergedTC.getCustomAttribute("id"));
     }
 
-    private boolean containsCustomAttr(Object attKey, TemplateConfiguration... expectedTCs) {
+    private boolean containsCustomAttr(Serializable attKey, TemplateConfiguration... expectedTCs) {
         for (TemplateConfiguration expectedTC : expectedTCs) {
-            if (expectedTC.getCustomAttribute(attKey) != null) {
+            if (expectedTC.getCustomAttribute(attKey, ProcessingConfiguration.MISSING_VALUE_MARKER)
+                    != ProcessingConfiguration.MISSING_VALUE_MARKER) {
                 return true;
             }
         }

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/dc689993/freemarker-core-test/src/test/java/org/apache/freemarker/core/util/CollectionUtilTest.java
----------------------------------------------------------------------
diff --git a/freemarker-core-test/src/test/java/org/apache/freemarker/core/util/CollectionUtilTest.java b/freemarker-core-test/src/test/java/org/apache/freemarker/core/util/CollectionUtilTest.java
new file mode 100644
index 0000000..a18d585
--- /dev/null
+++ b/freemarker-core-test/src/test/java/org/apache/freemarker/core/util/CollectionUtilTest.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.freemarker.core.util;
+
+import static org.junit.Assert.*;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.Test;
+
+public class CollectionUtilTest {
+
+    @Test
+    public void unmodifiableMap() {
+        Map<Object, Object> modifiableMap = new HashMap<>();
+        assertNotSame(modifiableMap, _CollectionUtil.unmodifiableMap(modifiableMap));
+
+        Map<Object, Object> wrappedModifiableMap = Collections.unmodifiableMap(modifiableMap);
+        assertSame(wrappedModifiableMap, _CollectionUtil.unmodifiableMap(wrappedModifiableMap));
+
+        assertSame(Collections.emptyMap(), _CollectionUtil.unmodifiableMap(Collections.emptyMap()));
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/dc689993/freemarker-core/src/main/java/org/apache/freemarker/core/Configuration.java
----------------------------------------------------------------------
diff --git a/freemarker-core/src/main/java/org/apache/freemarker/core/Configuration.java b/freemarker-core/src/main/java/org/apache/freemarker/core/Configuration.java
index 721331e..6691053 100644
--- a/freemarker-core/src/main/java/org/apache/freemarker/core/Configuration.java
+++ b/freemarker-core/src/main/java/org/apache/freemarker/core/Configuration.java
@@ -133,6 +133,9 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
  * </ul>
  * 
  * <p>{@link Configuration} is thread-safe and (as of 3.0.0) immutable (apart from internal caches).
+ *
+ * <p>The setting reader methods of this class don't throw {@link SettingValueNotSetException}, because all settings
+ * are set on the {@link Configuration} level (even if they were just initialized to a default value).
  */
 public final class Configuration
         implements TopLevelConfiguration, CustomStateScope {
@@ -279,7 +282,7 @@ public final class Configuration
     private final List<String> autoIncludes;
     private final Boolean lazyImports;
     private final Boolean lazyAutoImports;
-    private final Map<Object, Object> customAttributes;
+    private final Map<Serializable, Object> customAttributes;
 
     // CustomStateScope:
 
@@ -434,7 +437,7 @@ public final class Configuration
         autoIncludes = builder.getAutoIncludes();
         lazyImports = builder.getLazyImports();
         lazyAutoImports = builder.getLazyAutoImports();
-        customAttributes = builder.getCustomAttributes();
+        customAttributes = builder.getCustomAttributesSnapshot(false);
     }
 
     private <SelfT extends ExtendableBuilder<SelfT>> void wrapAndPutSharedVariables(
@@ -462,7 +465,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isTemplateExceptionHandlerSet() {
@@ -482,7 +486,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isTemplateLoaderSet() {
@@ -498,7 +503,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isTemplateLookupStrategySet() {
@@ -514,7 +520,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isTemplateNameFormatSet() {
@@ -530,7 +537,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isTemplateConfigurationsSet() {
@@ -543,7 +551,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isCacheStorageSet() {
@@ -556,7 +565,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isTemplateUpdateDelayMillisecondsSet() {
@@ -574,7 +584,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isWhitespaceStrippingSet() {
@@ -627,7 +638,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isAutoEscapingPolicySet() {
@@ -640,7 +652,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isOutputFormatSet() {
@@ -760,7 +773,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isRecognizeStandardFileExtensionsSet() {
@@ -773,7 +787,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isTemplateLanguageSet() {
@@ -786,11 +801,13 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isTagSyntaxSet() {
@@ -803,7 +820,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isNamingConventionSet() {
@@ -816,7 +834,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isTabSizeSet() {
@@ -829,7 +848,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isLocaleSet() {
@@ -842,7 +862,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isTimeZoneSet() {
@@ -855,7 +876,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isSQLDateAndTimeTimeZoneSet() {
@@ -868,7 +890,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isArithmeticEngineSet() {
@@ -881,7 +904,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isNumberFormatSet() {
@@ -899,7 +923,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isCustomNumberFormatsSet() {
@@ -912,7 +937,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isBooleanFormatSet() {
@@ -925,7 +951,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isTimeFormatSet() {
@@ -938,7 +965,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isDateFormatSet() {
@@ -951,7 +979,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isDateTimeFormatSet() {
@@ -969,7 +998,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isCustomDateFormatsSet() {
@@ -982,7 +1012,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isObjectWrapperSet() {
@@ -995,7 +1026,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isOutputEncodingSet() {
@@ -1008,7 +1040,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isURLEscapingCharsetSet() {
@@ -1021,7 +1054,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isNewBuiltinClassResolverSet() {
@@ -1034,7 +1068,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isAPIBuiltinEnabledSet() {
@@ -1047,7 +1082,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isAutoFlushSet() {
@@ -1060,7 +1096,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isShowErrorTipsSet() {
@@ -1073,7 +1110,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isLogTemplateExceptionsSet() {
@@ -1086,7 +1124,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isLazyImportsSet() {
@@ -1099,7 +1138,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isLazyAutoImportsSet() {
@@ -1112,7 +1152,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isAutoImportsSet() {
@@ -1125,29 +1166,56 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isAutoIncludesSet() {
         return true;
     }
 
+    /**
+     * {@inheritDoc}
+     * <p>
+     * Because {@link Configuration} has on parent, the {@code includeInherited} parameter is ignored.
+     */
     @Override
-    public Map<Object, Object> getCustomAttributes() {
+    public Map<Serializable, Object> getCustomAttributesSnapshot(boolean includeInherited) {
         return customAttributes;
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * {@inheritDoc}
+     * <p>
+     * Unlike the other isXxxSet methods of {@link Configuration}, this can return {@code false}, as at least the
+     * builders in FreeMarker Core can't provide defaults for custom attributes. Note that since
+     * {@link #getCustomAttribute(Serializable)} just returns {@code null} for unset custom attributes, it's usually not a
+     * problem.
      */
     @Override
-    public boolean isCustomAttributesSet() {
-        return true;
+    public boolean isCustomAttributeSet(Serializable key) {
+        return customAttributes.containsKey(key);
     }
 
     @Override
-    public Object getCustomAttribute(Object key) {
-        return customAttributes.get(key);
+    public Object getCustomAttribute(Serializable key) {
+        return getCustomAttribute(key, null, false);
+    }
+
+    @Override
+    public Object getCustomAttribute(Serializable key, Object defaultValue) {
+        return getCustomAttribute(key, defaultValue, true);
+    }
+
+    private Object getCustomAttribute(Serializable key, Object defaultValue, boolean useDefaultValue) {
+        Object value = customAttributes.get(key);
+        if (value != null || customAttributes.containsKey(key)) {
+            return value;
+        }
+        if (useDefaultValue) {
+            return defaultValue;
+        }
+        throw new CustomAttributeNotSetException(key);
     }
 
     @Override
@@ -1174,11 +1242,9 @@ public final class Configuration
     /**
      * Retrieves the template with the given name from the template cache, loading it into the cache first
      * if it's missing/staled.
-     * 
      * <p>
      * This is a shorthand for {@link #getTemplate(String, Locale, Serializable, boolean)
      * getTemplate(name, null, null, false)}; see more details there.
-     * 
      * <p>
      * See {@link Configuration} for an example of basic usage.
      */
@@ -1356,7 +1422,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isSourceEncodingSet() {
@@ -1414,7 +1481,8 @@ public final class Configuration
     }
 
     /**
-     * Always {@code true} in {@link Configuration}-s, so calling the corresponding getter is always safe.
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
      */
     @Override
     public boolean isLocalizedLookupSet() {
@@ -2558,15 +2626,17 @@ public final class Configuration
         }
 
         @Override
-        protected Object getDefaultCustomAttribute(Object name) {
-            return Collections.emptyMap();
+        protected Object getDefaultCustomAttribute(Serializable key, Object defaultValue, boolean useDefaultValue) {
+            if (useDefaultValue) {
+                return defaultValue;
+            }
+            throw new CustomAttributeNotSetException(key);
         }
 
         @Override
-        protected Map<Object, Object> getDefaultCustomAttributes() {
-            return Collections.emptyMap();
+        protected void collectDefaultCustomAttributesSnapshot(Map<Serializable, Object> target) {
+            // Doesn't inherit anything
         }
-
     }
 
     /**

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/dc689993/freemarker-core/src/main/java/org/apache/freemarker/core/CustomAttributeNotSetException.java
----------------------------------------------------------------------
diff --git a/freemarker-core/src/main/java/org/apache/freemarker/core/CustomAttributeNotSetException.java b/freemarker-core/src/main/java/org/apache/freemarker/core/CustomAttributeNotSetException.java
new file mode 100644
index 0000000..891f928
--- /dev/null
+++ b/freemarker-core/src/main/java/org/apache/freemarker/core/CustomAttributeNotSetException.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.freemarker.core;
+
+import java.io.Serializable;
+
+import org.apache.freemarker.core.util._StringUtil;
+
+/**
+ * Thrown by {@link ProcessingConfiguration#getCustomAttribute(Serializable)} if the custom attribute is not set.
+ */
+public class CustomAttributeNotSetException extends SettingValueNotSetException {
+
+    private final Serializable key;
+
+    /**
+     * @param key {@link ProcessingConfiguration#getCustomAttribute(Serializable)}
+     */
+    public CustomAttributeNotSetException(Serializable key) {
+        super("customAttributes[" + key instanceof String ? _StringUtil.jQuote(key) : _StringUtil.tryToString(key) +
+                        "]", false);
+        this.key = key;
+    }
+
+    /**
+     * The argument to {@link ProcessingConfiguration#getCustomAttribute(Serializable)}.
+     */
+    public Serializable getKey() {
+        return key;
+    }
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/dc689993/freemarker-core/src/main/java/org/apache/freemarker/core/Environment.java
----------------------------------------------------------------------
diff --git a/freemarker-core/src/main/java/org/apache/freemarker/core/Environment.java b/freemarker-core/src/main/java/org/apache/freemarker/core/Environment.java
index 35ec25d..fa2d696 100644
--- a/freemarker-core/src/main/java/org/apache/freemarker/core/Environment.java
+++ b/freemarker-core/src/main/java/org/apache/freemarker/core/Environment.java
@@ -92,14 +92,15 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
  * <tt>process()</tt> returns. This object stores the set of temporary variables created by the template, the value of
  * settings set by the template, the reference to the data model root, etc. Everything that is needed to fulfill the
  * template processing job.
- *
  * <p>
  * Data models that need to access the <tt>Environment</tt> object that represents the template processing on the
  * current thread can use the {@link #getCurrentEnvironment()} method.
- *
  * <p>
  * If you need to modify or read this object before or after the <tt>process</tt> call, use
  * {@link Template#createProcessingEnvironment(Object rootMap, Writer out, ObjectWrapper wrapper)}
+ * <p>
+ * The {@link ProcessingConfiguration} reader methods of this class don't throw {@link SettingValueNotSetException}
+ * because unset settings are ultimately inherited from {@link Configuration}.
  */
 public final class Environment extends MutableProcessingConfiguration<Environment> implements CustomStateScope {
     
@@ -1091,17 +1092,18 @@ public final class Environment extends MutableProcessingConfiguration<Environmen
     }
 
     @Override
-    protected Object getDefaultCustomAttribute(Object name) {
-        return getMainTemplate().getCustomAttribute(name);
+    protected Object getDefaultCustomAttribute(Serializable key, Object defaultValue, boolean useDefaultValue) {
+        return useDefaultValue ? getMainTemplate().getCustomAttribute(key, defaultValue)
+                : getMainTemplate().getCustomAttribute(key);
     }
 
     @Override
-    protected Map<Object, Object> getDefaultCustomAttributes() {
-        return getMainTemplate().getCustomAttributes();
+    protected void collectDefaultCustomAttributesSnapshot(Map<Serializable, Object> target) {
+        target.putAll(getMainTemplate().getCustomAttributesSnapshot(true));
     }
 
     /*
-     * Note that altough it's not allowed to set this setting with the <tt>setting</tt> directive, it still must be
+     * Note that although it's not allowed to set this setting with the <tt>setting</tt> directive, it still must be
      * allowed to set it from Java code while the template executes, since some frameworks allow templates to actually
      * change the output encoding on-the-fly.
      */

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/dc689993/freemarker-core/src/main/java/org/apache/freemarker/core/MutableProcessingConfiguration.java
----------------------------------------------------------------------
diff --git a/freemarker-core/src/main/java/org/apache/freemarker/core/MutableProcessingConfiguration.java b/freemarker-core/src/main/java/org/apache/freemarker/core/MutableProcessingConfiguration.java
index dcf0714..25d6c62 100644
--- a/freemarker-core/src/main/java/org/apache/freemarker/core/MutableProcessingConfiguration.java
+++ b/freemarker-core/src/main/java/org/apache/freemarker/core/MutableProcessingConfiguration.java
@@ -19,6 +19,7 @@
 
 package org.apache.freemarker.core;
 
+import java.io.Serializable;
 import java.nio.charset.Charset;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -26,10 +27,8 @@ import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
-import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
-import java.util.LinkedList;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
@@ -335,7 +334,9 @@ public abstract class MutableProcessingConfiguration<SelfT extends MutableProces
     private Boolean lazyImports;
     private Boolean lazyAutoImports;
     private boolean lazyAutoImportsSet;
-    private Map<Object, Object> customAttributes;
+    private Map<Serializable, Object> customAttributes = Collections.emptyMap();
+    /** If {@code false}, we must use copy-on-write behavior for {@link #customAttributes}. */
+    private boolean customAttributesModifiable;
 
     /**
      * Creates a new instance. Normally you do not need to use this constructor,
@@ -2124,131 +2125,167 @@ public abstract class MutableProcessingConfiguration<SelfT extends MutableProces
         return self();
     }
 
-    @Override
-    public Map<Object, Object> getCustomAttributes() {
-        return isCustomAttributesSet() ? customAttributes : getDefaultCustomAttributes();
+    /**
+     * Setter pair of {@link #getCustomAttribute(Serializable)}.
+     *
+     * @param key
+     *         The identifier of the the custom attribute; not {@code null}. Usually an enum or a {@link String}. Must
+     *         be usable as {@link HashMap} key.
+     * @param value
+     *         The value of the custom attribute. {@code null} is a legal attribute value. Thus, setting the value to
+     *         {@code null} doesn't unset (remove) the attribute; use {@link #unsetCustomAttribute(Serializable)} for
+     *         that. Also, {@link #MISSING_VALUE_MARKER} is not an allowed value.
+     *         The content of the object shouldn't be changed after it was added as an attribute (ideally, it should
+     *         be a true immutable object); if you need to change the content, certainly you should use the
+     *         {@link CustomStateScope} API.
+     */
+    public void setCustomAttribute(Serializable key, Object value) {
+        _NullArgumentException.check("key", key);
+        if (value == MISSING_VALUE_MARKER) {
+            throw new IllegalArgumentException("MISSING_VALUE_MARKER can't be used as attribute value");
+        }
+        ensureCustomAttributesModifiable();
+        customAttributes.put(key, value);
     }
 
-    protected abstract Map<Object,Object> getDefaultCustomAttributes();
-
     /**
-     * Setter pair of {@link #getCustomAttributes()}
-     *
-     * @param customAttributes Not {@code null}. The {@link Map} is copied to prevent aliasing problems.
+     * Fluent API equivalent of {@link #setCustomAttribute(Serializable, Object)}
      */
-    public void setCustomAttributes(Map<Object, Object> customAttributes) {
-        setCustomAttributes(customAttributes, false);
+    public SelfT customAttribute(Serializable key, Object value) {
+        setCustomAttribute(key, value);
+        return self();
+    }
+
+    @Override
+    public boolean isCustomAttributeSet(Serializable key) {
+        return customAttributes.containsKey(key);
     }
 
     /**
-     * @param validatedImmutableUnchanging
-     *         {@code true} if we know that the 1st argument is already validated, immutable, and unchanging (means,
-     *         won't change later because of aliasing).
-     */
-    void setCustomAttributes(Map<Object, Object> customAttributes, boolean validatedImmutableUnchanging) {
-        _NullArgumentException.check("customAttributes", customAttributes);
-        if (!validatedImmutableUnchanging) {
-            this.customAttributes = new LinkedHashMap<>(customAttributes); // TODO mutable
-        } else {
-            this.customAttributes = customAttributes;
+     * Unset the custom attribute for this {@link ProcessingConfiguration} (but not from the parent
+     * {@link ProcessingConfiguration}, from where it will be possibly inherited after this), as if
+     * {@link #setCustomAttribute(Serializable, Object)} was never called for it on this
+     * {@link ProcessingConfiguration}. Note that this is different than setting the custom attribute value to {@code
+     * null}, as then {@link #getCustomAttribute(Serializable)} will just return that {@code null}, and won't look for the
+     * attribute in the parent {@link ProcessingConfiguration}.
+     *
+     * @param key As in {@link #getCustomAttribute(Serializable)}
+     */
+    public void unsetCustomAttribute(Serializable key) {
+        if (customAttributesModifiable) {
+            customAttributes.remove(key);
+        } else if (customAttributes.containsKey(key)) {
+            ensureCustomAttributesModifiable();
+            customAttributes.remove(key);
         }
     }
 
-    /**
-     * Fluent API equivalent of {@link #setCustomAttributes(Map)}
-     */
-    public SelfT customAttributes(Map<Object, Object> customAttributes) {
-        setCustomAttributes(customAttributes);
-        return self();
+    @Override
+    public Object getCustomAttribute(Serializable key) throws CustomAttributeNotSetException {
+        return getCustomAttribute(key, null, false);
     }
 
     @Override
-    public boolean isCustomAttributesSet() {
-        return customAttributes != null;
+    public Object getCustomAttribute(Serializable key, Object defaultValue) {
+        return getCustomAttribute(key, defaultValue, true);
     }
 
-    boolean isCustomAttributeSet(Object key) {
-         return isCustomAttributesSet() && customAttributes.containsKey(key);
+    private Object getCustomAttribute(Serializable key, Object defaultValue, boolean useDefaultValue) {
+        Object value = customAttributes.get(key);
+        if (value != null || customAttributes.containsKey(key)) {
+            return value;
+        }
+        return getDefaultCustomAttribute(key, defaultValue, useDefaultValue);
+    }
+
+    @Override
+    public Map<Serializable, Object> getCustomAttributesSnapshot(boolean includeInherited) {
+        if (includeInherited) {
+            LinkedHashMap<Serializable, Object> result = new LinkedHashMap<>();
+            collectDefaultCustomAttributesSnapshot(result);
+            if (!result.isEmpty()) {
+                if (customAttributes != null) {
+                    result.putAll(customAttributes);
+                }
+                return Collections.unmodifiableMap(result);
+            }
+        }
+
+        // When there's no need for inheritance:
+        customAttributesModifiable = false; // Copy-on-write on next modification
+        return _CollectionUtil.unmodifiableMap(customAttributes);
     }
 
     /**
-     * Sets a {@linkplain #getCustomAttributes() custom attribute} for this configurable.
-     *
-     * @param name
-     *         the name of the custom attribute
-     * @param value
-     *         the value of the custom attribute. You can set the value to {@code null}, however note that there is a
-     *         semantic difference between an attribute set to {@code null} and an attribute that is not present (see
-     *         {@link #removeCustomAttribute(Object)}).
+     * Called from {@link #getCustomAttributesSnapshot(boolean)}, adds the default (such as inherited) custom attributes
+     * to the argument {@link Map}.
      */
-    public void setCustomAttribute(Object name, Object value) {
-        if (customAttributes == null) {
-            customAttributes = new LinkedHashMap<>();
+    protected abstract void collectDefaultCustomAttributesSnapshot(Map<Serializable, Object> target);
+
+    private void ensureCustomAttributesModifiable() {
+        if (!customAttributesModifiable) {
+            customAttributes = new LinkedHashMap<>(customAttributes);
+            customAttributesModifiable = true;
         }
-        customAttributes.put(name, value);
     }
 
     /**
-     * Fluent API equivalent of {@link #setCustomAttribute(Object, Object)}
+     * Called be {@link #getCustomAttribute(Serializable)} and {@link #getCustomAttribute(Serializable, Object)} if the
+     * attribute wasn't set in the current {@link ProcessingConfiguration}.
+     *
+     * @param useDefaultValue
+     *         If {@code true}, and the attribute is missing, then return {@code defaultValue}, otherwise throw {@link
+     *         CustomAttributeNotSetException}.
+     *
+     * @throws CustomAttributeNotSetException
+     *         if the attribute wasn't set in the parents, or has no default otherwise, and {@code useDefaultValue} was
+     *         {@code false}.
      */
-    public SelfT customAttribute(Object name, Object value) {
-        setCustomAttribute(name, value);
-        return self();
-    }
+    protected abstract Object getDefaultCustomAttribute(
+            Serializable key, Object defaultValue, boolean useDefaultValue) throws CustomAttributeNotSetException;
 
     /**
-     * Returns an array with names of all custom attributes defined directly on this {@link ProcessingConfiguration}.
-     * (That is, it doesn't contain the names of custom attributes inherited from other {@link
-     * ProcessingConfiguration}-s.) The returned array is never {@code null}, but can be zero-length.
+     * Convenience method for calling {@link #setCustomAttribute(Serializable, Object)} for each {@link Map} entry.
+     * Note that it won't remove the already existing custom attributes.
      */
-    // TODO env only?
-    // TODO should return List<String>?
-    public String[] getCustomAttributeNames() {
-        if (customAttributes == null) {
-            return _CollectionUtil.EMPTY_STRING_ARRAY;
-        }
-        Collection names = new LinkedList(customAttributes.keySet());
-        for (Iterator iter = names.iterator(); iter.hasNext(); ) {
-            if (!(iter.next() instanceof String)) {
-                iter.remove();
+    public void setCustomAttributes(Map<? extends Serializable, ?> customAttributes) {
+        _NullArgumentException.check("customAttributes", customAttributes);
+        for (Object value : customAttributes.values()) {
+            if (value == MISSING_VALUE_MARKER) {
+                throw new IllegalArgumentException("MISSING_VALUE_MARKER can't be used as attribute value");
             }
         }
-        return (String[]) names.toArray(new String[names.size()]);
+
+        ensureCustomAttributesModifiable();
+        this.customAttributes.putAll(customAttributes);
+        customAttributesModifiable = true;
     }
-    
+
     /**
-     * Removes a named custom attribute for this configurable. Note that this
-     * is different than setting the custom attribute value to null. If you
-     * set the value to null, {@link #getCustomAttribute(Object)} will return
-     * null, while if you remove the attribute, it will return the value of
-     * the attribute in the parent configurable (if there is a parent 
-     * configurable, that is). 
-     *
-     * @param name the name of the custom attribute
+     * Fluent API equivalent of {@link #setCustomAttributes(Map)}
      */
-    // TODO doesn't work properly, remove?
-    public void removeCustomAttribute(Object name) {
-        if (customAttributes == null) {
-            return;
-        }
-        customAttributes.remove(name);
+    public SelfT customAttributes(Map<Serializable, Object> customAttributes) {
+        setCustomAttributes(customAttributes);
+        return self();
     }
 
-    @Override
-    public Object getCustomAttribute(Object key) {
-        Object value;
-        if (customAttributes != null) {
-            value = customAttributes.get(key);
-            if (value == null && customAttributes.containsKey(key)) {
-                return null;
-            }
-        } else {
-            value = null;
-        }
-        return value != null ? value : getDefaultCustomAttribute(key);
+    /**
+     * Used internally to avoid copying the {@link Map} when we know that its content won't change anymore.
+     */
+    void setCustomAttributesMap(Map<Serializable, Object> customAttributes) {
+        _NullArgumentException.check("customAttributes", customAttributes);
+        this.customAttributes = customAttributes;
+        this.customAttributesModifiable = false;
     }
 
-    protected abstract Object getDefaultCustomAttribute(Object name);
+    /**
+     * Unsets all custom attributes which were set in this {@link ProcessingConfiguration} (but doesn't unset
+     * those inherited from a parent {@link ProcessingConfiguration}).
+     */
+    public void unsetAllCustomAttributes() {
+        customAttributes = Collections.emptyMap();
+        customAttributesModifiable = false;
+    }
 
     protected final List<String> parseAsList(String text) throws GenericParseException {
         return new SettingStringParser(text).parseAsList();

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/dc689993/freemarker-core/src/main/java/org/apache/freemarker/core/ProcessingConfiguration.java
----------------------------------------------------------------------
diff --git a/freemarker-core/src/main/java/org/apache/freemarker/core/ProcessingConfiguration.java b/freemarker-core/src/main/java/org/apache/freemarker/core/ProcessingConfiguration.java
index d68ab78..3756b34 100644
--- a/freemarker-core/src/main/java/org/apache/freemarker/core/ProcessingConfiguration.java
+++ b/freemarker-core/src/main/java/org/apache/freemarker/core/ProcessingConfiguration.java
@@ -19,10 +19,12 @@
 
 package org.apache.freemarker.core;
 
+import java.io.Serializable;
 import java.io.Writer;
 import java.nio.charset.Charset;
 import java.text.NumberFormat;
 import java.text.SimpleDateFormat;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
@@ -48,6 +50,12 @@ import org.apache.freemarker.core.valueformat.TemplateNumberFormatFactory;
 public interface ProcessingConfiguration {
 
     /**
+     * Useful as the default value parameter to {#getCustomAttribute(Serializable, Object)}, because this value is not
+     * allowed for custom attributes.
+     */
+    Object MISSING_VALUE_MARKER = new Object();
+
+    /**
      * The locale used for number and date formatting (among others), also the locale used for searching localized
      * template variations when no locale was explicitly specified where the template is requested.
      *
@@ -666,39 +674,86 @@ public interface ProcessingConfiguration {
     boolean isAutoIncludesSet();
 
     /**
-     * The {@code Map} of custom attributes. Custom attributes are key-value pairs associated to a
-     * {@link ProcessingConfiguration} objects, which meant to be used for storing application or framework specific
-     * configuration settings. The FreeMarker core doesn't define any attributes. Note that to store
-     * {@link ProcessingConfiguration}-scoped state (such as application or framework specific caches) you should use
-     * the methods provided by the {@link CustomStateScope} instead.
+     * Retrieves the value of a custom attribute. Custom attributes are key-value pairs associated to a {@link
+     * ProcessingConfiguration} object, that the FreeMarker core doesn't try to interpret. They are like configuration
+     * settings added dynamically (as opposed to in compilation time), where each custom attribute is treated as an
+     * individual setting. So where predefined configuration settings used to have {@code isXxxSet}, {@code
+     * unsetXxx}, and {@code setXxx} methods, custom attributes have these too, with a key (the identifier of the
+     * custom attribute) as an extra argument (see {@link #isCustomAttributeSet(Serializable)},
+     * {@link MutableProcessingConfiguration#setCustomAttribute(Serializable, Object)},
+     * {@link MutableProcessingConfiguration#unsetCustomAttribute(Serializable)}).
      * <p>
      * When the {@link ProcessingConfiguration} is part of a setting inheritance chain ({@link Environment} inherits
-     * settings from the main {@link Template}, which inherits from the {@link Configuration}), you still only get the
-     * {@link Map} from the closest {@link ProcessingConfiguration} where it was set, not a {@link Map} that respects
-     * inheritance. Thus to get attributes, you shouldn't use this {@link Map} directly, but
-     * {@link #getCustomAttribute(Object)} that will search the custom attribute in the whole inheritance chain.
+     * settings from the main {@link Template}, which inherits from the {@link Configuration}), this method will search
+     * the custom attribute in the whole inheritance chain, until it finds it.
+     * <p>
+     * To prevent key clashes (and for better performance), it's often a good idea to use enums as keys, rather than
+     * {@link String}-s. If {@link String}-s are used for keys (names) by components that will be reused on several
+     * places, then to avoid accidental name clashes, the names should use a prefix similar to a package name, like
+     * like "com.example.myframework.".
+     * <p>
+     * The values of custom attributes should be immutable, or at least not changed after they were added as a
+     * custom attribute value. To store custom state information (such as application or framework specific caches)
+     * you should use the methods provided by {@link CustomStateScope} instead.
+     * <p>
+     * The FreeMarker core doesn't provide any means for accessing custom attributes from the templates. If a framework
+     * or application needs such functionality, it has to add its own custom directives/methods for that. But its
+     * more typical that custom attributes just influence the behavior of custom directives/methods without the normal
+     * templates directly accessing them, or that they are just used by the framework code that invokes templates.
+     *
+     * @param key
+     *         The identifier (usually an enum or a {@link String}) of the custom attribute; not {@code null}; must be
+     *         usable as {@link HashMap} key
+     *
+     * @return The value of the custom attribute; possibly {@code null}, as that's a legal attribute value. The content
+     * of the value object shouldn't be changed after it was added as an attribute (ideally, it should be an
+     * immutable object); if you need to change the content, certainly you should use the {@link CustomStateScope}
+     * API. Note that if the custom attribute was created with <tt>&lt;#ftl&nbsp;attributes={...}&gt;</tt>, then this
+     * value is already unwrapped (i.e. it's a <code>String</code>, or a <code>List</code>, or a <code>Map</code>,
+     * ...etc., not a FreeMarker specific class).
+     *
+     * @throws CustomAttributeNotSetException if the custom attribute was not set (not even to {@code null}), nor in
+     * this {@link ProcessingConfiguration}, nor in another where we inherit settings from. Use
+     * {@link #getCustomAttribute(Serializable, Object)} to avoid this exception.
      */
-    Map<Object, Object> getCustomAttributes();
+    Object getCustomAttribute(Serializable key) throws CustomAttributeNotSetException;
 
     /**
-     * Tells if this setting is set directly in this object. If not, then depending on the implementing class, reading
-     * the setting mights returns a default value, or returns the value of the setting from a parent object, or throws
-     * an {@link SettingValueNotSetException}.
+     * Same as {@link #getCustomAttribute(Serializable)}, but instead of throwing {@link CustomAttributeNotSetException}
+     * it returns the default value specified as the 2nd argument.
+     *
+     * @param defaultValue
+     *         The value to return if the attribute is not set. Note that an attribute that was explicitly set to
+     *         {@code null}, then {@code null} will be returned for it, not the default value specified here, since
+     *         the attribute was set. If you want to know if the value was set, {@link #MISSING_VALUE_MARKER} can
+     *         be used, as it's guaranteed that an attribute never has that value.
      */
-    boolean isCustomAttributesSet();
+    Object getCustomAttribute(Serializable key, Object defaultValue);
 
     /**
-     * Retrieves a custom attribute for this {@link ProcessingConfiguration}. If the attribute is not present in the
-     * {@link ProcessingConfiguration}, but it inherits from another {@link ProcessingConfiguration}, then the attribute
-     * is searched the as well.
+     * Tells if this custom attribute is set directly in this object (not in its parent
+     * {@link ProcessingConfiguration}). If not, then depending on the implementing class, reading the custom
+     * attribute might returns the value of the setting from a parent object, or returns {@code null}, or throws a
+     * {@link SettingValueNotSetException}. Note that if an attribute was set to {@code
+     * null} (as opposed to not set at all) then this method will return {@code true}.
+     */
+    boolean isCustomAttributeSet(Serializable key);
+
+    /**
+     * Collects all {@linkplain #getCustomAttribute(Serializable)} custom attributes} into a {@link Map}; mostly useful for
+     * debugging and tooling, and is possibly too slow to call very frequently.
      *
-     * @param key
-     *         the identifier (usually a name) of the custom attribute
+     * @param includeInherited
+     *         If {@code false}, only the custom attributes set in this {@link ProcessingConfiguration} will be
+     *         collected, otherwise the custom attributes inherited from the parent {@link ProcessingConfiguration}-s
+     *         will be too. Note that it's the last that matches the behavior of {@link
+     *         #getCustomAttribute(Serializable)}.
      *
-     * @return the value of the custom attribute. Note that if the custom attribute was created with
-     * <tt>&lt;#ftl&nbsp;attributes={...}&gt;</tt>, then this value is already unwrapped (i.e. it's a
-     * <code>String</code>, or a <code>List</code>, or a <code>Map</code>, ...etc., not a FreeMarker specific class).
+     * @return An unmodifiable and unchanging {@link Map}; not {@code null}. The object identity of keys and values of
+     * this {@link Map} will not change when custom attributes are set/unset later (hence it's a snapshot). But, if
+     * a key or value objects are themselves mutable objects, FreeMarker can't prevent their content from changing.
+     * You shouldn't change the content of those objects.
      */
-    Object getCustomAttribute(Object key);
+    Map<Serializable, Object> getCustomAttributesSnapshot(boolean includeInherited);
 
 }

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/dc689993/freemarker-core/src/main/java/org/apache/freemarker/core/SettingValueNotSetException.java
----------------------------------------------------------------------
diff --git a/freemarker-core/src/main/java/org/apache/freemarker/core/SettingValueNotSetException.java b/freemarker-core/src/main/java/org/apache/freemarker/core/SettingValueNotSetException.java
index 1ce895d..c9817cf 100644
--- a/freemarker-core/src/main/java/org/apache/freemarker/core/SettingValueNotSetException.java
+++ b/freemarker-core/src/main/java/org/apache/freemarker/core/SettingValueNotSetException.java
@@ -21,13 +21,23 @@ package org.apache.freemarker.core;
 
 import org.apache.freemarker.core.util._StringUtil;
 
+/**
+ * Thrown when you try to read a configuration setting which wasn't set and isn't inherited from a parent object and has
+ * no default either. Because {@link Configuration} specifies a default value for all settings, objects that has a
+ * {@link Configuration} in their inheritance chain (like {@link Environment}, {@link Template}) won't throw this.
+ */
 public class SettingValueNotSetException extends IllegalStateException {
 
     private final String settingName;
 
     public SettingValueNotSetException(String settingName) {
-        super("The " + _StringUtil.jQuote(settingName)
+        this(settingName, true);
+    }
+
+    public SettingValueNotSetException(String settingName, boolean quoteSettingName) {
+        super("The " + (quoteSettingName ? _StringUtil.jQuote(settingName) : settingName)
                 + " setting is not set in this layer and has no default here either.");
         this.settingName = settingName;
     }
+
 }