3 de agosto de 2013

Principios SOLID con JavaScript: El principio abierto/cerrado (Traducción del Inglés)


En mi afán de aprender bien JavaScript, y ya de paso un poquito de inglés y un poquito de Ingeniería del Software, me he propuesto traducir una serie de artículos sobre los principios SOLID aplicados a JavaScript. El segundo de ellos es: SOLID JavaScript: The Open/Closed Principle.


Esta es la segunda entrega de la serie "SOLID JavaScript" que explora los principios de diseño SOLID dentro del contexto del lenguaje JavaScript. En esta entrega, vamos a explicar el principio abierto/cerrado.

El principio abierto/cerrado


El principio abierto/cerrado se refiere a la extensión de los objetos. El principio declara:
Las entidades de software (clases, módulos, funciones, etc.) deben estar abiertas para su extensión, pero cerradas para su modificación
Con "abiertas para su expansión", en este principio se entiende como la capacidad de una entidad para adaptarse a las necesidades cambiantes de una aplicación. Con "cerradas para su modificación", este principio quiere decir que la adaptación de una entidad no debe hacerse por la modificación de la entidad original. De una forma más simple, las entidades que realizan comportamientos variados deben de estar diseñadas para facilitar los cambios, sin necesidad de modificación. La adhesión al principio abierto/cerrado puede ayudar a mejorar la mantenibilidad, reduciendo al mínimo los cambios realizados en nuestro código.

Para ayudar a ilustrar, vamos a echar un vistazo al siguiente ejemplo que convierte de forma dinámica preguntas a una pantalla en función del tipo de respuesta esperada para cada pregunta:

<!DOCTYPE html>
<html>
<head>
  <meta http-equiv="content-type" content="text/html; charset=UTF-8">
  <title>Open/Closed Principle: 
      Example 1 - jsFiddle demo by derekgreer</title>
  <script type='text/javascript'
      src='http://code.jquery.com/jquery-1.7.1.js'></script>
  <script type='text/javascript'>//<![CDATA[ 
  $(window).load(function(){
      var AnswerType = {
          Choice: 0,
          Input: 1
      };

      function question(label, answerType, choices) {
          return {
              label: label,
              answerType: answerType,
              choices: choices
          };
      }

      var view = (function() {
          function renderQuestion(target, question) {
              var questionWrapper = document.createElement('div');
              questionWrapper.className = 'question';

              var questionLabel = document.createElement('div');
              questionLabel.className = 'question-label';
              var label = document.createTextNode(question.label);
              questionLabel.appendChild(label);

              var answer = document.createElement('div');
              answer.className = 'question-input';

              if (question.answerType === AnswerType.Choice) {
                  var input = document.createElement('select');
                  var len = question.choices.length;
                  for (var i = 0; i < len; i++) {
                      var option = document.createElement('option');
                      option.text = question.choices[i];
                      option.value = question.choices[i];
                      input.appendChild(option);
                  }
              }
              else if (question.answerType === AnswerType.Input) {
                  var input = document.createElement('input');
                  input.type = 'text';
              }

              answer.appendChild(input);
              questionWrapper.appendChild(questionLabel);
              questionWrapper.appendChild(answer);
              target.appendChild(questionWrapper);
          }

          return {
              render: function(target, questions) {
                  for (var i = 0; i < questions.length; i++) {
                      renderQuestion(target, questions[i]);
                  };
              }
          };
      })();

      var questions = [
          question(
              'Have you used tobacco within the last 30 days?', 
              AnswerType.Choice, ['Yes', 'No']),
          question(
              'What medications are you currently using?', 
              AnswerType.Input)];

      var questionRegion = document.getElementById('questions');
      view.render(questionRegion, questions);
  });//]]>
  </script>
  <style type='text/css'>
    .question {
        clear: both;
        padding: 0 0 30px 0; 
    }
    .question-label {
        float: left;
        width: 240px;
        padding: 0 4px 0 0;
    }
  </style>
</head>
<body>
  <div id='questions'></div>
</body>
</html>

En este ejemplo, un objeto de vista contiene un método de procesamiento que hace preguntas basadas en cada tipo de pregunta recibida. Una cuestión consiste en una etiqueta, un tipo de respuesta (opción o entrada de texto), y una lista opcional de opciones. Si el tipo de respuesta es Answer.Choice, un menú desplegable se crea con las opciones disponibles. Si el tipo de respuesta es AnswerType.Input, una entrada de texto simple se representa.

Siguiendo el patrón ya establecido, para añadir nuevos tipos de entrada se requiere agregar nuevas condiciones en el método render. Esto violaría el principio abierto/cerrado.

Echemos un vistazo a una implementación alternativa que nos permita ampliar las capacidades de la vista de renderizado de objetos sin requerir cambios en el objeto vista para cada tipo de respuesta nuevo:

<!DOCTYPE html>
<html>
<head>
  <meta http-equiv="content-type" content="text/html; charset=UTF-8">
  <title>Open/Closed Principle: 
      Example 2 - jsFiddle demo by derekgreer</title>
  <script type='text/javascript' 
      src='http://code.jquery.com/jquery-1.7.1.js'></script>
  <script type='text/javascript'>//<![CDATA[ 
  $(window).load(function(){
      function questionCreator(spec, my) {
          var that = {};

          my = my || {};
          my.label = spec.label;

          my.renderInput = function() {
              throw "not implemented";
          };

          that.render = function(target) {
              var questionWrapper = document.createElement('div');
              questionWrapper.className = 'question';

              var questionLabel = document.createElement('div');
              questionLabel.className = 'question-label';
              var label = document.createTextNode(spec.label);
              questionLabel.appendChild(label);

              var answer = my.renderInput();

              questionWrapper.appendChild(questionLabel);
              questionWrapper.appendChild(answer);
              return questionWrapper;
          };

          return that;
      }

      function choiceQuestionCreator(spec) {

          var my = {},
              that = questionCreator(spec, my);

          my.renderInput = function() {
              var input = document.createElement('select');
              var len = spec.choices.length;
              for (var i = 0; i < len; i++) {
                  var option = document.createElement('option');
                  option.text = spec.choices[i];
                  option.value = spec.choices[i];
                  input.appendChild(option);
              }

              return input;
          };

          return that;
      }

      function inputQuestionCreator(spec) {

          var my = {},
              that = questionCreator(spec, my);

          my.renderInput = function() {
              var input = document.createElement('input');
              input.type = 'text';
              return input;
          };

          return that;
      }

      var view = {
          render: function(target, questions) {
              for (var i = 0; i < questions.length; i++) {
                  target.appendChild(questions[i].render());
              }
          }
      };

      var questions = [
          choiceQuestionCreator({
            label: 'Have you used tobacco within the last 30 days?',
            choices: ['Yes', 'No']
          }),
          inputQuestionCreator({
            label: 'What medications are you currently using?'
          })];

      var questionRegion = document.getElementById('questions');

      view.render(questionRegion, questions);
  });//]]>  
  </script>
  <style type='text/css'>
    .question {
        clear: both;
        padding: 0 0 30px 0; 
    }
    .question-label {
        float: left;
        width: 240px;
        padding: 0 4px 0 0;
    }
  </style>
</head>
<body>
  <div id='questions'></div>
</body>
</html>

Hay algunas técnicas que se utilizan aquí, así que vamos a ir viéndolas una a una.

En primer lugar, hemos refactorizado el código responsable de crear preguntas con respuesta en un constructor funcional llamado questionCreator. Este constructor utiliza el patrón Template Method para delegar la creación de cada respuesta a los tipos que queramos extender.

En segundo lugar, hemos reemplazado el uso de las propiedades del constructor anterior por una propiedad privada de especificaciones que sirve como interfaz al constructor questionCreator. Ya que estamos encapsulando el comportamiento de representación de los datos con los que opera, ya no necesitamos que estas propiedades sean de acceso público.

En tercer lugar, hemos identificado el código que crea cada tipo de respuesta como una familia de algoritmos y hemos refactorizado cada algoritmo en un objeto separado (una técnica conocida como el patrón Strategy) que extiende del objeto questionCreator utilizando herencia diferencial.

Como beneficio adicional a esta refactorización, hemos sido capaces de eliminar la necesidad de la enumeración AnswerType y pudimos extraer el array de decisiones como requisito específico de la interfaz choiceQuestionCreator.

La versión refactorizada del objeto vista puede ahora extenderse limpiamente simplemente extendiendo nuevos objetos questionCreator.

La próxima vez, vamos a discutir el tercer principio del acrónico SOLID: el principio de sustitución de Liskov.

0 comentarios:

Publicar un comentario